CSRF: co to jest i jak chronić aplikację (SameSite i token)

Mateusz Bogolubow

21-10-2026

(aktualizacja: 21-10-2026)

Czym jest atak CSRF, jak działa SameSite (Lax, Strict, None), kiedy potrzebny jest token CSRF i dlaczego nagłówek Authorization go nie wymaga. Z testami w Chromium.

CSRF: co to jest i jak chronić aplikację (SameSite i token)
CSRF (Cross-Site Request Forgery) to atak, w którym obca strona zmusza przeglądarkę zalogowanego użytkownika do wysłania żądania do Twojej aplikacji, a przeglądarka dołącza do niego ciasteczko sesji automatycznie. Chronisz się przez ciasteczka z atrybutem `SameSite` (`Lax` lub `Strict`), zmianę stanu wyłącznie metodami innymi niż GET i token CSRF wysyłany w nagłówku. Gdy uwierzytelniasz żądania nagłówkiem `Authorization` ustawianym ręcznie w JavaScripcie, CSRF Cię nie dotyczy, ale wtedy trzeba pilnować XSS.

Zalogowałeś się do aplikacji bankowej, potem otworzyłeś w drugiej karcie inną stronę i bez Twojej wiedzy wykonał się przelew. Brzmi jak scenariusz z filmu, ale mechanizm jest prosty i wynika z tego, jak działają ciasteczka. Poniżej odtwarzam go na lokalnym serwerze testowym (FastAPI) i w prawdziwej przeglądarce (Chromium 151), a potem sprawdzam, co go zatrzymuje. „Przelew” to w moim teście zwykły licznik na serwerze, a „strona atakującego” działa na innym hoście na tym samym komputerze.

Czym jest CSRF?

Cross-Site Request Forgery oznacza podrobione żądanie między witrynami. Przeglądarka ma jedną ważną cechę: gdy wysyłasz żądanie do jakiejś domeny, dołącza do niego ciasteczka tej domeny, niezależnie od tego, która strona to żądanie wywołała. Jeśli Twoja sesja jest w ciasteczku, a inna strona każe przeglądarce wysłać formularz pod adres Twojej aplikacji, serwer zobaczy poprawne ciasteczko i uzna żądanie za prawdziwe.

Różni się to od XSS. Przy XSS atakujący uruchamia kod na Twojej stronie i może odczytać dane. Przy CSRF jego kod działa na swojej stronie i nie widzi odpowiedzi, ale potrafi wywołać akcję, która zmienia stan: przelew, zmianę hasła, usunięcie konta. O ataku, w którym cudzy kod trafia do przeglądarki, piszę w artykule o XSS w JavaScripcie.

Jak wygląda atak?

Serwer testowy ma logowanie, które ustawia ciasteczko sesji, i endpoint przelewu zaufany tylko na podstawie tego ciasteczka:

from typing import Annotated

from fastapi import Cookie, FastAPI, Form, HTTPException, Response

app = FastAPI()
state = {"balance": 1000, "transfers": 0}


@app.get("/login")
def login(response: Response):
    response.set_cookie("sid", "zalogowany", httponly=True)
    return {"zalogowano": True}


@app.post("/transfer")
def transfer(
    amount: Annotated[int, Form()],
    sid: Annotated[str | None, Cookie()] = None,
):
    if sid != "zalogowany":
        raise HTTPException(401, "Brak sesji")
    state["balance"] -= amount
    state["transfers"] += 1
    return state

Strona atakującego to jeden formularz, który wysyła się sam po załadowaniu:

<form id="f" method="POST" action="http://localhost:8000/transfer">
  <input name="amount" value="100">
</form>
<script>f.submit()</script>

Użytkownik jest zalogowany w aplikacji, wchodzi na tę stronę, a przeglądarka wysyła POST /transfer razem z ciasteczkiem sid. Użytkownik nie kliknął niczego w Twojej aplikacji. Czy atak się uda, zależy jednak od atrybutu SameSite ciasteczka.

Jak SameSite zmienia sytuację?

Atrybut SameSite mówi przeglądarce, kiedy wolno dołączyć ciasteczko do żądania pochodzącego z innej witryny. Sprawdziłem wszystkie warianty w Chromium. Zalogowany użytkownik wchodzi na stronę atakującego, a ja sprawdzam, czy przelew się wykonał:

