Сесии, бисквитки и CSRF
Накратко: CSRF е атака, при която чужд сайт праща заявка към твоя и браузърът добавя бисквитката на сесията; спира се със SameSite и CSRF токен.
Проблем
Адресът, който Мария не смени
В поддръжката на магазина пише купувачката Мария: пратката ѝ е заминала за „ул. Измамна 13“, а тя живее на „ул. Витоша 15“. Паролата ѝ е силна и никой не я знае. В лога на магазина има заявка POST /account/address в 21:14, с нейната сесия, от нейния браузър.
Мария се кълне, че в 21:14 не е била в магазина. Била е на страница „Спечели награда!“ в друг раздел. Никой не е откраднал паролата ѝ, нито бисквитката ѝ. Как тогава нейният браузър е сменил адреса ѝ?
Идеята
Как магазинът помни, че си влязъл
HTTP няма памет: всяка заявка е отделна (видя го в урока за мрежата). Затова след входа магазинът прави сесия: записва в таблица „това id е Мария“ и праща id-то на браузъра в бисквитка (cookie), със заглавката Set-Cookie. Оттам нататък браузърът сам праща бисквитката с всяка заявка към магазина, а магазинът намира Мария по id-то.
Id-то е като номерче от гардероба: който го носи, взима палтото. Затова то трябва да е:
- случайно: 32 байта от криптографски генератор, не брояч и не обикновен
Random. Иначе следващото id се познава; - HttpOnly: скриптовете на страницата не го виждат. Дори чужд скрипт, вкаран с XSS, не може да го прочете;
- Secure: тръгва само по HTTPS, не по отворен канал;
- SameSite: кога тръгва към магазина от страница на друг сайт. За него е втората половина на урока.
Паролата се проверява веднъж, при входа (както в урока за паролите), а после работи само id-то. Затова при всеки вход магазинът дава ново id и забравя старото. Защо, е в „По-точно“.
Виж го
Сесията в код
Бисквитката и заявката са обекти. Сайтът, от който тръгва заявката, е поле: така в един процес, без мрежа, се вижда всичко, което браузърът и сървърът правят.
record Cookie(String name, String value, String sameSite) { String header() { return name + "=" + value + "; HttpOnly; Secure; SameSite=" + sameSite + "; Path=/"; }}
// site: сайтът на страницата, от която тръгва заявкатаrecord Request(String site, String method, String path, Map<String, String> cookies, Map<String, String> form) {}class Shop { static final SecureRandom RANDOM = new SecureRandom(); final String sameSite; final Map<String, String> sessions = new HashMap<>(); // id → купувач final Map<String, String> addresses = new HashMap<>();
Shop(String sameSite) { this.sameSite = sameSite; }
static String newId() { byte[] bytes = new byte[32]; RANDOM.nextBytes(bytes); return Base64.getUrlEncoder().withoutPadding() .encodeToString(bytes); }
Cookie login(String user, String oldId) { // ново id при всеки вход; старото вече не важи if (oldId != null) sessions.remove(oldId); String id = newId(); sessions.put(id, user); return new Cookie("session", id, sameSite); }
String who(Request request) { return sessions.get(request.cookies().get("session")); }}SecureRandom дава криптографски случайни байтове, а Base64.getUrlEncoder().withoutPadding() ги прави на текст, който може да стои в бисквитка: 43 знака.Мария влиза два пъти, после браузърът пробва новото id, старото и едно измислено:
Shop shop = new Shop("Lax");Browser browser = new Browser();Cookie first = shop.login("maria", null);Cookie second = shop.login("maria", first.value()); // вход отновоSystem.out.println("id: " + second.value().length() + " знака");System.out.println("двата входа дават различни id: " + (first.value().equals(second.value()) ? "не" : "да"));System.out.println("Set-Cookie: " + second.header().replace(second.value(), "…"));browser.store(second);System.out.println("новото id → " + visit(shop, browser));browser.store(first);System.out.println("старото id → " + visit(shop, browser));browser.store(new Cookie("session", "123456", "Lax"));System.out.println("измислено id → " + visit(shop, browser));id: 43 знакадвата входа дават различни id: даSet-Cookie: session=…; HttpOnly; Secure; SameSite=Lax; Path=/новото id → mariaстарото id → никойизмислено id → никойId-тата са различни при всяко пускане, затова програмата печата само дължината и „да/не“. Старото id след втория вход вече не води до никого.
Идеята
Браузърът праща бисквитката сам
Ето какво е имало на страницата „Спечели награда!“:
<form action="https://shop.example/account/address" method="post"> <input type="hidden" name="address" value="ул. Измамна 13"></form><script>document.forms[0].submit()</script>Скрита форма към магазина и ред, който я праща веднага. Атакуващият не знае бисквитката на Мария и не му трябва. Браузърът вижда заявка към shop.example и сам слага бисквитката на магазина. Обработчикът намира Мария по нея и сменя адреса.
Това се казва CSRF (cross-site request forgery, подправена заявка от чужд сайт). Бисквитката доказва кой е в браузъра, но не и коя страница е пуснала заявката. Обработчик, който вярва само на бисквитката, вярва и на всяка страница в интернет, която Мария отвори.
Виж го
Чуждата форма и двете защити
// POST /account/addressint changeAddress(Request request) { String user = who(request); if (user == null) return 401; // няма сесия addresses.put(user, request.form().get("address")); return 200;}Cookie login(String user, String oldId) { // ново id при всеки вход; старото вече не важи if (oldId != null) sessions.remove(oldId); String id = newId(); sessions.put(id, user); return new Cookie("session", id, sameSite);}// POST /account/addressint changeAddress(Request request) { String user = who(request); if (user == null) return 401; // няма сесия addresses.put(user, request.form().get("address")); return 200;}static boolean attach(Cookie cookie, String site, String method) { // същият сайт: бисквитката тръгва винаги if (site.equals("shop.example")) return true; return switch (cookie.sameSite()) { case "None" -> true; case "Lax" -> method.equals("GET"); default -> false; // Strict };}int changeAddress(Request request) { Session s = session(request); if (s == null) return 401; // няма сесия String sent = request.form().getOrDefault("csrf", ""); if (!same(sent, s.csrf())) return 403; addresses.put(s.user(), request.form().get("address")); return 200;}Стъпка по стъпка, с думи
- Мария влиза в shop.example. Паролата е вярна, затова магазинът ѝ отваря сесия.
- Id-то на сесията е 32 случайни байта. Магазинът помни: това id е Мария.
- Id-то тръгва обратно в бисквитката session. Оттук нататък браузърът я праща с всяка заявка към магазина.
- В друг раздел Мария отваря free-gifts.example. Страницата там има скрита форма към магазина и я праща сама.
- Бисквитката е SameSite=None, затова браузърът я слага на заявката. Обработчикът намира Мария по нея.
- Той вярва на бисквитката и сменя адреса: 200, ул. Измамна 13. Мария не е натиснала нищо в магазина.
- Поправка 1: SameSite=Lax. При POST от чужд сайт браузърът оставя бисквитката вкъщи. Страниците на магазина пак я получават.
- Същата форма, но без бисквитката обработчикът не вижда сесия: 401. Адресът остава ул. Витоша 15.
- Поправка 2: CSRF токен. Сесията пази случаен токен, а страницата на магазина го слага в скрито поле на формата си.
- Дори при SameSite=None чуждата страница не може да прочете токена. Формата ѝ идва с бисквитката, но без него: 403.
- Формата на магазина праща токена и той съвпада: 200, ул. Шипка 7.
- Бисквитката казва кой си. Само токенът казва, че заявката идва от страница на магазина.
Браузърът решава дали бисквитката тръгва. Правилото е в атрибута SameSite: None (тръгва винаги), Lax (от чужд сайт само при GET) и Strict (от чужд сайт никога).
class Browser { final Map<String, Cookie> jar = new HashMap<>(); // бисквитките на shop.example
void store(Cookie cookie) { jar.put(cookie.name(), cookie); }
// страница от някой сайт праща заявка към магазина Request send(String site, String method, String path, Map<String, String> form) { Map<String, String> cookies = new HashMap<>(); for (Cookie c : jar.values()) { if (attach(c, site, method)) cookies.put(c.name(), c.value()); } return new Request(site, method, path, cookies, form); }
static boolean attach(Cookie cookie, String site, String method) { // същият сайт: бисквитката тръгва винаги if (site.equals("shop.example")) return true; return switch (cookie.sameSite()) { case "None" -> true; case "Lax" -> method.equals("GET"); default -> false; // Strict }; }}Обработчикът от проблема вярва на бисквитката:
// POST /account/addressint changeAddress(Request request) { String user = who(request); if (user == null) return 401; // няма сесия addresses.put(user, request.form().get("address")); return 200;}Мария, магазинът и двете форми, първо със SameSite=None, после с Lax:
for (String sameSite : List.of("None", "Lax")) { Shop shop = new Shop(sameSite); Browser browser = new Browser(); browser.store(shop.login("maria", null)); shop.addresses.put("maria", "ул. Витоша 15"); System.out.println("SameSite=" + sameSite); // страница на чужд сайт праща формата си към магазина show(shop, browser.send("free-gifts.example", "POST", "/account/address", Map.of("address", "ул. Измамна 13"))); // формата на самия магазин show(shop, browser.send("shop.example", "POST", "/account/address", Map.of("address", "ул. Шипка 7")));}SameSite=None free-gifts.example → 200, адрес: ул. Измамна 13 shop.example → 200, адрес: ул. Шипка 7SameSite=Lax free-gifts.example → 401, адрес: ул. Витоша 15 shop.example → 200, адрес: ул. Шипка 7Със Lax чуждата форма получава 401, а формата на самия магазин минава: тя е от същия сайт.
Пробвай
Предвиди изхода
Кога бисквитката тръгва? Какво ще отпечата?
Cookie lax = new Cookie("session", "…", "Lax");Cookie strict = new Cookie("session", "…", "Strict");System.out.println(Browser.attach(lax, "free-gifts.example", "GET"));System.out.println(Browser.attach(lax, "free-gifts.example", "POST"));System.out.println(Browser.attach(strict, "free-gifts.example", "GET"));System.out.println(Browser.attach(strict, "shop.example", "POST"));Lax пуска бисквитката от чужд сайт само при GET, затова първото е да, второто не. Strict не я пуска от чужд сайт никога, но на своя сайт тя тръгва винаги, и при POST.
Идеята
Токенът, който чуждата страница не може да прочете
SameSite е защита на браузъра. Тя е добра, но не стига сама: стари браузъри, поддомейни на същия сайт и обработчици, които сменят нещо при GET, я заобикалят. Затова магазинът има и своя защита: CSRF токен.
При входа сесията получава още едно случайно число, токена. Страницата на магазина го слага в скрито поле на своята форма. Обработчикът приема заявката само ако токенът във формата съвпада с този в сесията.
Чуждата страница може да прати заявка към магазина, но не може да прочете отговора му: браузърът не ѝ дава (това е same-origin policy). Затова тя не знае токена и не може да го сложи във формата.
Cookie login(String user, String oldId) { if (oldId != null) sessions.remove(oldId); String id = newId(); sessions.put(id, new Session(user, newId())); // и CSRF токен за формите return new Cookie("session", id, sameSite);}
Session session(Request request) { return sessions.get(request.cookies().get("session"));}
// страницата на магазина слага токена в скрито полеString formToken(Request request) { return session(request).csrf();}int changeAddress(Request request) { Session s = session(request); if (s == null) return 401; // няма сесия String sent = request.form().getOrDefault("csrf", ""); if (!same(sent, s.csrf())) return 403; addresses.put(s.user(), request.form().get("address")); return 200;}static boolean same(String a, String b) { // сравнява всички байтове, без да спира при първата разлика return MessageDigest.isEqual(a.getBytes(UTF_8), b.getBytes(UTF_8));}Дори със SameSite=None чуждата форма спира:
Shop shop = new Shop("None");Browser browser = new Browser();browser.store(shop.login("maria", null));shop.addresses.put("maria", "ул. Витоша 15");System.out.println("SameSite=None + CSRF");show(shop, browser.send("free-gifts.example", "POST", "/account/address", Map.of("address", "ул. Измамна 13")));// чуждият сайт не знае токена и пробва наслукиshow(shop, browser.send("free-gifts.example", "POST", "/account/address", Map.of("address", "ул. Измамна 13", "csrf", "123456")));// страницата на магазина слага токена в скрито полеString token = shop.formToken(browser.send("shop.example", "GET", "/account", Map.of()));show(shop, browser.send("shop.example", "POST", "/account/address", Map.of("address", "ул. Шипка 7", "csrf", token)));SameSite=None + CSRF free-gifts.example → 403, адрес: ул. Витоша 15 free-gifts.example → 403, адрес: ул. Витоша 15 shop.example → 200, адрес: ул. Шипка 7Пробвай
Попълни
Коя функция сравнява токените, без да спира при първата разлика?
return MessageDigest.(a.getBytes(UTF_8), b.getBytes(UTF_8));MessageDigest.isEqual гледа всички байтове. Различната дължина не е тайна: всички токени на магазина са дълги по 43 знака.
Пробвай
Намери грешката
Колега добавя CSRF токен и тестовете минават: грешен токен дава 403. Седмица по-късно адресите пак се сменят от чужд сайт. На кой ред е грешката?
Избери реда с грешката.
Токенът се проверява само ако го има. Чуждата форма просто не праща поле csrf и минава покрай проверката. Липсващият токен трябва да е отказ, както и грешният: getOrDefault("csrf", "") и после сравнение, без условие. Проверката на сигурността отказва, когато се съмнява, не пуска.
По-точно, за любопитните
Session fixation. Атакуващият взима id от магазина (например като гост) и го подхвърля на Мария: в линк, в бисквитка на поддомейн. Ако магазинът пази същото id и след входа ѝ, атакуващият вече е влязъл като Мария. Затова при вход старото id се изтрива и се дава ново, както прави login в урока.
Изход. При изход сесията се трие на сървъра, не само бисквитката в браузъра. Иначе откраднатото id работи и след изхода.
Lax по-точно. Lax пуска бисквитката от чужд сайт само при навигация на цялата страница с „безопасен“ метод (GET, HEAD): когато Мария натисне линк към магазина. Скрити заявки с fetch или в <iframe> не я получават. Затова GET никога не трябва да сменя нищо: иначе един линк стига за атаката. Ако бисквитката е без SameSite, Chrome и Edge я третират като Lax, а Firefox и Safari не винаги. Затова атрибутът се пише изрично.
Проверка на Origin. Браузърът праща заглавките Origin и Sec-Fetch-Site, които казват откъде е заявката, а чужда страница не може да ги подправи. Проверката им е добър трети слой.
В истински проект токенът го прави рамката: Spring Security (включено по подразбиране), ASP.NET Core antiforgery, Django (CsrfViewMiddleware), а в Express библиотека като csrf-csrf. Сесиите също са готови: HttpSession в Java, ISession в ASP.NET Core, сесиите на Django и Flask, express-session. Префиксът __Host- в името на бисквитката (__Host-session) кара браузъра да иска Secure, Path=/ и без Domain.
HTTPS (сертификатите и настройката на сървъра) е част от инфраструктурата и се учи в курса по DevOps ZeroToOps.
Упражнение
Отказ на поръчка
Реши задачата в редактора си.
Магазинът има и форма „Откажи поръчка“: POST /orders/cancel с поле order. Напиши обработчика cancelOrder: без сесия връща 401, без верен CSRF токен 403, иначе отбелязва поръчката като отказана и връща 200. Пробвай го с три заявки, като бисквитката е SameSite=None: от чуждия сайт с бисквитката на Мария, но без токен; от браузър без вход; от страницата на магазина с токена.
чужд сайт: 403, поръчка 1042: активнабез вход: 401, поръчка 1042: активнамагазинът: 200, поръчка 1042: отказанаОще задачи
- Нов токен. Давай нов CSRF токен след всяка успешна смяна на адреса. Какво става, ако Мария е отворила формата в два раздела?
- Origin. Добави в заявката поле
originи откажи всяка заявка, която сменя нещо и не е отshop.example.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: record, switch и шаблони · Мрежово програмиране · Недоверен вход · Пароли
- Продължава в: JWT и капаните му · Мащабиране
- Задачи по темата: Сигурност за програмисти