Admin Save Returns "Forbidden" But the Post Loads Fine: Diagnosing a Hosting WAF Blocking CMS Saves

Admin Save Returns "Forbidden" But the Post Loads Fine: Diagnosing a Hosting WAF Blocking CMS Saves

TL;DR — Saving a specific, code-heavy article in the CMS admin returned a bare, plain-text 403 Forbidden. The post loaded and read fine; only saving it failed. Nothing was wrong with permissions or the CMS itself — the site's hosting-provider CDN/WAF was anomaly-scoring the request body and blocking it at the edge, before authentication was even checked.

Table of Contents
  1. How to recognize this variant of "save keeps failing"
  2. The diagnostic
  3. Root cause
  4. Fix options
  5. Variant: a single field silently fails to save, with no error shown
  6. How to recognize this variant
  7. Fix: a small reusable script beats a one-off SQL patch
  8. Lessons learned

How to recognize this variant of "save keeps failing"

  • The response body is plain text (e.g. just the word "Forbidden"), not your CMS's normal JSON error shape.
  • The Server response header names the CDN/hosting provider, not your app.
  • The same post saves fine with trivial content; other posts save fine too — only content-heavy saves die.

The diagnostic

Replay the same save payload without a valid session:

curl -i -X PUT https://your-site.com/_emdash/api/content/posts/<id> -d @payload.json
# 401 -> passed the WAF, hit your app's own auth check (not a WAF issue)
# 403 (plain text, no app error shape) -> blocked before auth ever ran
emdashkits.com
Read also:

Root cause

