Cross-site request forgery

Does obfuscation break CSRF token verification?

A CSRF middleware is configured almost entirely with narrowing: a per-session secret instead of a global one, a stricter list of methods to skip, a reader that looks in one place instead of four. Every one of those is a property name on an options object, and the middleware that reads them was installed rather than built. We protected a file that configures one, renamed the option names a group at a time, and counted which forged requests were accepted.

What the sample actually does

The file configures a CSRF middleware with six options and then puts six requests through it. Five of the six requests are supposed to be refused, and they are refused for five different reasons: no token at all, a token that arrived somewhere the application does not read, a token minted in a different session, a state-changing request sent with a method the default configuration would skip, and a token in the request body when the application only reads headers. One legitimate request is accepted. That arrangement is deliberate: if a single guard catches everything, the other guards report no change for a reason that has nothing to do with renaming.

The middleware itself is copied into the measurement directory unprotected, because that is the shape of the real thing. Your bundle is rebuilt when you protect it; the middleware in node_modules is not, and it keeps reading the property names it has always read. Its defaults are the permissive ones this family of libraries ships with: a single application-wide secret, a token reader that looks in the body, then the query string, then two header spellings, a safe-method list that includes OPTIONS, eighteen bytes of token entropy and no cookie flags.

Protection alone was applied first, on five presets covering both output targets, the gate profile and the compressed profile. All five produced output byte-identical in behaviour to the unprotected file. Nothing below is caused by protection on its own. Every result required member renaming aimed at the option names, which is what the MemberRegexp option exists to scope.

The token stopped being bound to a session

Renaming getSecret alone is the sharpest result in this area. That option supplies the per-session secret a token is derived from; without it the library falls back to one application-wide secret, which it is documented to do and which is a perfectly reasonable default for an application that has not thought about it yet.

The measured effect: a token minted in the attacker's own session, replayed against the victim's session, went from refused with the reason token-not-bound-to-this-session to accepted with the reason token-valid. The scope the middleware reported for itself went from per-session to application-wide. The number of accepted requests went from one in six to two in six.

There is no error anywhere in that. The middleware is doing exactly what it was asked: it was asked for a token that verifies against the secret, and the token does. What disappeared is the only thing that made a token belong to one caller. An attacker who can obtain a valid token at all - by signing up for the service, for instance - now holds a token that works for every user.

It is worth naming what an application-wide secret still buys you, because it is not nothing. It still stops a request that carries no token, which is the shape of the simplest forged form post. What it stops being is a defence against anybody who can get a token of their own, and on a service with self-service sign-up that is everybody.

The reader was downgraded, not removed

The second result is the same shape one of last week's findings had in a different library, which is why it is worth repeating here: a caller-supplied implementation of a guard does not switch the guard off when its name is lost. The library substitutes its own built-in, the guard reports that it ran, and it is weaker in exactly the situation you supplied yours for.

This application pins getTokenFromRequest to a function that reads the token from one request header and nowhere else. The library's built-in reader is deliberately generous: body field, then query string, then two header spellings. Renaming that one option took the reported source from caller-supplied to library-builtin and took a request carrying the token in the URL from refused to accepted. Accepted requests went from one in six to three in six, and the request that only ever had a body token was accepted too.

Every check an application would run to confirm CSRF protection is present still passes on that arm. The middleware is installed, it runs, it refuses a request with no token, and it accepts the legitimate one. What changed is that a token in the query string is now sufficient, and a token in the query string is a token in browser history, in the Referer header sent to every third-party asset on the page, in proxy access logs and in whatever aggregates those logs.

That is why an application narrows the reader in the first place. Losing the narrowing is invisible from the outside; it is only detectable by asking the middleware which reader it is using, which almost nothing does.

Two more arms, and the paired one that inverts the usual result

Renaming ignoreMethods restores the default safe-method list, which includes OPTIONS. The state-changing request this application deliberately still checks went from refused to accepted with the reason method-not-checked: the middleware did not decide the token was fine, it decided the request was none of its business. Renaming size took token entropy from 32 bytes to 18. Renaming cookieOptions dropped HttpOnly, Secure and SameSite=strict off the emitted Set-Cookie header while leaving the token itself intact.

Renaming getSecret and getTokenFromRequest together produces four accepted requests out of six, which is the pattern this series keeps finding: a wider rename is quieter and worse than either half.

