DEV Community

Cover image for CSP Broke Two of My Apps Today, in Opposite Directions
Nasrul Hazim
Nasrul Hazim

Posted on

CSP Broke Two of My Apps Today, in Opposite Directions

TL;DR — Content-Security-Policy took down parts of two different apps in one day. One app enabled it by accident and blocked its own fonts and analytics. The other had it locked down correctly, and that correctness broke a legally-required contact link. Same header, opposite failure modes, and neither one threw an error.


Failure one: a null that means "on"

I patched an app onto a newer baseline of our internal Laravel starter kit. The baseline shipped a SecurityHeaders middleware and a config/security.php that looked like this:

'headers' => [
    'csp' => env('SECURITY_CSP', null), // null = auto (production only)
    'csp_policy' => env(
        'SECURITY_CSP_POLICY',
        "default-src 'self'; ".
        "script-src 'self' 'unsafe-inline' 'unsafe-eval'; ".
        "style-src 'self' 'unsafe-inline'; ".
        "font-src 'self' data:; ".
        "connect-src 'self'; ".
        "frame-ancestors 'none'; ".
        "form-action 'self'"
    ),
],
Enter fullscreen mode Exit fullscreen mode

Read that default carefully. null doesn't mean "off". It means auto, and auto means on in production. The middleware branches on it:

private function cspEnabled(): bool
{
    $flag = config('security.headers.csp');

    return $flag === null ? app()->isProduction() : (bool) $flag;
}
Enter fullscreen mode Exit fullscreen mode

I did notice the flag during the patch. I set SECURITY_CSP=false in .env.example and moved on, which felt like enough.

It wasn't. .env.example is documentation, not configuration. The production box has its own .env written months ago, and that file has no SECURITY_CSP key at all. So env() returned the default, the default was null, null meant auto, and the app started enforcing default-src 'self' on a page that loads a webfont CDN, Google Tag Manager, the Meta Pixel and a Cloudflare analytics beacon.

Every one of those got blocked. The pages still rendered. Nothing 500'd, no exception hit the logs, no test failed — the fonts just fell back to system stacks and analytics quietly stopped recording. The only evidence lived in a browser console nobody had open.

The fix is boring, which is the point:

// Opt-in, not auto. An explicit null still restores the old behaviour.
'csp' => env('SECURITY_CSP', false),
Enter fullscreen mode Exit fullscreen mode

And then widen the default policy so it's actually usable when someone does turn it on — the font host under style-src and font-src, the tag manager and pixel under script-src, their beacon endpoints under connect-src.

The lesson isn't about CSP. It's about defaults that change behaviour based on environment. A null that resolves differently in production than in local is a config value you cannot test locally by definition. If a security control needs to be on, make it explicitly on, per-app, after somebody looked at the policy. "Secure by default" is a good instinct, but a policy that was never verified against the app's actual asset list isn't security — it's an outage with good intentions.

There's a second-order rule underneath it: a change to .env.example changes nothing that is already running. Any new key you introduce has exactly two safe forms — a safe default in config/, or a deploy step that writes the key. .env.example is neither.

Failure two: a CSP that was right, and broke something anyway

Different project, same afternoon. A small static marketing site, ships an intentionally brutal policy: script-src 'none', connect-src 'none'. It's a site for a children's app, and there's no JavaScript on it worth the attack surface.

It's proxied through Cloudflare, with Scrape Shield's Email Address Obfuscation on — a feature that rewrites every mailto: in the HTML into /cdn-cgi/l/email-protection#<hex> and injects a small script to decode it back in the browser.

You can see where that lands. The rewrite happens at the edge, the decoder is a script, and script-src 'none' blocks the decoder. So all ten contact links rendered as the literal string:

Enter fullscreen mode Exit fullscreen mode

...pointed at a dead /cdn-cgi/ URL. That address happens to be the PDPA access-and-deletion contact and the child-safety contact a store review requires to be reachable, so "it's just a mailto" wasn't available to me.

Three options:

  1. Turn off Email Obfuscation in the Cloudflare dashboard. Works. It's also invisible state living in a web console that no repo remembers, and it dies the day the site moves to another DNS provider or someone spins up a fresh zone.
  2. Loosen the CSP to admit the decoder script. Weakening script-src on a children's site to fix an email link is not a trade I'd defend in review.
  3. Use Cloudflare's own opt-out markers in the markup.

Option three. Cloudflare skips anything wrapped in <!--email_off--> / <!--email_on-->, so the whole fix is one tiny component:

---
/**
 * The contact address as a working mailto link.
 * Never write a bare mailto: anywhere else — route every one through this.
 */
import { CONTACT_EMAIL } from '../config';
---

<!--email_off--><a href={`mailto:${CONTACT_EMAIL}`}>{CONTACT_EMAIL}</a><!--email_on-->
Enter fullscreen mode Exit fullscreen mode

Ten mailto: links became ten <MailLink />. The behaviour now lives in version control, survives a DNS move, and the docblock tells the next person why the component exists at all — which is most of its value. A one-line component with no explanation is the kind of thing a tidy-up PR deletes six months later.

What the two have in common

Both failures were silent, and silent in the way that costs you the most: the page rendered, the deploy went green, and the thing that broke was something you only notice by going and looking.

Both also happened at a boundary where two systems each behaved correctly on their own. The middleware honoured its documented default. Cloudflare performed the feature it was asked to perform. Nobody's code was wrong; the combination was.

That's the shape I'd watch for. When you add a policy that says "deny everything not on this list", you have quietly signed up to maintain that list against every other system in the request path — your CDN, your edge proxy, your analytics vendor, your font host — none of which will tell you when they start needing something new.

Two practical habits I'm taking from today:

  • Enable restrictive headers explicitly, per app, after checking the real asset list. Not by inheriting a default from a shared baseline.
  • Encode edge behaviour in the repo, not in a dashboard. If a fix can be expressed in markup or config that ships with the code, prefer that over a toggle in someone's control panel.

And if you run a strict CSP behind Cloudflare: go check your mailto: links right now. I'll wait.

Top comments (0)