Čtvrt století s CSRF. Teď ho konečně řeší prohlížeč

před 4 hodinami od David Grudl  

Cross-Site Request Forgery je s námi minimálně od přelomu tisíciletí. Princip je primitivní: cizí web pošle z prohlížeče nic netušící oběti požadavek na váš server. Prohlížeč k němu ochotně přibalí cookies, takže request vypadá jako legitimní akce přihlášeného uživatele: změna hesla, odeslání formuláře, smazání účtu.

A celé čtvrtstoletí se to řešilo chabě, protézami na straně aplikací.

Éra tokenů a triků

Klasická obrana: vygenerovat náhodný token, schovat ho do formuláře, uložit do session a při odeslání porovnat. Funguje to, ale je to upatlané: token musí být v každém formuláři, musí někde žít, expiruje, zlobí s cache i s AJAXem. V Nette to znamenalo volat $form->addProtection() a hlavně na to nikde nezapomenout. Jediný formulář bez tokenu = díra.

První velká úleva přišla se SameSite cookies: cookie jde označit atributem, aby ji prohlížeč k požadavkům z cizích webů neposílal. Nette na tom postavilo vlastní ochranu: posílalo speciální cookie _nss (dříve nette-samesite) se SameSite=Strict a metoda isSameSite() podle její přítomnosti poznala, že request nepřišel z cizího webu. Šikovný trik, ale s vrozenými limity. Cookie umí rozlišit jen site, ne origin: požadavek z libovolné subdomény vypadá stejně jako přímo z webu, takže subdoména s uživatelským obsahem projde kontrolou taky. A pořád je to stavová informace, posíláme cookie navíc v každém requestu.

Sec-Fetch hlavičky: Konečně systémové řešení!

A pak prohlížeče přidaly Fetch Metadata: hlavičky Sec-Fetch-*, kterými v každému requestu samy říkají, odkud a proč přišel:

Sec-Fetch-Site: cross-site   (request inicioval cizí web)
Sec-Fetch-Dest: document     (cílem je navigace na stránku)
Sec-Fetch-User: ?1           (akci vyvolal sám uživatel)

Žádný token, žádná cookie, žádný stav na serveru. Hlavičky s prefixem Sec- nedovolí prohlížeč nastavit ani JavaScriptu, ani rozšířením. Mimo prohlížeč si je samozřejmě dopíše kdokoli, jenže cURL útočníka nemá cookies oběti, takže pro CSRF je to bezpředmětné. Podstatné je, že prohlížeč oběti řekne vždycky pravdu.

Nette s nasazením schválně vyčkávalo: hlavičky sice existují od roku 2019, ale jako poslední je doplnilo Safari až ve verzi 16.4 (březen 2023). Konečně je adopce dostatečně široká a tak přichází nové API.

isFrom(): řekněte si, odkud smí request přijít

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

// přišel request z mého originu?
$request->isFrom(FetchSite::SameOrigin);

// je to navigace na stránku, kterou na mém originu vyvolal sám uživatel?
$request->isFrom(FetchSite::SameOrigin, FetchDest::Document, user: true);

První parametr odpovídá hlavičce Sec-Fetch-Site (enum FetchSite), volitelně lze zpřesnit cíl requestu (enum FetchDest: dokument, obrázek, skript, fetch…) a ověřit si, že akci spustil sám uživatel.

Všimněte si, že na rozdíl od cookie už jde rozlišit SameOrigin od SameSite: same-site povoluje i subdomény, same-origin vyžaduje přesnou shodu schématu, domény i portu. Pro citlivé akce volte FetchSite::SameOrigin.

Původní isSameSite() zůstává funkční, jen je deprecated a interně deleguje právě na isFrom().

Fallback pro staré iOS

Jedna zadní vrátka zatím raději zůstala: prohlížeče bez podpory Sec-Fetch, což dnes znamená hlavně Safari starší než 16.4. A protože na iOS je každý prohlížeč pouhou nadstavbou nad systémovým WebKitem, týká se to starších iPhonů a iPadů bez ohledu na to, jaký prohlížeč na nich běží.

V tomto případě nastupuje stará dobrá _nss cookie. Nette ji nově posílá pouze prohlížečům, které hlavičku Sec-Fetch-Site neposlaly, všude jinde už ochrana běží úplně bez cookies.

Nette to za vás dělá automaticky

Možná to ani nevíte: formuláře se v Nette brání samy už dávno. Od nette/forms 3.1 se POST přicházející z cizího webu vůbec nepřijme, dokud to explicitně nepovolíte přes $form->allowCrossOrigin(). A signály v nette/application jsou automaticky chráněné, jako by měly atribut #[Requires(sameOrigin: true)]. Obojí dosud interně stálo na isSameSite(), tedy na _nss cookie.

Co se teď změní:

  • Hned s nette/http 3.4 přechází isSameSite() interně na Sec-Fetch hlavičky, takže existující automatická ochrana formulářů i signálů se zpřesní bez jediné změny ve vašem kódu. Jedna viditelná změna chování: přímá navigace (záložka, ručně zadaná adresa, odkaz z e-mailu) se za same-site už nepovažuje; dříve procházela díky strict cookie. Pokud nějaký váš signál spoléhá na akční odkazy v e-mailech, označte ho #[Requires(sameOrigin: false)].
  • S nette/forms 3.3 a nette/application 3.3 přejde obojí z isSameSite() na isFrom(FetchSite::SameOrigin) a ochrana bude ještě o fous přísnější: requesty ze subdomén už neprojdou. Atribut sameOrigin tak bude poprvé kontrolovat skutečně origin, ne jen site.

Konec addProtection()

A tím se dostáváme k pointě: $form->addProtection() vlastně nepotřebujete už dnes, automatická ochrana kryje totéž. Poslední rozdíl, tedy ochranu před requesty ze subdomén, smaže přechod na same-origin ve forms 3.3. Tokeny v session, skrytá pole a přemýšlení, jestli jsme někde nezapomněli, můžou do důchodu.

Prakticky to znamená: povyšte nette/http na 3.4, u nových formulářů addProtection() vynechte a ze stávajících ho můžete začít mazat.

Trvalo to jen čtvrt století.

Nové API přichází s verzí nette/http 3.4.0, forms 3.3 a application 3.3 budou následovat. Stejná verze přidává i ochranu proti SSRF útokům.

David Grudl Je specialista na umělou inteligenci a webové technologie, tvůrce Nette Framework a dalších populárních open-source projektů. Publikuje na blozích Uměligence, phpFashion a La Trine. Školí práci s AI nástroji a moderuje pořad Tech Guys. Umělou inteligenci se snaží přiblížit lidem srozumitelným způsobem. Je kreativní a má smysl pro praktické využití technologií.