This area produced the first counter-example to that pattern, and it is worth recording. Rename the reader option and the request-shape names the built-in reader looks for - the body, query and header properties - and the built-in reader finds nothing anywhere. Accepted requests went from one in six to zero in six. The legitimate request is refused along with all five forged ones, so the application is broken, loudly, in the first minute of testing. Widening the pattern made it safe rather than more dangerous, because the fallback path was starved of the very structure it falls back onto.

That is a narrow escape rather than a design principle. It happens only because the built-in reader and the request objects live in the same bundle; had the request objects come from the web framework, as they do in production, the renaming would not have reached them and the quiet result would have stood.

The self-owned names, and the direction that crashes

Two arms in this area measured no change at all, and both are the expected kind. The signed option is pinned to a value identical to the library default, so losing it changes nothing - the sixth consecutive pass in which every option pinned that way behaved exactly as predicted. The session object and its identifier are written and read entirely inside the protected file, so renaming them renames both sides consistently and the behaviour is preserved.

The second one carries the caveat that matters. It is only inert because both ends are in your bundle. The moment a session identifier is read out of a framework object your build did not produce, it is a contract name and behaves like every other contract name in this series.

Renaming the fields of the verdict the middleware hands back - accepted, reason, checked, the reported reader and the reported secret scope - is the quiet direction. The middleware kept working perfectly; the application could not read any of its answers. The three security assertions the sample makes all reported undefined, and the count of accepted requests dropped to zero because a falsy read is not an acceptance.

That is benign in this sample and would not be in an application whose next line is if (!result.accepted) reject() written as if (result.accepted === false) reject(). The first form fails closed on undefined; the second does not. Which one you wrote decides whether a middleware you can no longer hear is a refusal or an approval.

What to do about it

The mechanism is not specific to CSRF and it is not a defect in the obfuscator. Member renaming rewrites property names inside the code it is given. An installed middleware is not inside that code, so a renamed option name is an option name the middleware has never heard of, and every mature library ignores what it does not recognise and applies its documented default. The defaults are permissive because they have to work for an application that has configured nothing.

The practical control is scoping. RenameMembers takes a MemberRegexp, and options objects handed to third-party middleware belong outside it. If you would rather not maintain an exclusion list, build the options object with quoted string keys and read it with bracket access using literal strings you wrote - both survive renaming - or move the configuration into a JSON file the build does not transform.

The verification that would have caught every arm in this article takes one assertion. Ask the middleware what it is using rather than whether it is installed: log the reported secret scope and the reported token reader at start-up and fail the boot if either is not the value you configured. A test that posts a forged request and expects a rejection passes on four of the six arms measured here, because a middleware with an application-wide secret still refuses a request that carries no token at all.

Frequently asked questions

Does protecting my JavaScript break CSRF protection on its own?

Not in this measurement. The sample was protected on five profiles covering both output targets, the gate profile and the compressed profile, and all five behaved identically to the unprotected file. Every result in this article required member renaming pointed at the option names.

Why does a renamed option name not raise an error?

Because a middleware receives an ordinary object and reads the property names it knows. A name it has never heard of is not an error, it is simply absent, and the documented default applies. Every mature library behaves this way; refusing unknown keys would break forward compatibility.

Which single option was the worst to lose?

The per-session secret. Losing it made a token minted in one session verify in another, which converts CSRF protection from a per-user check into a proof that the caller has a token at all. On a service with open sign-up, anybody can obtain one.

If the token reader falls back to the library's own, is that safe?

It is weaker in the specific way you configured yours to avoid. The built-in reader accepts a token from the query string, which means the token reaches browser history, the Referer header and proxy logs. The guard still reports that it ran, so no health check notices.

Does a stricter SameSite cookie make this moot?

It helps a great deal and it is not a substitute. SameSite is enforced by the browser, so it does nothing for a non-browser client, for a request that is same-site but cross-origin, or where a legacy browser or a relaxed default applies. It is also one of the flags that disappeared when the cookie options object was renamed.

How do I tell whether my build is affected?

Log the values the middleware reports about itself at start-up - which token reader it selected and whether its secret is per-session - and compare them with what you configured. Do that in the protected artifact, not in the source, because the source is not what runs.

What is the recommended fix?

Scope member renaming so that options objects handed to installed middleware are excluded, or write those objects with quoted string keys and read them with literal bracket access. Both survive renaming. Then add the start-up assertion above so a future configuration change cannot silently regress.

Related reading