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 strony | Przelew |
|---|---|---|
bez atrybutu SameSite, tuż po zalogowaniu | formularz POST | wykonany |
bez atrybutu SameSite, 130 s po zalogowaniu | formularz POST | zablokowany |
SameSite=Lax | formularz POST | zablokowany |
SameSite=Lax | przejście GET na adres z ?amount= | wykonany |
SameSite=Strict | przejście GET | zablokowany |
SameSite=None; Secure | formularz POST | wykonany |
Co z tego wynika:
Laxzatrzymuje obce POST-y, ale nie GET-y wywołane nawigacją. Dlatego endpoint zmieniający stan przez GET (/transfer-get?amount=100) jest podatny nawet przyLax. Zmieniaj stan wyłącznie metodami POST, PUT, PATCH lub DELETE.Strictblokuje 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 jakLax, 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=Nonewyłącza ochronę (i wymagaSecure). 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:
- Front i API stoją na różnych witrynach (inna domena główna), więc ciasteczko musi mieć
SameSite=None, czyli bez ochrony. - Endpoint zmienia stan metodą GET.
- Witryna to nie origin. Strony
app.example.comiapi.example.comnależą do tej samej witryny, więcSameSiteich 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 uwierzytelniania | CSRF | XSS |
|---|---|---|
| Ciasteczko sesji (dołączane automatycznie) | tak, potrzebne SameSite lub token | ciasteczko HttpOnly nie wycieknie, ale atakujący wykona żądania z poziomu strony |
Token w nagłówku, trzymany w localStorage | nie dotyczy | tak, skrypt odczyta token |
| Token w nagłówku, trzymany w pamięci JavaScriptu | nie dotyczy | trudniejszy 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łapka | Skutek | Jak uniknąć |
|---|---|---|
| „Przyjmuję tylko POST, więc jestem bezpieczny” | POST też da się wysłać z obcej strony | SameSite i token |
| Zmiana stanu przez GET | atak przechodzi nawet przy SameSite=Lax | tylko POST, PUT, PATCH, DELETE |
Ciasteczko bez jawnego SameSite | w Chromium przez 2 minuty obce POST-y przechodzą | ustaw samesite="lax" lub "strict" wprost |
SameSite=None bez tokenu | brak jakiejkolwiek ochrony | token CSRF w nagłówku |
| Token CSRF w adresie URL | trafia do historii i logów | tylko w nagłówku lub ukrytym polu |
Porównanie tokenów zwykłym == | teoretycznie wyciek przez czas porównania | secrets.compare_digest |
Ciasteczko HttpOnly uznane za pełną ochronę | chroni przed odczytem (XSS), nie przed CSRF | to 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.