Hosting-provider CDNs/WAFs (e.g. Hostinger's hcdn, Cloudflare) score request bodies for injection-looking patterns. A save request ships the entire article body along with whatever field changed. Code-heavy content — many JS template literals (${...}) or piped shell one-liners in code blocks — adds to that anomaly score, and enough of it in one payload crosses the WAF's threshold.

Fix options

  • Rewrite the offending article's code blocks to avoid the triggering patterns — string concatenation instead of template literals, no shell pipes — then re-verify with the same unauthenticated probe.
  • For a durable fix, whitelist the CMS admin path (e.g. /_emdash/*) in the hosting panel's WAF rules.
  • If a save still won't go through no matter how the content is rewritten, writing the specific field directly via a DB script is a legitimate one-off escape hatch — confirm with whoever owns the content first, since it skips the app's own validation.

Variant: a single field silently fails to save, with no error shown

A subtler version of this bug does not throw any 403 at all. Uploading a featured image through the post editor can successfully register the media file and set the postseo_image field (used for Open Graph and social-card previews), while the separate write that links that same image to the postfeatured_image field silently drops. Same root cause, same WAF anomaly scoring, just tripped on a different write inside the same save sequence. Nothing in the admin UI flags it as a failure.

How to recognize this variant

  • The social/Open Graph preview for the post looks correct, but the featured-image thumbnail is missing in the admin post list, and there is no hero image at the top of the published post.
  • Comparing the post row against its SEO metadata row directly in the database shows the media file registered under seo_image, while featured_image on the same post is empty.

Fix: a small reusable script beats a one-off SQL patch

The one-off DB-script escape hatch above is fine for a single incident, but a field that keeps silently dropping across multiple articles is worth turning into a small reusable script instead of hand-writing the same lookup and update again each time it recurs. A script that resolves the post by slug, finds the already-uploaded media row, and writes that field in its exact JSON shape to both the post row and its revision data keeps the admin UI and public page in sync, and can fall back to whatever is already set in seo_image when no image is specified explicitly.

node scripts/set-featured-image.mjs --slug=your-post-slug
emdashkits.com

Lessons learned

  • A bare 403 with a non-app-shaped body, especially reproducible unauthenticated, is a hosting-layer signal — don't waste time auditing CMS permissions code first.
  • Writing technical articles about your own stack means the article body itself can trip this; write code samples defensively (concatenation, no pipes) as a habit, not just as a reaction.
  • A save that reports success can still have silently dropped one field. Cross-check a value mirrored elsewhere (like seo_image) against the field that looks missing, rather than assuming a clean save means every field made it through.
Share
Next Article

Newly Published Post Missing From Homepage/Sidebar Even Though the Database Says "Published": Stale Route Cache After a Direct DB Insert

Comments

Write a comment

Related Articles

Login Works in Production but Fails on localhost: The secure Cookie Flag Gotcha

July 16, 2026

Login Works in Production but Fails on localhost: The secure Cookie Flag Gotcha

Google Analytics (GA4) Not Tracking Anything and No Console Errors: Check Your CSP First

July 16, 2026

Google Analytics (GA4) Not Tracking Anything and No Console Errors: Check Your CSP First

Native Module Build Fails on Shared Hosting (node-gyp, Old glibc/Python): The .npmrc Fix

July 16, 2026

Native Module Build Fails on Shared Hosting (node-gyp, Old glibc/Python): The .npmrc Fix

Login Works in Production but Fails on localhost: The secure Cookie Flag Gotcha

Login Works in Production but Fails on localhost: The secure Cookie Flag Gotcha

TL;DR — A custom session-cookie login flow appeared to succeed on localhost (the OTP verified, the response looked fine) but every subsequent request to a login-gated page treated the visitor as logged out. Identical code worked fine on the live HTTPS site. The cookie's Secure attribute was hardcoded to true — and per the cookie spec, browsers never store or send a Secure cookie over a plain, non-HTTPS connection.

Table of Contents
  1. Diagnostic
  2. Root cause
  3. Fix
  4. Lessons learned

Diagnostic

Check the actual Set-Cookie response header and the browser's own cookie storage panel — on localhost over http://, the cookie is sent by the server but never actually stored by the browser.

Root cause

// before -- assumes the app is always served over HTTPS
setCookie("session", token, { secure: true, httpOnly: true });
emdashkits.com

A cookie config that quietly assumes "we're always on HTTPS" breaks the instant you test over plain HTTP, which local dev servers commonly are.

Read also:

Fix

// after -- derive secure from the actual request protocol
const isHttps = request.url.startsWith("https://");
setCookie("session", token, { secure: isHttps, httpOnly: true });
emdashkits.com

Lessons learned

  • Any Secure-flagged cookie needs to key off the real request scheme, not an assumption baked in once at cookie-creation time.
  • "Works in production, silently fails in local dev" is a strong signal to check cookie flags before anything else in an auth flow.
  • Check other cookies in the same codebase for the same hardcoded assumption — if one cookie has this bug, sibling cookies set the same way are worth auditing too.
Share
Previous Article

Google Analytics (GA4) Not Tracking Anything and No Console Errors: Check Your CSP First

Comments

Write a comment

Related Articles

Google Analytics (GA4) Not Tracking Anything and No Console Errors: Check Your CSP First

July 16, 2026

Google Analytics (GA4) Not Tracking Anything and No Console Errors: Check Your CSP First

Native Module Build Fails on Shared Hosting (node-gyp, Old glibc/Python): The .npmrc Fix

July 16, 2026

Native Module Build Fails on Shared Hosting (node-gyp, Old glibc/Python): The .npmrc Fix

Uploaded Images Disappear After Every Deploy on Shared Hosting (and Other Reverse-Proxy Gotchas)

July 16, 2026

Uploaded Images Disappear After Every Deploy on Shared Hosting (and Other Reverse-Proxy Gotchas)

Google Analytics (GA4) Not Tracking Anything and No Console Errors: Check Your CSP First

Google Analytics (GA4) Not Tracking Anything and No Console Errors: Check Your CSP First

TL;DR — GA4's gtag.js snippet was installed correctly, the page loaded with no visible JavaScript error, and GA4's real-time report still showed zero activity. The CMS's default Content-Security-Policy had no allowance for analytics domains and no config option to add one — so every request to Google's tracking endpoints was blocked at the browser level before it could fail loudly.

Table of Contents
  1. Diagnostic
  2. Root cause
  3. Lessons learned

Diagnostic

Check the browser's dedicated CSP violation reporting, not the regular console error list — CSP blocks are reported through their own channel, not thrown as normal script errors, so "no console errors" doesn't mean nothing was blocked.

Root cause

The CSP's script-src and connect-src directives had no entry for googletagmanager.com or google-analytics.com, and the CMS exposed no configuration surface to add one — the only way in was patching the CSP directives directly.

// patch-package: add analytics domains to the existing CSP directives
scriptSrc.push("https://www.googletagmanager.com");
connectSrc.push("https://www.google-analytics.com", "https://www.googletagmanager.com");
emdashkits.com
Read also:

Lessons learned

  • "No console errors" is not proof nothing was blocked — CSP violations live in their own reporting surface and are easy to miss if you're only scanning for red error text.
  • Before adding any third-party script tag to a site with a CSP already in place, check the CSP's directives first rather than assuming a silently-empty analytics dashboard means a snippet-installation mistake.
Share
Previous Article

Native Module Build Fails on Shared Hosting (node-gyp, Old glibc/Python): The .npmrc Fix

Next Article

Login Works in Production but Fails on localhost: The secure Cookie Flag Gotcha

Comments

Write a comment

Related Articles

Login Works in Production but Fails on localhost: The secure Cookie Flag Gotcha

July 16, 2026

Login Works in Production but Fails on localhost: The secure Cookie Flag Gotcha

Native Module Build Fails on Shared Hosting (node-gyp, Old glibc/Python): The .npmrc Fix

July 16, 2026

Native Module Build Fails on Shared Hosting (node-gyp, Old glibc/Python): The .npmrc Fix

Uploaded Images Disappear After Every Deploy on Shared Hosting (and Other Reverse-Proxy Gotchas)

July 16, 2026

Uploaded Images Disappear After Every Deploy on Shared Hosting (and Other Reverse-Proxy Gotchas)