A Quarter Century of CSRF. Now the Browser Finally Handles It

3 hours ago by David Grudl  

Cross-Site Request Forgery has been with us since at least the turn of the millennium. The principle is primitive: a foreign website sends a request to your server from the browser of an unsuspecting victim. The browser happily attaches cookies to it, so the request looks like a legitimate action by a logged-in user: a password change, a form submission, an account deletion.

And for a whole quarter century it was handled poorly, with crutches on the application side.

The era of tokens and tricks

The classic defense: generate a random token, hide it in the form, store it in the session and compare on submit. It works, but it's clumsy: the token has to be in every form, it has to live somewhere, it expires, it fights with caching and with AJAX. In Nette it meant calling $form->addProtection() and, above all, never forgetting it anywhere. A single form without a token = a hole.

The first big relief came with SameSite cookies: a cookie can be marked with an attribute so the browser won't send it with requests from foreign websites. Nette built its own protection on top of that: it sent a special cookie _nss (formerly nette-samesite) with SameSite=Strict, and the isSameSite() method recognized from its presence that a request hadn't come from a foreign website. A clever trick, but with inherent limits. A cookie can only distinguish site, not origin: a request from any subdomain looks the same as one straight from your website, so a subdomain with user-generated content passes the check too. And it's still stateful information, an extra cookie sent with every request.

Sec-Fetch headers: A systemic solution at last!

And then browsers added Fetch Metadata: the Sec-Fetch-* headers, with which they tell you on every request where it came from and why:

Sec-Fetch-Site: cross-site   (a foreign website initiated the request)
Sec-Fetch-Dest: document     (the target is a page navigation)
Sec-Fetch-User: ?1           (the user triggered the action themselves)

No token, no cookie, no state on the server. Headers with the Sec- prefix can't be set by the browser for JavaScript or for extensions. Outside the browser anyone can of course add them, but an attacker's cURL doesn't have the victim's cookies, so for CSRF it's irrelevant. The point is that the victim's browser always tells the truth.

Nette deliberately waited with the rollout: the headers have existed since 2019, but Safari was the last to add them, only in version 16.4 (March 2023). Adoption is finally broad enough, so the new API arrives.

isFrom(): say where a request may come from

use Nette\Http\FetchSite;
use Nette\Http\FetchDest;

// did the request come from my origin?
$request->isFrom(FetchSite::SameOrigin);

// is it a page navigation triggered by the user themselves on my origin?
$request->isFrom(FetchSite::SameOrigin, FetchDest::Document, user: true);

The first parameter corresponds to the Sec-Fetch-Site header (the FetchSite enum), optionally you can narrow down the request target (the FetchDest enum: document, image, script, fetch…) and verify that the user triggered the action themselves.

Notice that unlike the cookie, you can now distinguish SameOrigin from SameSite: same-site allows subdomains too, same-origin requires an exact match of scheme, domain and port. For sensitive actions choose FetchSite::SameOrigin.

The original isSameSite() remains functional, it's just deprecated and internally delegates to isFrom().

Fallback for old iOS

One back door has stayed, at least for now: browsers without Sec-Fetch support, which today mainly means Safari older than 16.4. And because on iOS every browser is just a layer over the system WebKit, this affects older iPhones and iPads regardless of which browser runs on them.

In that case the good old _nss cookie steps in. Nette now sends it only to browsers that didn't send the Sec-Fetch-Site header, everywhere else the protection already runs completely without cookies.

Nette does it for you automatically

Maybe you don't even know it: forms in Nette have been defending themselves for a long time already. Since nette/forms 3.1 a POST coming from a foreign website isn't accepted at all unless you explicitly allow it via $form->allowCrossOrigin(). And signals in nette/application are automatically protected, as if they had the #[Requires(sameOrigin: true)] attribute. Both have so far internally relied on isSameSite(), that is on the _nss cookie.

What changes now:

  • Right away with nette/http 3.4 isSameSite() switches internally to the Sec-Fetch headers, so the existing automatic protection of forms and signals gets more precise without a single change to your code. One visible change in behavior: direct navigation (a bookmark, a manually typed address, a link from an email) is no longer considered same-site; previously it passed thanks to the strict cookie. If some signal of yours relies on action links in emails, mark it #[Requires(sameOrigin: false)].
  • With nette/forms 3.3 and nette/application 3.3 both switch from isSameSite() to isFrom(FetchSite::SameOrigin) and the protection gets a touch stricter still: requests from subdomains no longer pass. The sameOrigin attribute will thus for the first time check the actual origin, not just the site.

The end of addProtection()

And that brings us to the point: you actually don't need $form->addProtection() anymore today, the automatic protection covers the same thing. The last difference, protection against requests from subdomains, is erased by the switch to same-origin in forms 3.3. Tokens in the session, hidden fields and wondering whether we forgot one somewhere can all retire.

In practice this means: upgrade nette/http to 3.4, skip addProtection() on new forms and start removing it from existing ones.

It only took a quarter century.

The new API arrives with nette/http 3.4.0, forms 3.3 and application 3.3 to follow. The same release also adds protection against SSRF attacks.

David Grudl A web developer since 1999 who now specializes in artificial intelligence. He's the creator of Nette Framework and libraries including Texy!, Tracy, and Latte. He hosts the Tech Guys podcast and covers AI developments on Uměligence. His blog La Trine earned a Magnesia Litera award nomination. He's dedicated to AI education and approaches technology with pragmatic optimism.