Browser security headers

Does obfuscation break Content-Security-Policy headers?

Most options objects are read by a runtime that has a default to fall back on. A Content-Security-Policy object is different: its keys are converted into text and shipped to a browser, and a browser has no defaults, no way to complain, and a specification that tells it to ignore any directive name it does not know. We measured what member renaming does to that.

What the sample actually does

The file holds a policy object with a reportOnly flag and a directives map containing seven entries: defaultSrc, scriptSrc with a nonce, styleSrc, objectSrc set to none, baseUri, frameAncestors set to none, and formAction. A small builder converts each camel-case key to its kebab-case directive name and joins the whole thing into one header value.

The reader is a browser, modelled by an unprotected module that parses the header the way the specification says to: recognised directive names take effect, unrecognised names are collected and ignored. It also implements the fallback chain, which turns out to matter more than anything else here.

Protection alone was applied on five presets and produced identical output every time. What follows is member renaming, and this area behaves unlike any other in the series because the renamed name does not stay inside the program - it becomes part of the response.

A header that is present, well formed, and enforces nothing

With a pattern matching all seven directive keys, the emitted header value was _0x1 'self'; _0x2 'self' 'nonce-r4nd0m'; _0x3 'self'; _0x4 'none'; _0x5 'self'; _0x6 'none'; _0x7 'self'. That is a syntactically valid Content-Security-Policy. It is the right length, it has the right shape, and it is served on the right header name with a 200.

Our verdict lines all flipped at once: inline script blocking, off-origin script blocking, framing lock and base-tag lock all moved from true to false, and the count of directives the browser ignored went from none to seven. There is no error, no console warning that reaches your server, and no change in status code.

Every monitoring check we have seen for CSP asks whether the header is present. All of them pass here. So does a scanner that checks the header is non-empty, and so does a compliance screenshot. The only check that fails is one that parses the header and looks for the directive names it expects.

The fallback chain decides which directives fail loudly

Renaming scriptSrc on its own does not leave scripts unrestricted, because script-src is a fetch directive and falls back to default-src. Our reported effective value moved from 'self' 'nonce-r4nd0m' to 'self'. Scripts are still restricted to the origin - but the nonce is gone, so every legitimately nonced inline script on the page is now blocked. That failure is loud and confusing rather than silent and dangerous.

base-uri, frame-ancestors and form-action are not fetch directives and have no fallback at all. Renaming frameAncestors moved our framing lock from true to false with nothing else changing: the page became embeddable, which is a clickjacking exposure, and no other line in the output moved. Renaming objectSrc and baseUri together degraded object loading from 'none' to the inherited 'self' and removed the base-tag restriction outright.

So the split is precise, and it is a property of the specification rather than of any tool. Directives that fall back to default-src degrade, usually noticeably. Directives with no fallback disappear, and their absence means permitted.

Two arms that did not move, for two different reasons

Renaming reportOnly changed nothing, and it is important to say why rather than to record it as safe. The flag was pinned to false, and the code that reads it treats a missing value as falsy, so the renamed key and the original produce the same header name. Had the flag been pinned true for a staged rollout, the same rename would have promoted a report-only policy to an enforcing one and broken the site in front of users.

Renaming directives, the container, also changed nothing - because the builder both writes and reads that name inside the same protected file, so both sides moved together. The same is true of the header object's own field names. Names you own on both sides are genuinely safe.

Put the two together and the shape of this area is clear. The container is safe, the flag is safe only because of the value it happened to hold, and the leaves are dangerous precisely because they leave the program as text.

Why this failure mode is new

Everywhere else in this series a renamed option key produces one of two outcomes: the runtime falls back to a default, or something throws. A CSP directive does neither. The name is serialised, transmitted, parsed by a different piece of software on a different machine, and discarded silently by a specification that requires forward compatibility.

Forward compatibility is the reason. A browser that met an unknown directive and refused the whole policy could never ship a new directive without breaking old sites, so the standard tells it to skip what it does not know. That is the correct design, and it is exactly what turns a renamed key into an invisible hole.

Anything else you assemble from object keys and send over a wire has the same property. Feature-Policy and Permissions-Policy headers, structured log fields, metrics labels, and query parameters built from an options object all convert a member name into data that somebody else parses leniently.

What to do about it

Exclude the directive names from your member pattern: defaultSrc, scriptSrc, styleSrc, imgSrc, connectSrc, objectSrc, baseUri, frameAncestors, formAction and the rest of the set you use. If you build the policy from a plain object literal, that is the whole fix.

Better, stop deriving directive names from member names at all. A policy expressed as an array of pairs, or loaded from configuration, uses string literals for the directive names - and string literals are not member names, so no renaming pattern can reach them. This is the same conclusion the sanitizer article reaches, and for the same reason.

Then add the check that catches everything in this article in one line: parse your own header in an integration test and assert that the set of recognised directive names is exactly the set you intended. Assert on names, not on presence. A test that checks the header exists passed on every single arm we measured.

Frequently asked questions

Does obfuscation change the Content-Security-Policy my app sends?

Only if member renaming matches the keys of the object you build the policy from. Protection alone produced an identical header on all five presets we tested. When the keys are renamed, the renamed names are serialised into the header itself.

What does a browser do with a directive name it does not recognise?

It ignores it. The specification requires that, so that new directives can ship without breaking old pages. The consequence here is that a header full of renamed directive names is accepted as valid and enforces nothing.

Would our monitoring notice?

Not if it checks that the header is present, which is what most CSP monitoring does. In our measurement the header was present, well formed and served with a 200 on every arm. Only a check that parses the value and looks for expected directive names would fail.

Which directives are the most dangerous to lose?

The ones with no fallback: base-uri, frame-ancestors and form-action. A missing frame-ancestors makes the page embeddable again, with nothing else in the response changing. Fetch directives such as script-src fall back to default-src instead, which degrades rather than disappears.

What happens to a script nonce?

Renaming script-src drops the whole directive, so the browser falls back to default-src and the nonce goes with it. Legitimately nonced inline scripts are then blocked, which is a visible breakage rather than a silent one.

Why did renaming the reportOnly flag change nothing?

Because it was pinned to false and the code treats a missing value as false, so the renamed key produced the same result. That is a property of the value, not evidence that the name is safe. Pinned true, for a staged rollout, the same rename would have turned an unenforced policy into an enforcing one.

What is the cleanest fix?

Do not derive directive names from member names. Build the policy from string literals or configuration, since string literals are never renamed. If you keep the object literal, exclude the directive keys from the member pattern and assert on the parsed directive names in a test.

Related reading