Към съдържанието
wannacode

Сесии, бисквитки и 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/address
changeAddress(request) {
const user = this.who(request);
if (user === undefined) return 401; // няма сесия
this.addresses.set(user, request.form.address);
return 200;
}
Стъпка по стъпка, с думи
  1. Мария влиза в shop.example. Паролата е вярна, затова магазинът ѝ отваря сесия.
  2. Id-то на сесията е 32 случайни байта. Магазинът помни: това id е Мария.
  3. Id-то тръгва обратно в бисквитката session. Оттук нататък браузърът я праща с всяка заявка към магазина.
  4. В друг раздел Мария отваря free-gifts.example. Страницата там има скрита форма към магазина и я праща сама.
  5. Бисквитката е SameSite=None, затова браузърът я слага на заявката. Обработчикът намира Мария по нея.
  6. Той вярва на бисквитката и сменя адреса: 200, ул. Измамна 13. Мария не е натиснала нищо в магазина.
  7. Поправка 1: SameSite=Lax. При POST от чужд сайт браузърът оставя бисквитката вкъщи. Страниците на магазина пак я получават.
  8. Същата форма, но без бисквитката обработчикът не вижда сесия: 401. Адресът остава ул. Витоша 15.
  9. Поправка 2: CSRF токен. Сесията пази случаен токен, а страницата на магазина го слага в скрито поле на формата си.
  10. Дори при SameSite=None чуждата страница не може да прочете токена. Формата ѝ идва с бисквитката, но без него: 403.
  11. Формата на магазина праща токена и той съвпада: 200, ул. Шипка 7.
  12. Бисквитката казва кой си. Само токенът казва, че заявката идва от страница на магазина.

Браузърът решава дали бисквитката тръгва. Правилото е в атрибута 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/address
changeAddress(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, адрес: ул. Шипка 7
SameSite=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"));
Отговор

Идеята

Токенът, който чуждата страница не може да прочете

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);

Пробвай

Намери грешката

Колега добавя CSRF токен и тестовете минават: грешен токен дава 403. Седмица по-късно адресите пак се сменят от чужд сайт. На кой ред е грешката?

Избери реда с грешката.

По-точно, за любопитните

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.

Тест

Провери се

1. Защо бисквитката на сесията е с HttpOnly?
2. Какво използва CSRF атаката?
3. Защо CSRF токенът спира чуждата форма, щом бисквитката пак тръгва?
4. Защо магазинът дава ново id на сесията при всеки вход?
5. Защо токенът се сравнява с MessageDigest.isEqual / FixedTimeEquals / compare_digest / timingSafeEqual, а не с equals или ==?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·