Сесии, бисквитки и 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){ public string Header() => $"{Name}={Value}; HttpOnly; Secure; SameSite={SameSite}; Path=/";}
// Site: сайтът на страницата, от която тръгва заявкатаrecord Request(string Site, string Method, string Path, Dictionary<string, string> Cookies, Dictionary<string, string> Form);class Shop(string sameSite){ readonly Dictionary<string, string> sessions = new(); // id → купувач public Dictionary<string, string> Addresses { get; } = new();
static string NewId() => Base64Url.EncodeToString(RandomNumberGenerator.GetBytes(32));
public Cookie Login(string user, string? oldId) { // ново id при всеки вход; старото вече не важи if (oldId is not null) sessions.Remove(oldId); var id = NewId(); sessions[id] = user; return new Cookie("session", id, sameSite); }
public string? Who(Request request) => request.Cookies.TryGetValue("session", out var id) ? sessions.GetValueOrDefault(id) : null;}RandomNumberGenerator.GetBytes(32) дава криптографски случайни байтове, а Base64Url.EncodeToString ги прави на текст, който може да стои в бисквитка: 43 знака.Мария влиза два пъти, после браузърът пробва новото id, старото и едно измислено:
var shop = new Shop("Lax");var browser = new Browser();var first = shop.Login("maria", null);var second = shop.Login("maria", first.Value); // вход отновоConsole.WriteLine($"id: {second.Value.Length} знака");Console.WriteLine($"двата входа дават различни id: {(first.Value == second.Value ? "не" : "да")}");Console.WriteLine("Set-Cookie: " + second.Header().Replace(second.Value, "…"));browser.Store(second);Console.WriteLine($"новото id → {Visit(shop, browser)}");browser.Store(first);Console.WriteLine($"старото id → {Visit(shop, browser)}");browser.Store(new Cookie("session", "123456", "Lax"));Console.WriteLine($"измислено 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/addresspublic int ChangeAddress(Request request){ var user = Who(request); if (user is null) return 401; // няма сесия Addresses[user] = request.Form["address"]; return 200;}public Cookie Login(string user, string? oldId){ // ново id при всеки вход; старото вече не важи if (oldId is not null) sessions.Remove(oldId); var id = NewId(); sessions[id] = user; return new Cookie("session", id, sameSite);}// POST /account/addresspublic int ChangeAddress(Request request){ var user = Who(request); if (user is null) return 401; // няма сесия Addresses[user] = request.Form["address"]; return 200;}static bool Attach(Cookie cookie, string site, string method){ // същият сайт: бисквитката тръгва винаги if (site == "shop.example") return true; return cookie.SameSite switch { "None" => true, "Lax" => method == "GET", _ => false, // Strict };}public int ChangeAddress(Request request){ var s = Find(request); if (s is null) return 401; // няма сесия var sent = request.Form.GetValueOrDefault("csrf", ""); if (!Same(sent, s.Csrf)) return 403; Addresses[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{ readonly Dictionary<string, Cookie> jar = new(); // бисквитките на shop.example
public void Store(Cookie cookie) => jar[cookie.Name] = cookie;
// страница от някой сайт праща заявка към магазина public Request Send(string site, string method, string path, Dictionary<string, string>? form = null) { var cookies = new Dictionary<string, string>(); foreach (var c in jar.Values) { if (Attach(c, site, method)) cookies[c.Name] = c.Value; } return new Request(site, method, path, cookies, form ?? new()); }
public static bool Attach(Cookie cookie, string site, string method) { // същият сайт: бисквитката тръгва винаги if (site == "shop.example") return true; return cookie.SameSite switch { "None" => true, "Lax" => method == "GET", _ => false, // Strict }; }}Обработчикът от проблема вярва на бисквитката:
// POST /account/addresspublic int ChangeAddress(Request request){ var user = Who(request); if (user is null) return 401; // няма сесия Addresses[user] = request.Form["address"]; return 200;}Мария, магазинът и двете форми, първо със SameSite=None, после с Lax:
foreach (var sameSite in new[] { "None", "Lax" }){ var shop = new Shop(sameSite); var browser = new Browser(); browser.Store(shop.Login("maria", null)); shop.Addresses["maria"] = "ул. Витоша 15"; Console.WriteLine($"SameSite={sameSite}"); // страница на чужд сайт праща формата си към магазина Show(shop, browser.Send("free-gifts.example", "POST", "/account/address", new() { ["address"] = "ул. Измамна 13" })); // формата на самия магазин Show(shop, browser.Send("shop.example", "POST", "/account/address", new() { ["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, а формата на самия магазин минава: тя е от същия сайт.
Пробвай
Предвиди изхода
Кога бисквитката тръгва? Какво ще отпечата?
var lax = new Cookie("session", "…", "Lax");var strict = new Cookie("session", "…", "Strict");Console.WriteLine(Browser.Attach(lax, "free-gifts.example", "GET"));Console.WriteLine(Browser.Attach(lax, "free-gifts.example", "POST"));Console.WriteLine(Browser.Attach(strict, "free-gifts.example", "GET"));Console.WriteLine(Browser.Attach(strict, "shop.example", "POST"));Lax пуска бисквитката от чужд сайт само при GET, затова първото е да, второто не. Strict не я пуска от чужд сайт никога, но на своя сайт тя тръгва винаги, и при POST.
Идеята
Токенът, който чуждата страница не може да прочете
SameSite е защита на браузъра. Тя е добра, но не стига сама: стари браузъри, поддомейни на същия сайт и обработчици, които сменят нещо при GET, я заобикалят. Затова магазинът има и своя защита: CSRF токен.
При входа сесията получава още едно случайно число, токена. Страницата на магазина го слага в скрито поле на своята форма. Обработчикът приема заявката само ако токенът във формата съвпада с този в сесията.
Чуждата страница може да прати заявка към магазина, но не може да прочете отговора му: браузърът не ѝ дава (това е same-origin policy). Затова тя не знае токена и не може да го сложи във формата.
public Cookie Login(string user, string? oldId){ if (oldId is not null) sessions.Remove(oldId); var id = NewId(); sessions[id] = new Session(user, NewId()); // и CSRF токен за формите return new Cookie("session", id, sameSite);}
public Session? Find(Request request) => request.Cookies.TryGetValue("session", out var id) ? sessions.GetValueOrDefault(id) : null;
// страницата на магазина слага токена в скрито полеpublic string FormToken(Request request) => Find(request)!.Csrf;public int ChangeAddress(Request request){ var s = Find(request); if (s is null) return 401; // няма сесия var sent = request.Form.GetValueOrDefault("csrf", ""); if (!Same(sent, s.Csrf)) return 403; Addresses[s.User] = request.Form["address"]; return 200;}static bool Same(string a, string b) => // сравнява всички байтове, без да спира при първата разлика CryptographicOperations.FixedTimeEquals( Encoding.UTF8.GetBytes(a), Encoding.UTF8.GetBytes(b));Дори със SameSite=None чуждата форма спира:
var shop = new Shop("None");var browser = new Browser();browser.Store(shop.Login("maria", null));shop.Addresses["maria"] = "ул. Витоша 15";Console.WriteLine("SameSite=None + CSRF");Show(shop, browser.Send("free-gifts.example", "POST", "/account/address", new() { ["address"] = "ул. Измамна 13" }));// чуждият сайт не знае токена и пробва наслукиShow(shop, browser.Send("free-gifts.example", "POST", "/account/address", new() { ["address"] = "ул. Измамна 13", ["csrf"] = "123456" }));// страницата на магазина слага токена в скрито полеvar token = shop.FormToken(browser.Send("shop.example", "GET", "/account"));Show(shop, browser.Send("shop.example", "POST", "/account/address", new() { ["address"] = "ул. Шипка 7", ["csrf"] = token }));SameSite=None + CSRF free-gifts.example → 403, адрес: ул. Витоша 15 free-gifts.example → 403, адрес: ул. Витоша 15 shop.example → 200, адрес: ул. Шипка 7Пробвай
Попълни
Коя функция сравнява токените, без да спира при първата разлика?
CryptographicOperations.(Encoding.UTF8.GetBytes(a), Encoding.UTF8.GetBytes(b));CryptographicOperations.FixedTimeEquals гледа всички байтове. Различната дължина не е тайна: всички токени на магазина са дълги по 43 знака.
Пробвай
Намери грешката
Колега добавя CSRF токен и тестовете минават: грешен токен дава 403. Седмица по-късно адресите пак се сменят от чужд сайт. На кой ред е грешката?
Избери реда с грешката.
Токенът се проверява само ако го има. Чуждата форма просто не праща поле csrf и минава покрай проверката. Липсващият токен трябва да е отказ, както и грешният: GetValueOrDefault("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 ([ValidateAntiForgeryToken]), 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 и капаните му · Мащабиране
- Задачи по темата: Сигурност за програмисти