CiasteczkoŻądanie z obcej stronyPrzelew
bez atrybutu SameSite, tuż po zalogowaniuformularz POSTwykonany
bez atrybutu SameSite, 130 s po zalogowaniuformularz POSTzablokowany
SameSite=Laxformularz POSTzablokowany
SameSite=Laxprzejście GET na adres z ?amount=wykonany
SameSite=Strictprzejście GETzablokowany
SameSite=None; Secureformularz POSTwykonany

Co z tego wynika:

  • Lax zatrzymuje obce POST-y, ale nie GET-y wywołane nawigacją. Dlatego endpoint zmieniający stan przez GET (/transfer-get?amount=100) jest podatny nawet przy Lax. Zmieniaj stan wyłącznie metodami POST, PUT, PATCH lub DELETE.
  • Strict blokuje wszystko, co przyszło z obcej strony, kosztem tego, że użytkownik wchodzący na Twój serwis z cudzego linku nie jest rozpoznawany jako zalogowany przy pierwszym wejściu.
  • Brak atrybutu to nie to samo co Lax. Chromium traktuje taki plik cookie jak Lax, ale przez pierwsze dwie minuty od jego ustawienia dopuszcza obce POST-y. W moim teście atak udał się zaraz po zalogowaniu, a po 130 sekundach już nie. Nie polegaj na takim zachowaniu domyślnym: ustaw atrybut jawnie. Zachowanie w Firefoksie i Safari nie jest tu sprawdzone.
  • SameSite=None wyłącza ochronę (i wymaga Secure). Potrzebujesz go, gdy front i API stoją na różnych witrynach, ale wtedy sam atrybut nie chroni przed CSRF.

W FastAPI warto wiedzieć, że Response.set_cookie bez podania samesite wysyła SameSite=lax (w wersji Starlette 1.9.0, którą testowałem). Mimo to ustawiaj wartość wprost, żeby nie zależeć od domyślnych ustawień biblioteki.

Czy SameSite zawsze wystarczy?

Nie, są trzy typowe luki:

  1. Front i API stoją na różnych witrynach (inna domena główna), więc ciasteczko musi mieć SameSite=None, czyli bez ochrony.
  2. Endpoint zmienia stan metodą GET.
  3. Witryna to nie origin. Strony app.example.com i api.example.com należą do tej samej witryny, więc SameSite ich nie rozdziela. Cross-origin, ale same-site żądania z podatnej subdomeny przejdą. Tego scenariusza nie testowałem.

Wtedy potrzebny jest drugi mechanizm: token.

Jak działa token CSRF?

Token to losowa wartość, którą serwer wydaje zalogowanemu użytkownikowi i oczekuje z powrotem w nagłówku każdego żądania zmieniającego stan. Przeglądarka dołącza ciasteczko automatycznie, ale nagłówek musi dopisać kod z Twojej strony. Atakujący nie zna tokenu, bo nie może go odczytać z Twojej aplikacji: blokuje to reguła tego samego pochodzenia (SOP), którą opisuje artykuł o CORS, SOP i nagłówkach HTTP.

import secrets
from typing import Annotated

from fastapi import Cookie, FastAPI, Form, Header, HTTPException, Response

app = FastAPI()
state = {"balance": 1000, "transfers": 0}
csrf_tokens = {}  # w prawdziwej aplikacji: powiązane z sesją w bazie


@app.get("/login")
def login(response: Response):
    response.set_cookie("sid", "zalogowany", httponly=True, samesite="lax")
    csrf_tokens["zalogowany"] = secrets.token_hex(16)
    return {"csrf_token": csrf_tokens["zalogowany"]}


@app.post("/transfer")
def transfer(
    amount: Annotated[int, Form()],
    sid: Annotated[str | None, Cookie()] = None,
    x_csrf_token: Annotated[str | None, Header()] = None,
):
    if sid not in csrf_tokens:
        raise HTTPException(401, "Brak sesji")
    if not x_csrf_token or not secrets.compare_digest(x_csrf_token, csrf_tokens[sid]):
        raise HTTPException(403, "Zły token CSRF")
    state["balance"] -= amount
    state["transfers"] += 1
    return state

Po stronie przeglądarki własna strona odbiera token po zalogowaniu i dołącza go do każdego żądania:

const { csrf_token } = await (await fetch('/login')).json();

