Сесии, бисквитки и 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 и забравя старото. Защо, е в „По-точно“.
Виж го
Сесията в код
Бисквитката и заявката са обекти. Сайтът, от който тръгва заявката, е поле: така в един процес, без мрежа, се вижда всичко, което браузърът и сървърът правят.
class Cookie { constructor(name, value, sameSite) { this.name = name; this.value = value; this.sameSite = sameSite; }
header() { return `${this.name}=${this.value}; HttpOnly; Secure; SameSite=${this.sameSite}; Path=/`; }}
// request: { site, method, path, cookies, form }// site: сайтът на страницата, от която тръгва заявкатаclass Shop { sessions = new Map(); // id → купувач addresses = new Map();
constructor(sameSite) { this.sameSite = sameSite; }
login(user, oldId) { // ново id при всеки вход; старото вече не важи this.sessions.delete(oldId); const id = randomBytes(32).toString("base64url"); this.sessions.set(id, user); return new Cookie("session", id, this.sameSite); }
who(request) { return this.sessions.get(request.cookies.session); }}randomBytes(32) от node:crypto дава криптографски случайни байтове, а toString("base64url") ги прави на текст, който може да стои в бисквитка: 43 знака.Мария влиза два пъти, после браузърът пробва новото id, старото и едно измислено:
const shop = new Shop("Lax");const browser = new Browser();const first = shop.login("maria");const second = shop.login("maria", first.value); // вход отновоconsole.log(`id: ${second.value.length} знака`);console.log(`двата входа дават различни id: ${first.value === second.value ? "не" : "да"}`);console.log("Set-Cookie: " + second.header().replace(second.value, "…"));browser.store(second);console.log(`новото id → ${visit(shop, browser)}`);browser.store(first);console.log(`старото id → ${visit(shop, browser)}`);browser.store(new Cookie("session", "123456", "Lax"));console.log(`измислено 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/addresschangeAddress(request) { const user = this.who(request); if (user === undefined) return 401; // няма сесия this.addresses.set(user, request.form.address); return 200;}login(user, oldId) { // ново id при всеки вход; старото вече не важи this.sessions.delete(oldId); const id = randomBytes(32).toString("base64url"); this.sessions.set(id, user); return new Cookie("session", id, this.sameSite);}// POST /account/addresschangeAddress(request) { const user = this.who(request); if (user === undefined) return 401; // няма сесия this.addresses.set(user, request.form.address); return 200;}function attach(cookie, site, method) { // същият сайт: бисквитката тръгва винаги if (site === "shop.example") return true; switch (cookie.sameSite) { case "None": return true; case "Lax": return method === "GET"; default: return false; // Strict }}changeAddress(request) { const s = this.session(request); if (s === undefined) return 401; // няма сесия const sent = request.form.csrf ?? ""; if (!same(sent, s.csrf)) return 403; this.addresses.set(s.user, request.form.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 { jar = new Map(); // бисквитките на shop.example
store(cookie) { this.jar.set(cookie.name, cookie); }
// страница от някой сайт праща заявка към магазина send(site, method, path, form = {}) { const cookies = {}; for (const c of this.jar.values()) { if (attach(c, site, method)) cookies[c.name] = c.value; } return { site, method, path, cookies, form }; }}
function attach(cookie, site, method) { // същият сайт: бисквитката тръгва винаги if (site === "shop.example") return true; switch (cookie.sameSite) { case "None": return true; case "Lax": return method === "GET"; default: return false; // Strict }}Обработчикът от проблема вярва на бисквитката:
// POST /account/addresschangeAddress(request) { const user = this.who(request); if (user === undefined) return 401; // няма сесия this.addresses.set(user, request.form.address); return 200;}Мария, магазинът и двете форми, първо със SameSite=None, после с Lax:
for (const sameSite of ["None", "Lax"]) { const shop = new Shop(sameSite); const browser = new Browser(); browser.store(shop.login("maria")); shop.addresses.set("maria", "ул. Витоша 15"); console.log(`SameSite=${sameSite}`); // страница на чужд сайт праща формата си към магазина show(shop, browser.send("free-gifts.example", "POST", "/account/address", { address: "ул. Измамна 13" })); // формата на самия магазин show(shop, browser.send("shop.example", "POST", "/account/address", { 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, а формата на самия магазин минава: тя е от същия сайт. Заявката е обикновен обект { site, method, path, cookies, form }, а не клас: в JavaScript това е най-краткият запис.
Пробвай
Предвиди изхода
Кога бисквитката тръгва? Какво ще отпечата?
const lax = new Cookie("session", "…", "Lax");const strict = new Cookie("session", "…", "Strict");console.log(attach(lax, "free-gifts.example", "GET"));console.log(attach(lax, "free-gifts.example", "POST"));console.log(attach(strict, "free-gifts.example", "GET"));console.log(attach(strict, "shop.example", "POST"));Lax пуска бисквитката от чужд сайт само при GET, затова първото е да, второто не. Strict не я пуска от чужд сайт никога, но на своя сайт тя тръгва винаги, и при POST.
Идеята
Токенът, който чуждата страница не може да прочете
SameSite е защита на браузъра. Тя е добра, но не стига сама: стари браузъри, поддомейни на същия сайт и обработчици, които сменят нещо при GET, я заобикалят. Затова магазинът има и своя защита: CSRF токен.
При входа сесията получава още едно случайно число, токена. Страницата на магазина го слага в скрито поле на своята форма. Обработчикът приема заявката само ако токенът във формата съвпада с този в сесията.
Чуждата страница може да прати заявка към магазина, но не може да прочете отговора му: браузърът не ѝ дава (това е same-origin policy). Затова тя не знае токена и не може да го сложи във формата.
login(user, oldId) { this.sessions.delete(oldId); const id = randomBytes(32).toString("base64url"); // и CSRF токен за формите const csrf = randomBytes(32).toString("base64url"); this.sessions.set(id, { user, csrf }); return new Cookie("session", id, this.sameSite);}
session(request) { return this.sessions.get(request.cookies.session);}
// страницата на магазина слага токена в скрито полеformToken(request) { return this.session(request).csrf;}changeAddress(request) { const s = this.session(request); if (s === undefined) return 401; // няма сесия const sent = request.form.csrf ?? ""; if (!same(sent, s.csrf)) return 403; this.addresses.set(s.user, request.form.address); return 200;}function same(a, b) { // сравнява всички байтове, без да спира при първата разлика const x = Buffer.from(a); const y = Buffer.from(b); return x.length === y.length && timingSafeEqual(x, y);}Дори със SameSite=None чуждата форма спира:
const shop = new Shop("None");const browser = new Browser();browser.store(shop.login("maria"));shop.addresses.set("maria", "ул. Витоша 15");console.log("SameSite=None + CSRF");show(shop, browser.send("free-gifts.example", "POST", "/account/address", { address: "ул. Измамна 13" }));// чуждият сайт не знае токена и пробва наслукиshow(shop, browser.send("free-gifts.example", "POST", "/account/address", { address: "ул. Измамна 13", csrf: "123456" }));// страницата на магазина слага токена в скрито полеconst token = shop.formToken(browser.send("shop.example", "GET", "/account"));show(shop, browser.send("shop.example", "POST", "/account/address", { address: "ул. Шипка 7", csrf: token }));SameSite=None + CSRF free-gifts.example → 403, адрес: ул. Витоша 15 free-gifts.example → 403, адрес: ул. Витоша 15 shop.example → 200, адрес: ул. Шипка 7Пробвай
Попълни
Коя функция сравнява токените, без да спира при първата разлика?
return x.length === y.length && (x, y);crypto.timingSafeEqual гледа всички байтове. Тя хвърля грешка при различна дължина, затова дължините се сравняват преди нея.
Пробвай
Намери грешката
Колега добавя CSRF токен и тестовете минават: грешен токен дава 403. Седмица по-късно адресите пак се сменят от чужд сайт. На кой ред е грешката?
Избери реда с грешката.
Токенът се проверява само ако го има. Чуждата форма просто не праща поле csrf и минава покрай проверката. Липсващият токен трябва да е отказ, както и грешният: request.form.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.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Данни, switch и разглобяване · Мрежово програмиране · Недоверен вход · Пароли
- Продължава в: JWT и капаните му · Мащабиране
- Задачи по темата: Сигурност за програмисти