A Quarter Century of CSRF. Now the Browser Finally Handles It
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()toisFrom(FetchSite::SameOrigin)and the protection gets a touch stricter still: requests from subdomains no longer pass. ThesameOriginattribute 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.
Further reading
- Nette Http 3.4.0 Brings Protection Against SSRF Attacks
- Nette Http 3.2: change access to credentials
- Nette Tester: HTTP testing has never been so easy
- Write Safer Code with the New Nette Database Documentation
- Latte 3.1: When a templating system truly understands HTML
- New Elements for Your Forms: Date, Time, Colors, and Floats
Sign in to submit a comment