await fetch('/transfer', {
  method: 'POST',
  body: new URLSearchParams({ amount: '100' }),
  headers: { 'X-CSRF-Token': csrf_token },
});

W teście ten sam atak z obcej strony, przy ciasteczku SameSite=None; Secure (czyli bez ochrony przeglądarki), zakończył się odpowiedzią 403 i licznik przelewów został równy 0. Własna strona z poprawnym tokenem dostała 200 i przelew się wykonał. (Test uruchomiłem z prostszą wersją porównania tokenów, a sprawdzenie compare_digest osobno. Używaj compare_digest, bo porównuje w stałym czasie.)

Kiedy CSRF w ogóle nie dotyczy aplikacji?

Gdy uwierzytelniasz żądania nagłówkiem Authorization: Bearer ..., który Twój kod JavaScript sam dopisuje, przeglądarka nie dołącza go do żądań z innych stron. Atakujący nie ma jak wstawić takiego nagłówka do swojego formularza. W teście próba cross-origin fetch z nagłówkiem Authorization do serwera bez CORS skończyła się błędem Failed to fetch: przeglądarka zablokowała ją przed wysłaniem.

To jednak nie znaczy, że token w nagłówku jest „bezpieczniejszy”. Zmienia się tylko ryzyko:

Sposób uwierzytelnianiaCSRFXSS
Ciasteczko sesji (dołączane automatycznie)tak, potrzebne SameSite lub tokenciasteczko HttpOnly nie wycieknie, ale atakujący wykona żądania z poziomu strony
Token w nagłówku, trzymany w localStoragenie dotyczytak, skrypt odczyta token
Token w nagłówku, trzymany w pamięci JavaScriptunie dotyczytrudniejszy do kradzieży, ale znika po odświeżeniu strony

Szczegóły o tokenach JWT i miejscu ich przechowywania znajdziesz w artykule o uwierzytelnianiu w FastAPI.

A co z frameworkami?

Popularne frameworki mają ochronę gotową. Django włącza CsrfViewMiddleware domyślnie i wymaga tokenu w formularzach, a we Flasku służy do tego rozszerzenie Flask-WTF. FastAPI nie ma wbudowanej ochrony CSRF, bo zakłada API uwierzytelniane nagłówkami. Jeśli używasz ciasteczek sesji, dodaj ochronę sam, tak jak powyżej. Tych frameworków nie testowałem tu osobno, opieram się na ich dokumentacji.

Jakie błędy mentor poprawia najczęściej?

Uwaga mentora: wybierz jeden sposób uwierzytelniania i poznaj jego ryzyko. Ciasteczko wymaga ochrony przed CSRF, token w nagłówku wymaga ochrony przed XSS. Mieszanie obu bez zrozumienia daje tylko złudzenie bezpieczeństwa.

Poniżej cała lista pułapek w jednym miejscu:

PułapkaSkutekJak uniknąć
„Przyjmuję tylko POST, więc jestem bezpieczny”POST też da się wysłać z obcej stronySameSite i token
Zmiana stanu przez GETatak przechodzi nawet przy SameSite=Laxtylko POST, PUT, PATCH, DELETE
Ciasteczko bez jawnego SameSitew Chromium przez 2 minuty obce POST-y przechodząustaw samesite="lax" lub "strict" wprost
SameSite=None bez tokenubrak jakiejkolwiek ochronytoken CSRF w nagłówku
Token CSRF w adresie URLtrafia do historii i logówtylko w nagłówku lub ukrytym polu
Porównanie tokenów zwykłym ==teoretycznie wyciek przez czas porównaniasecrets.compare_digest
Ciasteczko HttpOnly uznane za pełną ochronęchroni przed odczytem (XSS), nie przed CSRFto dwie osobne obrony

Co dalej?

Masz teraz komplet trzech podstawowych zagrożeń. Cudzy kod w przeglądarce opisuje XSS w JavaScripcie, a cudzy tekst w zapytaniu do bazy SQL injection i zapytania parametryzowane. Jeśli budujesz logowanie, zacznij od uwierzytelniania w FastAPI. Jeśli chcesz uczyć się budowania bezpiecznych aplikacji z mentorem, który ocenia Twój kod, sprawdź kurs Python backend.

Konsultacja i warsztaty devmentor.pl

Masz pytania?