Сесии, бисквитки и 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 и забравя старото. Защо, е в „По-точно“.
Виж го
Сесията в код
Бисквитката и заявката са обекти. Сайтът, от който тръгва заявката, е поле: така в един процес, без мрежа, се вижда всичко, което браузърът и сървърът правят.
@dataclassclass Cookie: name: str value: str same_site: str
def header(self): return (f"{self.name}={self.value}; HttpOnly; Secure; " f"SameSite={self.same_site}; Path=/")
@dataclassclass Request: site: str # сайтът на страницата, от която тръгва заявката method: str path: str cookies: dict form: dictclass Shop: def __init__(self, same_site): self.same_site = same_site self.sessions = {} # id → купувач self.addresses = {}
def login(self, user, old_id=None): # ново id при всеки вход; старото вече не важи self.sessions.pop(old_id, None) session_id = secrets.token_urlsafe(32) self.sessions[session_id] = user return Cookie("session", session_id, self.same_site)
def who(self, request): return self.sessions.get(request.cookies.get("session"))secrets.token_urlsafe(32) взима 32 криптографски случайни байта и ги прави на текст, който може да стои в бисквитка: 43 знака.Мария влиза два пъти, после браузърът пробва новото id, старото и едно измислено:
shop = Shop("Lax")browser = Browser()first = shop.login("maria")second = shop.login("maria", first.value) # вход отновоprint(f"id: {len(second.value)} знака")print("двата входа дават различни id:", "не" if first.value == second.value else "да")print("Set-Cookie:", second.header().replace(second.value, "…"))browser.store(second)print("новото id →", visit(shop, browser))browser.store(first)print("старото id →", visit(shop, browser))browser.store(Cookie("session", "123456", "Lax"))print("измислено 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/addressdef change_address(self, request): user = self.who(request) if user is None: return 401 # няма сесия self.addresses[user] = request.form["address"] return 200def login(self, user, old_id=None): # ново id при всеки вход; старото вече не важи self.sessions.pop(old_id, None) session_id = secrets.token_urlsafe(32) self.sessions[session_id] = user return Cookie("session", session_id, self.same_site)# POST /account/addressdef change_address(self, request): user = self.who(request) if user is None: return 401 # няма сесия self.addresses[user] = request.form["address"] return 200def attach(cookie, site, method): # същият сайт: бисквитката тръгва винаги if site == "shop.example": return True if cookie.same_site == "None": return True if cookie.same_site == "Lax": return method == "GET" return False # Strictdef change_address(self, request): s = self.session(request) if s is None: return 401 # няма сесия sent = request.form.get("csrf", "").encode() if not hmac.compare_digest(sent, s.csrf.encode()): return 403 self.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: def __init__(self): self.jar = {} # бисквитките на shop.example
def store(self, cookie): self.jar[cookie.name] = cookie
# страница от някой сайт праща заявка към магазина def send(self, site, method, path, form=None): cookies = {c.name: c.value for c in self.jar.values() if attach(c, site, method)} return Request(site, method, path, cookies, form or {})
def attach(cookie, site, method): # същият сайт: бисквитката тръгва винаги if site == "shop.example": return True if cookie.same_site == "None": return True if cookie.same_site == "Lax": return method == "GET" return False # StrictОбработчикът от проблема вярва на бисквитката:
# POST /account/addressdef change_address(self, request): user = self.who(request) if user is None: return 401 # няма сесия self.addresses[user] = request.form["address"] return 200Мария, магазинът и двете форми, първо със SameSite=None, после с Lax:
for same_site in ["None", "Lax"]: shop = Shop(same_site) browser = Browser() browser.store(shop.login("maria")) shop.addresses["maria"] = "ул. Витоша 15" print(f"SameSite={same_site}") # страница на чужд сайт праща формата си към магазина 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, а формата на самия магазин минава: тя е от същия сайт.
Пробвай
Предвиди изхода
Кога бисквитката тръгва? Какво ще отпечата?
lax = Cookie("session", "…", "Lax")strict = Cookie("session", "…", "Strict")print(attach(lax, "free-gifts.example", "GET"))print(attach(lax, "free-gifts.example", "POST"))print(attach(strict, "free-gifts.example", "GET"))print(attach(strict, "shop.example", "POST"))Lax пуска бисквитката от чужд сайт само при GET, затова първото е да, второто не. Strict не я пуска от чужд сайт никога, но на своя сайт тя тръгва винаги, и при POST.
Идеята
Токенът, който чуждата страница не може да прочете
SameSite е защита на браузъра. Тя е добра, но не стига сама: стари браузъри, поддомейни на същия сайт и обработчици, които сменят нещо при GET, я заобикалят. Затова магазинът има и своя защита: CSRF токен.
При входа сесията получава още едно случайно число, токена. Страницата на магазина го слага в скрито поле на своята форма. Обработчикът приема заявката само ако токенът във формата съвпада с този в сесията.
Чуждата страница може да прати заявка към магазина, но не може да прочете отговора му: браузърът не ѝ дава (това е same-origin policy). Затова тя не знае токена и не може да го сложи във формата.
def login(self, user, old_id=None): self.sessions.pop(old_id, None) session_id = secrets.token_urlsafe(32) # и CSRF токен за формите self.sessions[session_id] = Session(user, secrets.token_urlsafe(32)) return Cookie("session", session_id, self.same_site)
def session(self, request): return self.sessions.get(request.cookies.get("session"))
# страницата на магазина слага токена в скрито полеdef form_token(self, request): return self.session(request).csrfdef change_address(self, request): s = self.session(request) if s is None: return 401 # няма сесия sent = request.form.get("csrf", "").encode() if not hmac.compare_digest(sent, s.csrf.encode()): return 403 self.addresses[s.user] = request.form["address"] return 200hmac.compare_digest сравнява токена за постоянно време: гледа всички байтове, без да спира при първата разлика. Иначе времето за отговор издава колко от началото е познато. (.encode() е, защото с текст тя приема само латиница.)Дори със SameSite=None чуждата форма спира:
shop = Shop("None")browser = Browser()browser.store(shop.login("maria"))shop.addresses["maria"] = "ул. Витоша 15"print("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"}))# страницата на магазина слага токена в скрито полеtoken = shop.form_token(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Пробвай
Попълни
Коя функция сравнява токените, без да спира при първата разлика?
if not hmac.(sent, s.csrf.encode()):
return 403hmac.compare_digest гледа всички байтове. Различната дължина не е тайна: всички токени на магазина са дълги по 43 знака.
Пробвай
Намери грешката
Колега добавя CSRF токен и тестовете минават: грешен токен дава 403. Седмица по-късно адресите пак се сменят от чужд сайт. На кой ред е грешката?
Избери реда с грешката.
Токенът се проверява само ако го има. Чуждата форма просто не праща поле csrf и минава покрай проверката. Липсващият токен трябва да е отказ, както и грешният: get("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. Напиши обработчика cancel_order: без сесия връща 401, без верен CSRF токен 403, иначе отбелязва поръчката като отказана и връща 200. Пробвай го с три заявки, като бисквитката е SameSite=None: от чуждия сайт с бисквитката на Мария, но без токен; от браузър без вход; от страницата на магазина с токена.
чужд сайт: 403, поръчка 1042: активнабез вход: 401, поръчка 1042: активнамагазинът: 200, поръчка 1042: отказанаОще задачи
- Нов токен. Давай нов CSRF токен след всяка успешна смяна на адреса. Какво става, ако Мария е отворила формата в два раздела?
- Origin. Добави в заявката поле
originи откажи всяка заявка, която сменя нещо и не е отshop.example.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: dataclass, match и шаблони · Мрежово програмиране · Недоверен вход · Пароли
- Продължава в: JWT и капаните му · Мащабиране
- Задачи по темата: Сигурност за програмисти