vyvchy
    Теми розділу

    03 · Веб і мережі для AQA

    Кукі, сесії та сховище браузера

    Зміст

    HTTP не памʼятає попереднього запиту: сервер дивиться на кожен запит так, ніби бачить вас уперше (це властивість протоколу з глави «Клієнт-сервер і як працює веб»; чому вона корисна — у першому розділі нижче). Тому застосунок мусить сам памʼятати, що ви залогінені, і доводити це серверу з кожним запитом заново. Місць для такої памʼяті три, і глава розбере кожне окремо:

    • кукі (cookie) — невеликий запис виду «імʼя=значення»; браузер зберігає його й сам повертає серверу;
    • сесія на сервері — самі дані лежать на сервері, а браузер тримає лише короткий ідентифікатор до них; що це за ідентифікатор — розділ про сесію;
    • сховища браузера — дані, які на сервер самі не їдуть ніколи.

    Ця глава — про те, де саме лежить цей стан і хто вирішує, чи доїде він до сервера. Це канонічна глава теми — повний виклад тут, сусідні глави посилаються сюди.

    Тестувальник, який не знає цієї карти, пише хибні репорти й губить на них години. «Кукі в DevTools є — отже, вона поїхала на сервер»: ні, збереження і відправка — різні факти. «Користувач розлогінений — баг бекенду»: часто це атрибут кукі, який заборонив доставку. «Почистили localStorage — тести ізольовані»: а серверний стан нікуди не дівся. Наприкінці глави весь цей матеріал зійдеться в автотестах: там стан логіну зберігають одним файлом, і файл ламається рівно об ті самі атрибути.

    Чому сервер не памʼятає нас між запитами

    Спершу — властивість, через яку все це взагалі потрібне. HTTP — протокол без стану: сервер не запамʼятовує, від кого прийшов попередній запит. Цю властивість називають (statelessness), і вона належить протоколу, а не застосунку: специфікація прямо вимагає, щоб кожен запит був зрозумілий сам по собі, без огляду на попередні.

    Правило суворіше, ніж здається. Навіть якщо два запити прийшли одним зʼєднанням, сервер не має припускати, що їх надіслав той самий агент — так специфікація називає програму, яка робить запити, найчастіше браузер.

    Навіщо протоколу така забудькуватість? Мотив прагматичний — масштабованість. Великий сайт — це зазвичай не один сервер, а десяток однакових; на вході стоїть балансувальник — сервер-диспетчер, який роздає вхідні запити між ними. Якщо серверу не треба памʼятати клієнтів, то будь-який із десятка обробить будь-який запит.

    Стан у застосунку при цьому є майже завжди. Просто він живе не в протоколі, а в базі — і в тому, що клієнт приносить із кожним запитом.

    Звідси правило, з якого росте вся глава: клієнт мусить нести доказ «це знову я» в кожному запиті заново, а не показати його один раз при логіні. Механізмів доставки два:

    • Кукі. Сервер віддає її заголовком відповіді Set-Cookie; далі браузер сам вирішує, чи повернути пару імʼя=значення в заголовку Cookie, — і вирішує за атрибутами, які зберіг разом із кукі.
    • Токен (token). Його клієнт зберігає сам і сам додає в заголовок Authorization. Специфікація задає для цього заголовка лише форму: слово Bearer, пробіл, далі b64token — так специфікація називає сам токен: рядок із букв і цифр. Bearer англійською — «предʼявник»: впускають того, хто предʼявив токен; докладніше — в розділі про сесію.

    Почнімо з робочої моделі — свідомо спрощеної: сервер поставив кукі — браузер повертає її з кожним наступним запитом на той самий сайт. Для першого дня на проєкті модель годиться. Далі глава зламає її тричі — на шляху, на каналі й на межі сайту. Кожен злам додасть до моделі рівно одне уточнення.

    Наша практика (не канон). У специфікації повернення кукі описане як обовʼязок браузера — він робить це сам. Про токен такого правила немає: специфікація каже лише, як має виглядати запит, а додати заголовок мусить код застосунку. Тож висновок «кукі доїде сама, а токен — лише той, який поклав клієнтський код» ми виводимо з того, що специфікація про токен мовчить.

    Кукі: сервер пропонує, браузер вирішує

    Кукі — пара імʼя=значення, яку сервер надсилає браузеру заголовком Set-Cookie, а браузер зберігає й сам повертає в заголовку Cookie. Перша версія логіну на app.example.com ставила її без жодного атрибута:

    POST /api/login HTTP/1.1
    Host: app.example.com
    
    HTTP/1.1 200 OK
    Set-Cookie: session_id=abc123def456
    Set-Cookie: theme=dark; Path=/; Max-Age=31536000

    Назад браузер повертає лише пари імʼя=значення, злиті в один заголовок (сама механіка заголовків HTTP — у главі «HTTP: методи, структура, заголовки»):

    GET /api/profile HTTP/1.1
    Host: app.example.com
    Cookie: session_id=abc123def456; theme=dark

    Головне в цьому обміні — асиметрія: у два боки їде не те саме. Сервер надіслав кукі з атрибутами. Назад атрибути не їдуть — повертаються самі пари імʼя=значення. Тому із заголовка Cookie сервер не бачить ні коли кукі спливе, ні для яких хостів вона чинна (хост — повне доменне імʼя сервера з адреси, як-от app.example.com), ні чи стояли прапорці захисту.

    Ще дві деталі цього заголовка. Порядок пар — не контракт: специфікація радить серверам на нього не покладатися. А сенсу окремої кукі специфікація не визначає взагалі: що означає theme=dark, знає лише застосунок.

    І ще одне: ставити кукі може не лише сервер. Клієнтський код теж пише їх — через document.cookie. Навіщо це памʼятати вже зараз: нижче зʼявиться атрибут HttpOnly — він закриває скриптам саме цей доступ до кукі.

    І ця версія логіну видала перший сюрприз одразу: після успішного POST /api/login перехід на GET /dashboard повертає нас на форму логіну, хоча кукі session_id нікуди не ділася. Це видно в DevTools: панель Application показує все, що браузер зберіг для сайту, і кукі там на місці. Кукі в браузері є, запит іде на той самий хост — чому вона не поїхала?

    Тому що «браузер повертає кукі на той самий сайт» — і є те спрощення, яке ламається першим. Насправді браузер вирішує за атрибутами кукі. Якщо якогось атрибута немає, браузер підставляє значення за замовчуванням. І дефолтний Path — це не корінь сайту /. Це «тека» того шляху, звідки приїхав Set-Cookie. Наша кукі приїхала з відповіді на POST /api/login, тож її дефолтний Path/api. Тому кукі їде на /api/profile, але не на /dashboard. Той самий дефолт браузер підставить і замість порожнього чи некоректного Path. Це найчастіша причина симптому «кукі в DevTools є, а на запит не йде» — і перше уточнення моделі: не «на той самий сайт», а «на запити, що проходять атрибути». Виправлення — одна деталь у відповіді логіну:

    Set-Cookie: session_id=abc123def456; Path=/

    При цьому Path — про доставку, не про безпеку: він не дає ні конфіденційності, ні цілісності, тож розробники не використовують його як межу захисту.

    Тут же видно, чому в session_id їде короткий ключ, а не самі дані про користувача: кукі — тісний механізм, і тісним його зробили навмисно. Специфікація не гарантує браузерам великих обсягів. Вона задає лише мінімуми, які браузеру варто підтримувати (у тексті специфікації це SHOULD — рекомендація, не жорстка вимога):

    • щонайменше 4096 байтів на кукі — сумарна довжина імені, значення й атрибутів;
    • 50 кукі на домен;
    • 3000 кукі всього.

    Серверам специфікація виставляє дзеркальну вимогу: тримати кукі якомога меншими і нечисленними, бо заголовок Cookie їде з кожним запитом. Гарантій повернення теж немає: браузер має право будь-коли витіснити будь-яку кукі.

    Що саме ховається за коротким ключем, розкаже розділ про серверну сесію. Куди класти те, що в кукі не влазить, — покаже розділ про сховища. І останній дефолт: кукі без атрибутів повертається лише тому серверу, який її поставив, і зникає в кінці сесії браузера.

    Атрибути: правила доставки, які виконує браузер

    Отже, долю кукі вирішують атрибути. Ось повний набір — а далі розберемо ті, на яких ламаються тести.

    АтрибутЩо задаєНаслідок
    DomainНа які хости надсилатиБез нього кукі host-only — її бачить лише хост, що її поставив
    PathНа які шляхи надсилатиДефолт — «тека» шляху, що поставив кукі, не /
    ExpiresГраничний момент життя як датуПісля нього браузер кукі видаляє
    Max-AgeТе саме в секундахЗа наявності обох пріоритет має Max-Age
    SecureСлати лише захищеним каналомПо відкритому HTTP не поїде — виняток localhost
    HttpOnlyЗакрити від читання скриптомdocument.cookie її не бачить
    SameSiteПоведінку в міжсайтових запитахStrict / Lax / None — окремий розділ нижче

    Domain розширює, а не звужує. Тобто атрибут робить кукі видимою для ширшого кола хостів, а не для вужчого. Наступне рішення команди: чи має сесію бачити api.example.com? Поки в кукі немає атрибута Domain, діє правило за замовчуванням: кукі бачить лише той хост, який її поставив, — таку кукі називають host-only. Поставив app.example.com — бачить тільки app.example.com. Піддомени, як-от api.example.com, — ні.

    Заданий Domain=example.com знімає це обмеження: кукі поїде на всі піддомени example.com, зокрема тестові стенди (staging — окрема копія сайту для перевірок). На чужий домен розширитись не вийде: хост сервера, який ставить кукі, має входити в домен із Domain — як app.example.com входить у example.com. Кукі з доменом, куди хост не входить, браузер просто відкине. Ми лишили host-only. «Сесія на всі піддомени» — ширша поверхня для , ніж нам потрібно.

    Час життя ділить кукі на два види. Наша session_id — без Expires і Max-Age. Така кукі називається сесійною: вона зникає, коли завершується сесія браузера. Типова роль сесійної кукі — саме ідентифікатор сесії. Другий вид — постійна кукі, як theme=dark із Max-Age=31536000: вона переживає перезапуск браузера. Так реалізують «запамʼятати мене». Одне застереження: «закрив браузер — розлогінився» справджується не завжди. Багато браузерів відновлюють вкладки після перезапуску — і разом із ними зберігають сесійні кукі.

    Окреме питання — що для браузера означає «та сама» кукі. Її «посвідчення» — трійка «імʼя + домен + шлях»: нова кукі з тією самою трійкою витісняє стару. Через ту саму трійку працює й видалення: сервер надсилає той самий заголовок Set-Cookie з тією ж трійкою, але з Expires у минулому або з Max-Age нуль чи відʼємним — і браузер кукі прибирає.

    Secure захищає канал — і тільки канал. Після глави «HTTPS, TLS і безпека» логічний крок — заборонити кукі їздити відкритим текстом. Для цього додаємо атрибут Secure. Браузер і тут діє сам: таку кукі він не віддасть незахищеному зʼєднанню. Але в цього захисту дві межі.

    Перша межа: Secure не рятує від перезапису. По кроках:

    1. контролює мережу між браузером і сервером.
    2. Він відповідає браузеру від імені сайту по відкритому HTTP — і в тій відповіді ставить свою кукі з тим самим іменем.
    3. Браузер перезапише нею навіть Secure-кукі — тепер у жертви лежить ідентифікатор сесії, який атакувальник вибрав сам і тому знає наперед.

    Саме так працює (session fixation) — атака, у якій жертві підсаджують наперед відомий ідентифікатор сесії.

    Друга межа — пастка середовищ. Якщо Secure виставлений завжди, сесія не працює на стендах без TLS, і це не дефект застосунку. Виняток — localhost: для нього браузер робить послаблення, тож локально все «працює».

    І ще: перевіряти треба у відповіді сервера, у заголовку Set-Cookie, а не здогадом з архітектури. Буває, що HTTPS закінчується на балансувальнику: він розшифровує трафік, а далі до застосунку запити йдуть уже відкритим HTTP. Браузер при цьому бачить HTTPS, а застосунок — ні. А поведінку кукі визначає те, що бачить браузер.

    HttpOnly закриває читання — і тільки читання. Другий прапорець на сесійній куці відрізає скриптам доступ до значення: читання такої кукі через document.cookie повертає порожній рядок. Тепер про те, чого він не робить. Він не блокує XSS — атаку, за якої на сторінку потрапляє чужий скрипт, — і не спиняє самі скрипти. Кукі з HttpOnly так само летить із запитами, які ініціював JavaScript через fetch чи XMLHttpRequest (способи, якими код сторінки сам шле запити). Тобто впроваджений скрипт усе одно діє від імені користувача — просто не бачить значення кукі.

    Прапорці незалежні один від одного, і на сесійній куці мають стояти обидва: Secure — проти підслуханого каналу, HttpOnly — проти читання скриптом.

    Перевірте на будь-якому сайті, де ви залогінені: виконайте в DevTools document.cookie і порівняйте з таблицею в Application → Cookies. Очікуваний результат: рядок у консолі коротший — усе, чого в ньому бракує, і є HttpOnly-кукі.

    Наша кукі тепер виглядає так:

    Set-Cookie: session_id=abc123def456; Path=/; Secure; HttpOnly

    Тепер найпідступніше. У першому з прапорцями трапилась одруківка — HttpOnl без y. Здається, що зламана кукі не встановиться або хоч якось просигналізує про помилку. Ні. Нерозпізнані атрибути браузер мовчки ігнорує, а саму кукі зберігає. Доказ — одразу в консолі:

    document.cookie
    // "session_id=abc123def456; theme=dark"  ← значення видно: HttpOnly не спрацював

    Кукі працює, логін працює, тести зелені — а захисту немає. Побачити це можна лише в самому заголовку Set-Cookie — у вкладці Network або в тесті API-рівня. Перевірити прапорці зі сторінки не можна в принципі: вебплатформа тримає список заголовків відповіді, які скриптам читати заборонено, і Set-Cookie стоїть у цьому списку. Підмінити заголовок Cookie у fetch теж не можна.

    Проти таких помилок є окремий рубіж — префікси імені кукі. Ідея: сервер зашиває обіцянку захисту прямо в імʼя кукі — його одруківкою в атрибутах не зіпсуєш, — а браузер відмовиться зберігати кукі, якщо сервер обіцянки не виконав. Префіксів два:

    • __Secure- — браузер прийме таку кукі, лише якщо вона Secure і поставлена з HTTPS: обіцянку «тільки захищений канал» він перевіряє вже на кроці збереження;
    • __Host- — ті самі дві умови плюс без Domain і з Path=/: додаткові умови привʼязують кукі рівно до одного хоста й до всього сайту, щоб її не перекрила кукі з піддомену чи вужчого шляху.

    І тут є чесна межа: браузер, який префіксів не підтримує, прийме такі кукі завжди. Це страховка, не гарантія.

    SameSite: «той самий сайт» — не той самий хост

    Останній атрибут нашої кукі — SameSite. Він керує тим, чи додається кукі до запитів, які ініціює інший сайт: наприклад, коли чужа сторінка вбудовує картинку з вашого сайту або відправляє форму на ваш URL. Значень три:

    • Strict — кукі їде лише в запитах у межах того самого сайту.
    • Lax — те саме плюс один виняток. Кукі поїде, коли збіглися дві умови разом: перехід на ваш сайт — верхньорівнева навігація, і запит іде . Верхньорівнева навігація — адреса змінюється в самому адресному рядку вкладки, а не у вбудованому фреймі (чужій сторінці всередині вашої — про них розділ про сторонні кукі). Безпечний метод — той, що лише читає дані, як GET. Найзвичніший приклад, де збігається і те, і те, — клік по посиланню.
    • None — кукі їде і в крос-сайтових запитах, але лише разом із Secure. Без Secure кукі з SameSite=None браузер відкидає цілком, ще на кроці збереження. У тестах це виглядає як «сервер її ставить, а її немає».

    Ми ставимо явний Lax — і кукі набуває фінального вигляду, який пройде через решту глави:

    Set-Cookie: session_id=abc123def456; Path=/; Secure; HttpOnly; SameSite=Lax

    Чому явний Lax, а не дефолтний? Кукі без атрибута рушії сімейства Chromium теж трактують як Lax — але це поведінка саме цих рушіїв, не всіх браузерів. Рушій — ядро, на якому побудований браузер; на Chromium працюють Chrome, Edge та багато інших. У крос-браузерних сценаріях на таку поведінку не покладаються.

    Є й друга причина: дефолтний Lax дозвільніший за явний. Спершу — висновок для тестувальника: свіжа кукі без SameSite може поїхати з чужого POST — кукі, старша за дві хвилини, вже ні. Механізм — по кроках:

    • сценарій: чужий сайт відправляє форму методом POST на ваш, і адреса вкладки змінюється на вашу. Явний Lax кукі в такий запит не пустить ніколи;
    • для кукі без SameSite рушій може тимчасово пустити її і в такий POST. В оригіналі поведінка зветься Lax-allowing-unsafe — «Lax, що пропускає небезпечні методи», тобто методи, які змінюють дані, як POST;
    • послаблення діє, поки кукі не виповнилось дві хвилини (поріг — зі специфікації рушія). Це внутрішня поведінка рушія, а не окреме значення атрибута.

    А от одруківка у значенні атрибута — на відміну від одруківки HttpOnl у назві — не роззброює кукі: невідоме значення браузер трактує як Lax. Тобто результат — не «без обмежень», а лаксові правила.

    Тепер третій злам моделі — межа «того самого сайту». Інтуїція каже: той самий сайт — це той самий домен. Специфікація каже інакше, і щоб її прочитати, потрібні три слова — від найпростішого до складеного:

    • мітка — один сегмент доменного імені між крапками: в app.example.com міток три;
    • — частина адреси, під якою будь-хто може зареєструвати собі імʼя: com, co.uk, github.io; браузери звіряються зі спільним переліком таких суфіксів — списком публічних суфіксів (Public Suffix List);
    • (registrable domain) — публічний суфікс плюс одна мітка ліворуч від нього.

    Сайт — пара «схема + реєстрований домен». Для app.example.com публічний суфікс — com, тож реєстрований домен — example.com. Звірте інтуїцію з таблицею:

    Пара адресSame-site?Чому
    app.example.com і api.example.comтакреєстрований домен один — example.com
    http://example.com і https://example.comнісхема входить у межу сайту
    https://site.com:8443 і https://site.comтакпорт у межу не входить
    your-project.github.io і my-project.github.ioніgithub.io — у списку публічних суфіксів

    Схему в означення сайту додали навмисно: якби http:// і https:// рахувалися одним сайтом, незахищений HTTP був би слабким каналом до сесії. Для co.uk межа проходить на третьому рівні: реєстрований домен — на кшталт example.co.uk. У статтях реєстрований домен часто скорочують до eTLD+1 — це розмовна назва, самі джерела кажуть «реєстрований домен».

    І в самої цієї межі є слабке місце: список публічних суфіксів у різних клієнтів різниться, тож вебплатформа радить будувати перевірки безпеки не на сайті, а на (origin) — вужчій межі «схема + хост + порт», якій нижче присвячено окремий розділ.

    Щодо захисту. Міжсайтова підробка запиту (cross-site request forgery, CSRF) — атака, коли чужий сайт змушує ваш браузер зробити запит від вашого імені. Проти неї Lax дає лише глибину оборони — додатковий шар, а не повноцінний захист. Як виглядає атака — і чому GET, який змінює стан на сервері, зводить цей шар нанівець, — розбирає глава «Автентифікація та авторизація».

    Наша практика (не канон). SameSite найчастіше болить у двох сценаріях, яких джерела не описують. Перший — SSO (single sign-on, єдиний вхід через зовнішній сервіс) і платіжні редіректи: користувача веде на зовнішній сайт і повертає назад. Кукі для такого повернення має бути None; Secure, інакше сесія «губиться» на поверненні. Другий — вхід за посиланням із листа чи месенджера: під Strict перший екран виглядає розлогіненим, і це очікувана поведінка, а не баг.

    Сторонні кукі: вирішує браузер, а не атрибути

    У SameSite правило обирав сервер: він записував його в Set-Cookie, а браузер лише виконував — перед кожним запитом сам перевіряв, чи можна класти куку. Зі сторонніми (third-party) кукі інакше: браузер вирішує сам, за власною політикою, і жоден атрибут у Set-Cookie цього рішення не переважить. «Стороння» — властивість не самої кукі, а пари «кукі × поточна сторінка»: кукі стороння тоді, коли її домен і схема розходяться з тим, що в адресному рядку. Точніша назва — крос-сайтові кукі: поведінка та сама, навіть якщо всі домени — ваші.

    Важлива деталь: «свій чи чужий» браузер міряє від адреси самої вкладки — того, що видно в адресному рядку. Звідки саме на сторінці пішов запит — ролі не грає. Сторінка може вбудувати чужий документ через <iframe> — рамку з окремою сторінкою всередині; той документ може вбудувати ще один, і так далі. Для кукі в такому вкладеному фреймі браузер перевіряє всі сторінки-обгортки навколо нього, аж до самої вкладки. Тож те, як поведеться сесійна кукі всередині <iframe>, залежить від адреси самої вкладки.

    Дефолти в різних рушіїв різні — і вивести їх нема звідки: це не вимога специфікації, а продуктові рішення команд браузерів, які ще й змінюються з версіями. Тому їх звіряють із документацією; поточна картина по браузерах:

    • Firefox — Total Cookie Protection («повний захист кукі»): кожному сайту — своя «банка» кукі. Це ізоляція, а не блокування.
    • Safari — блокує сторонні кукі типово. Точково послабити заборону для конкретного фрейму можна лише кодом — через Storage Access API, браузерний інтерфейс, яким вбудований фрейм просить доступ до власних кукі.
    • Chrome — типово не блокує. Блокує лише в режимі Incognito (в українському інтерфейсі — «анонімний режим») або за явним налаштуванням.

    Наслідок: крос-сайтовий сценарій, що працює в Chrome, може мовчки не працювати в браузері поруч.

    Найдалі йде Safari з ITP (Intelligent Tracking Prevention — «розумне запобігання стеженню»). Одразу застереження: усе, що нижче до кінця розділу, — довідка для розбору багів () під Safari, а не матеріал для запамʼятовування. Рішення «цей домен — трекер» ухвалює модель машинного навчання просто на пристрої — за трьома сигналами:

    • на скількох різних сайтах домен вантажиться як підресурс — картинка чи скрипт;
    • на скількох сайтах він зʼявляється як вбудований фрейм;
    • на скільки різних доменів він перенаправляє відвідувачів.

    Тому поведінка залежить від історії конкретного профілю браузера — і на чистому профілі відтворюється не завжди.

    Далі — три режими. Safari обирає між ними за однією міркою: як давно користувач взаємодіяв із доменом як із першою стороною, тобто відкривав сам сайт, а не зустрічав його вбудованим у чужий. Цифри нижче — з публікації 2017 року; Safari з того часу міняв деталі, тож сприймай їх як порядок величин, а не точні пороги:

    • менш як 24 години тому — кукі домену доступні й як сторонні; саме це вікно тримає живим SSO;
    • від 24 годин до 30 днів — кукі розкладені по , тобто окремих «банках»: своя на кожен реєстрований домен (у документації WebKit — «TLD+1», ще одна назва того самого реєстрованого домену) і спільна для його піддоменів;
    • понад 30 днів — дані вичищаються.

    Наслідок для баг-тріажу: «вчора ввійшли — сьогодні ні» в Safari буває очікуваною поведінкою браузера, а не багом застосунку.

    Сесія на сервері: у браузері лише ключ

    Час чесно сказати, що таке abc123def456. Це не дані про вас — це ключ. Класична схема тримає стан на сервері. Після логіну сервер створює у себе запис сесії — у памʼяті або в базі даних — і кладе туди все: хто ви, які у вас ролі, що в кошику. Браузеру він віддає лише довгий випадковий ідентифікатор — кукою. На кожен наступний запит браузер сам надсилає кукі, сервер знаходить за нею запис і «згадує» вас. Ключова думка діаграми: у браузері ніколи не лежить сам стан — там лежить лише перепустка до нього.

    Сховище сесійСерверБраузерСховище сесійСерверБраузерPOST /api/login (логін і пароль)Створити запис сесії (хто ви, ролі, кошик)Set-Cookie: session_id=abc123def456 (Secure, HttpOnly)GET /dashboard, Cookie: session_id=abc123def456Знайти запис за ідентифікаторомСтан користувача200 OKСховище сесійСерверБраузерСховище сесійСерверБраузерPOST /api/login (логін і пароль)Створити запис сесії (хто ви, ролі, кошик)Set-Cookie: session_id=abc123def456 (Secure, HttpOnly)GET /dashboard, Cookie: session_id=abc123def456Знайти запис за ідентифікаторомСтан користувача200 OK

    Чому до цього ключа стільки вимог? Через його bearer-природу (bearer — «предʼявник»): сервер перевіряє лише те, що значення дійсне, і не питає, хто саме його надіслав. Хто предʼявив ключ — той і ввійшов. Тому після входу ідентифікатор сесії тимчасово еквівалентний найсильнішому методу автентифікації застосунку: крадіжка сесійної кукі має той самий вплив, що й крадіжка логіна з паролем. Звідси правила, які варто перевіряти як вимоги:

    • ідентифікатор генерується заново саме після логіну — новим випадковим значенням із високою ентропією, тобто досить довгим і непередбачуваним, щоб його не вгадали; повторне використання ідентифікатора, який був до входу, — ознака вразливості (та сама фіксація сесії);
    • ідентифікатор не передається в URL;
    • живе він у захищеній куці — Secure + HttpOnly; для суто автентифікаційних кукі придатний і SameSite=Strict: на сторінки за логіном користувач і так переходить кліками всередині сайту, а не за посиланням із чужого;
    • сесія стає недійсною після виходу, після простою і після абсолютного — граничного часу життя незалежно від активності;
    • сервер ніколи не приймає ідентифікатор, якого сам не генерував.

    OWASP (Open Worldwide Application Security Project) — міжнародна некомерційна спільнота фахівців із безпеки застосунків; її перелік найпоширеніших — загальновизнаний орієнтир галузі. Цей перелік описує і сценарій, від якого захищає Secure: даунгрейд — примусовий перехід із HTTPS на незашифрований HTTP — і перехоплення сесійної кукі.

    Альтернатива серверному запису — токен, у який стан зашитий цілком: сервер окремого запису не тримає. Типовий приклад — JWT (JSON Web Token); JSON тут — просто текстовий формат запису структурованих даних, у ньому токен тримає свій вміст. Токен кладуть або в кукі, або у сховище браузера. У другому разі він не їде на сервер автоматично: його мусить явно додати в заголовок Authorization код застосунку.

    Що обрати — залежить від того, де потрібен стан. Стан на сервері можна відкликати миттєво. Стан у токені робить самодостатнім кожен сервер за балансувальником: будь-який із них перевірить токен, не звертаючись до спільного сховища сесій. Зате достроково відкликати такий токен важко.

    Для тестів це розвилка стратегії логіну. — ізольований «профіль» зі своїми кукі й сховищем, у якому інструмент автотестів відкриває сторінки; докладніше — у розділі про тести. Сесію на куці такий контекст переносить сам, бо кукі — його частина. А для токена доведеться відтворювати і сховище, і заголовок. Формати й потоки — JWT, OAuth, коди 401/403 — розбирає наступна глава «Автентифікація та авторизація». Нам тут досить розвилки «де живе стан».

    Наша практика (не канон). Імена кукі часто підказують стек: JSESSIONID — Java, PHPSESSID — PHP, connect.sid — Express, sessionid — Django. Орієнтир зручний на незнайомому продукті, але переліку в джерелах немає.

    Сховища браузера: дані, які нікуди не їдуть

    Не все в нашому застосунку — сесія. Продукт хоче зберігати дві речі. Першу — чернетку коментаря — між перезавантаженнями сторінки. Другу — поточний крок покрокової форми оформлення замовлення (кроки «Доставка → Оплата → Підтвердження») — лише в межах вкладки. Тягати це кукою з кожним запитом безглуздо: серверу ці дані не потрібні. Для такого є Web Storage — пари «ключ-значення», доступні через обʼєкт Storage: window.localStorage і window.sessionStorage. API у них однаковий. Різняться область дії і час життя — і саме ця різниця вирішує, що переживе новий контекст у тесті:

    ХарактеристикаlocalStorage (чернетка)sessionStorage (крок оформлення)
    ОбластьПоходженняПоходження і вкладка
    Час життяПереживає перезапуск браузераЗакриття вкладки знищує дані
    Спільність між вкладкамиТак, у межах походженняНі, у кожної вкладки свій
    Доступ із JSЗавжди — аналога HttpOnly немаєЗавжди

    Обидва сховища синхронні: поки триває запис чи читання, решта JavaScript на сторінці чекає. Тому для великих обсягів чи чутливого до швидкодії коду документація радить асинхронну альтернативу — IndexedDB, про неї нижче.

    Значення сховище тримає як рядки тексту. Тому обʼєкт перед записом перетворюють на текст у форматі JSON, а при читанні розбирають назад в обʼєкт — це перетворення називають серіалізацією. У JavaScript його роблять функції JSON.stringify (запис) і JSON.parse (читання).

    Про зміну сховища браузер сповіщає подією storage: її можуть слухати інші відкриті вкладки того самого сайту, щоб помітити зміну.

    Квоти — обмеження на обсяг даних — описані в окремому документі, і формулювання там важливі. Стеля Web Storage — 10 МіБ у всіх браузерах. МіБ — мебібайт: майже звичний мегабайт, просто документація рахує обсяг степенями двійки (1 МіБ = 1024 × 1024 байтів).

    У межах цієї стелі кожне сховище має свою частку: до 5 МіБ localStorage і до 5 МіБ sessionStorage на походження. «До» — верхня межа, не гарантія.

    І остання деталь: квота рахується на все походження. Кілька застосунків під різними шляхами (/site1/ і /site2/) — за означенням глави одне походження, тому квота в них спільна.

    Перевищення квоти дає помилку QuotaExceededError. Документація радить коду, який пише у сховище, бути готовим до неї — перехоплювати через try...catch, конструкцію JavaScript для обробки помилок; тестувальнику досить знати, що переповнення — очікувана ситуація, а не аварія. Скільки місця доступно і скільки вже зайнято, показує метод navigator.storage.estimate() — він віддає оцінку, а не точну цифру.

    Типовий режим зберігання — best-effort («як вийде»): дані живуть, поки є місце й користувач їх не стер. Походження може попросити режим persistent — «не витісняти». У приватному режимі квоти бувають інші, дані зазвичай стираються після виходу, а localStorage поводиться як sessionStorage.

    Коли даних більше — застосунки беруть IndexedDB — повноцінну базу даних просто в браузері. Привʼязана вона так само до походження, а від Web Storage відрізняється трьома речами:

    • зберігає обʼєкти без перетворення на рядок і приймає бінарні дані — «сирі» байти на кшталт зображень чи файлів;
    • операції йдуть : група змін або застосовується цілком, або не застосовується зовсім;
    • API асинхронний: на відміну від Web Storage, запис і читання не зупиняють решту коду сторінки.

    Квоти тут іншого порядку: не мегабайти, а частки всього диска, у кожного рушія свої. І рахуються вони від повного розміру диска, а не від вільного місця. Причина — фінгерпринтинг (fingerprinting): розпізнавання конкретного користувача за технічними дрібницями його системи. Вільне місце в кожного своє, тож квота, порахована від нього, виказувала б конкретну машину — і була б такою дрібницею. Тож фактично дійти до квоти може й не вдатися — місце скінчиться раніше.

    При нестачі місця браузер витісняє дані за принципом LRU (least recently used — «найдавніше використане»): першим — походження, яким найдавніше користувалися; persistent-сховища пропускаються. Де це все живе як підсистема браузера — глава «Архітектура браузера й рендеринг».

    Головний водорозділ між кукою і сховищем — не обсяг, а автовідправка. Кукі браузер додає до відповідних запитів сам. Web Storage та IndexedDB читає лише код сторінки: щоб дані дісталися сервера, застосунок мусить покласти їх у запит явно — у тіло або в заголовок на кшталт Authorization: Bearer …. Звідси безпековий наслідок. Аналога HttpOnly у сховищ немає: доступ має будь-який скрипт сторінки. Тож при XSS чужий скрипт прочитає токен із localStorage так само легко, як свій код, — а значення HttpOnly-кукі за тих самих умов лишиться для нього закритим. Вибір місця для токена — це вибір, яким шляхом атакувальник зможе до нього дістатися (такий шлях називають ), а не питання смаку.

    І ще дві межі сховищ, окремі від автовідправки:

    • сторонній скрипт, підключений тегом <script>, виконується в контексті сторінки-вбудовника — і пише в її сховище, а не у своє;
    • вбудований чужий <iframe> не отримає Web Storage, якщо користувач вимкнув сторонні кукі.

    Наша практика (не канон). Пласке правило «localStorage, sessionStorage та IndexedDB ніколи не їдуть на сервер самі» формулюємо ми. Джерела кажуть те саме, але з іншого боку: сховище доступне скриптам походження, а на сервер дані потрапляють лише тоді, коли їх туди поклав застосунок.

    Походження: чому сховище і кукі розділені по-різному

    У таблиці сховищ областю стояло «походження» — і це не той самий кордон, що в кукі. Походження (origin) — трійка «схема + хост + порт». Дві адреси мають одне походження лише тоді, коли збігаються всі три складові. Шлях і параметри запиту в трійку не входять. А от піддомен — уже інший хост, тобто інше походження. Саме ця межа розділяє сховища.

    І саме цю межу охороняє (same-origin policy) — вбудована поведінка браузера. Вона обмежує читання даних чужого походження скриптом, а не саме звернення до ресурсу: запит на чужий домен браузер відправить і відповідь отримає, але скрипту сторінки її не покаже.

    Порівняйте кордони на нашому прикладі — розбіжність між ними і є джерело «дивних» багів:

    • app.example.com і api.example.comрізні походження (окремі сховища), але один сайт (спільні правила SameSite). Що для сховища чужина, для кукі — своє.
    • http://localhost:3000 і http://localhost:4000різні походження, але один сайт: порт входить у походження й не входить у межу сайту. Ба більше, кукі не ізолюються за портом узагалі: що поставив сервіс на :3000, прочитає й сервіс на :4000. Межа довіри кукі — домен, а не застосунок. Тож симптом «сесія губиться між портами» треба пояснювати чимось іншим.

    Останній кордон живе в <iframe>. У нього є атрибут sandbox: ним сторінка-вбудовник саджає вбудований документ у — режим з урізаними правами, де окремі можливості треба вмикати явно.

    Один із таких дозволів — allow-same-origin: він лишає документу його рідне походження. Без нього фрейм отримує непрозоре походження — унікальне, що не збігається ні з чиїм, — а отже, лишається і без document.cookie, і без localStorage. Тож «сесія у фреймі не працює» — привід перевірити атрибут sandbox: це окрема від SameSite причина.

    Повний виклад походження, політики одного походження й CORS — у главі «CORS і політика одного походження».

    Що з цього видно в DevTools

    Прапорці кукі й факт її відправки перевіряють у різних місцях, і плутанина між цими місцями коштує годин.

    • Application → Storage → Cookies із вибором походження — таблиця збережених кукі з колонками Domain, Path, Expires / Max-Age, HttpOnly, Secure, SameSite; у сесійної кукі в колонці часу життя завжди стоїть Session. Усі поля, крім Size, редаговані; кукі з некоректними значеннями підсвічуються червоним; фільтр шукає лише за Name і Value.
    • Network → запит → вкладка Cookies — що реально поїхало саме з цим запитом. Дивитися треба тут, а не у вкладці Headers: збережена і відправлена кукі — різні факти, у чому ми переконалися ще на дефолтному Path.
    • Відкинуте Set-Cookie не мовчить: фільтр Blocked response cookies збирає запити з відкинутими кукі; причина — під іконкою info на вкладці Cookies, а поруч із самим запитом стоїть попереджувальна іконка.
    • Пастка «Provisional headers are shown»: цей напис означає, що DevTools показує не справжні заголовки. Або запит віддано з локального кешу, який заголовків не зберігає, або DevTools показує лише часткові заголовки з міркувань безпеки. «Заголовка немає у Headers» ≠ «браузер його не надсилав».
    • HAR очищений від секретів за замовчуванням. HAR (HTTP Archive) — файл, у який DevTools експортує весь мережевий журнал вкладки Network. В експорті немає Cookie, Set-Cookie й Authorization — тож його безпечно чіпляти до тікета.

    Повний розбір панелі — у главі «DevTools: вкладка Network і дебаг».

    Стан у тестах: кукі й сховище в Playwright

    Усе вивчене — атрибути, сесія, сховища — сходиться в автотестах. UI-тести виконує Playwright — інструмент автотестів, який керує браузером замість людини. Проходити форму логіну в кожному тесті повільно й крихко: найшвидший логін той, що виконався один раз.

    Механіка стандартна. «Проєкт» у Playwright — це група тестів зі своїми налаштуваннями; логін виносять в окремий службовий тест — setup-проєкт. Він виконується першим, логіниться один раз і зберігає (storage state) — файл із кукі й сховищем, у прикладі нижче це playwright/.auth/user.json. Далі в главі звемо цей файл знімком стану: знімок тут — копія даних на момент збереження, не . Решта тестів стартують уже з цим файлом — залогіненими.

    // auth.setup.ts — один прохід логіну на весь прогін
    import { test as setup } from '@playwright/test';
    
    setup('authenticate', async ({ page, context }) => {
      await page.goto('https://app.example.com/login');
      // process.env.USER_EMAIL — змінна середовища: логін тримають поза кодом
      await page.getByLabel('Email').fill(process.env.USER_EMAIL!);
      // знак оклику — підказка мові TypeScript, якою написано тест: «значення тут точно є»
      await page.getByLabel('Password').fill(process.env.USER_PASSWORD!);
      await page.getByRole('button', { name: 'Sign in' }).click();
      // логін ставить кукі через кілька редиректів: без очікування
      // кінцевого URL знімок застане проміжний стан
      await page.waitForURL('**/dashboard');
      await context.storageState({ path: 'playwright/.auth/user.json' });
    });

    Хто не знає складу знімка, годинами шукає не там: тест упав, чернетка коментаря на місці, а крок оформлення — з покрокової форми з розділу про сховища — зник, і з коду тесту не видно чому. Тому склад — явно:

    • Кукі — потрапляють.
    • localStorage — потрапляє.
    • IndexedDB — лише з явною опцією indexedDB: true.
    • sessionStorageніколи: API для нього немає.

    Саме тому крок оформлення з нашого прикладу і зник: він жив у sessionStorage, а чернетка — у localStorage, тож у новий контекст переїхала лише вона. Крок оформлення відновлюють вручну — через browserContext.addInitScript(). Це скрипт, який Playwright виконує на кожній новій сторінці ще до коду самої сторінки: він розкладає у сховище збережені заздалегідь дані у текстовому вигляді. Офіційний приклад цього скрипту в документації перед записом перевіряє window.location.hostname — властивість, якою скрипт читає хост сторінки, де він виконується. Дані він розкладає лише на очікуваному хості. Це той самий запобіжник, що й у прикладі нижче: знімок, знятий з одного хоста, не повинен потрапити у сховище іншого.

    Ця схема вміє ламатися тихо. Приклад: setup залогінився на стенді https://app.example.com і зберіг user.json, а потім прогін перевели на новий стенд https://stage.example.com. Setup зелений, файл на місці, всередині — кукі session_id=abc123def456, але всі тести падають на формі логіну: сервер поводиться так, ніби бачить нас уперше. Розбираємо за алгоритмом, який працює для будь-якого «розлогіненого» тесту:

    1. Чи є кукі в контексті? context.cookies() показує: є — { name: 'session_id', value: 'abc123def456', domain: 'app.example.com', path: '/', ... }. Отже, файл підхопився, і «стан не завантажився» відпадає.
    2. Чи поїхала вона із запитом? Вкладка Cookies першого запиту прогону — GET https://stage.example.com/dashboard — порожня. Ось і розрив: збережена в браузері кукі є, у запиті її немає.
    3. Звірити атрибути з запитом. У кукі domain: 'app.example.com' — а ми памʼятаємо рішення з розділу про атрибути: кукі host-only. Хост запиту — stage.example.com. Браузер робить рівно те, що його попросили: не віддає кукі нікому, крім app.example.com.

    Сервер ні до чого, Playwright ні до чого — файл стану просто знятий з іншого стенду.

    А чому setup зелений? Бо в ньому стояла «оптимізація»: якщо user.json існує — логін пропустити. Інструмент не відстежує, чи протух файл стану, — це обовʼязок автора — а такий guard, тобто перевірка-запобіжник «файл існує — логін пропустити», зробив файл вічним.

    Обидва виправлення зводяться до одного результату: знімок завжди свіжий і з правильного стенду. Для цього стан генерують проти того стенду, на який ходить прогін, а файл кладуть у теку виводу, яку чистять перед прогоном. Ключова думка діаграми: спершу три перевірки — і лише потім гіпотези про бекенд.

    Ні

    Так

    Так

    Ні

    Кукі сесії є в контексті?

    Стан не підхопився
    або сесія жила в sessionStorage

    Кукі поїхала в заголовку Cookie?

    Причина поза кукою:
    сесія згасла або бекенд

    Звірити Domain / Path / Secure / SameSite
    з хостом, шляхом і схемою запиту

    Ні

    Так

    Так

    Ні

    Кукі сесії є в контексті?

    Стан не підхопився
    або сесія жила в sessionStorage

    Кукі поїхала в заголовку Cookie?

    Причина поза кукою:
    сесія згасла або бекенд

    Звірити Domain / Path / Secure / SameSite
    з хостом, шляхом і схемою запиту

    Решта обовʼязків навколо стану — списком, кожен вартий окремої перевірки:

    • Файл — живий секрет. У ньому справжні кукі. Файл не зберігають у спільному репозиторії коду — тека внесена в список ігнорованого (.gitignore); чому це важливо, пояснить глава про Git. Артефакт із живою сесією виникає за будь-якої стратегії логіну.
    • Ролі й паралельність. Кілька ролей — кілька файлів стану. Дві ролі в одному тесті — два контексти. Якщо тести змінюють серверний стан — окремий акаунт на кожен : так називають паралельні процеси, які виконують тести одночасно. Спільний акаунт для них — джерело гонок: два тести одночасно міняють ті самі дані, і результат залежить від того, хто встиг першим.
    • Логін без UI. Стан взаємозамінний між контекстом браузера й контекстом API-запитів — це такий самий ізольований профіль зі своїми кукі, тільки для запитів без браузера. Тож офіційний шлях — залогінитися і створити контекст, у якому кукі вже є.
    • Кукі кладуть і чистять на рівні контексту. Кладуть через addCookies, чистять через clearCookies — це команди самого контексту браузера, вони працюють повз сторінку. HttpOnly-кукі досяжні лише на рівні контексту. Зсередини сторінки такої кукі не видно: команда page.evaluate, яка виконує JavaScript прямо на сторінці, побачить те саме, що й будь-який скрипт, — порожньо.
    • У Selenium кукі — вбудована частина WebDriver-API. WebDriver — стандартний протокол керування браузером, поверх якого побудований Selenium; набір полів кукі обмежений його специфікацією. Додати кукі можна лише на її домені: спершу get() (відкрити URL — так у Selenium називається перехід на сторінку), потім кукі.
    • Нюанс UI-режиму. UI-режим — це графічна оболонка Playwright, де тести запускають кнопкою і переглядають покроково. Setup у ньому за замовчуванням не виконується.

    Ізоляцію ж між тестами дає сама модель контекстів: кожен тест отримує свіжий контекст браузера — еквівалент нового профілю майже без накладних витрат. Тому чистий стан не «прибирають» — його дає новий контекст. Покладатися на це можна, поки стан не шерять свідомо. Спільний файл стану, спільний акаунт чи дані в базі знову звʼязують тести між собою — і межі доводиться проводити руками.

    Наша практика (не канон). Повний перелік причин «розлогіненого» тесту, з якого зібраний алгоритм:

    • стан не підхопився;
    • сесія жила в sessionStorage чи IndexedDB, яких знімок не покрив;
    • кукі була сесійною й не пережила перезапуск браузера;
    • сесія згасла за часом життя;
    • невірні Domain/Path;
    • спільний акаунт убили паралельні воркери.

    І дві суміжні тези. Симптом протікання стану — «тести зелені поодинці, червоні в наборі». А клієнтська чистка не прибирає серверного стану: записи під спільним акаунтом наступний тест побачить і з чистим сховищем.

    Типові помилки

    • «Сервер віддає Set-Cookie, а кукі в браузері немає — баг бекенду». Насправді SameSite=None без Secure браузер відкидає цілком; дивитися треба на комбінацію атрибутів у відповіді.
    • «Кукі видно в DevTools — отже, вона поїхала із запитом». Насправді збереження і відправка — різні факти. Дефолтний чи завузький Path, host-only Domain або domain в addCookies не з тим хостом лишають кукі збереженою в браузері, але не пускають у запит. Що поїхало насправді — вкладка Cookies запиту.
    • «document.cookie не бачить кукі — щось зламалося». Насправді так виглядає справний HttpOnly: скрипт отримує порожній рядок, а читати кукі треба на рівні контексту браузера.
    • «Прапорець у коді стоїть — значить, він і у відповіді». Насправді одруківка в назві атрибута кукі не ламає: браузер мовчки проігнорує нерозпізнане слово й лишить кукі без захисту. Видно це лише в Set-Cookie.
    • «Локально Secure-кукі працює — і на стенді працюватиме». Насправді localhost — виняток. На стенді без TLS та сама кукі не поїде, і виглядає це як розлогінений користувач, а не як дефект.
    • «Стан підхопився, а користувач розлогінений — інструмент бреше». Насправді sessionStorage у знімок не потрапляє ніколи, IndexedDB — лише за явною опцією; те, що там жило, відновлюють окремо.
    • «Почистили кукі й localStorage — тести ізольовані». Насправді клієнтська чистка не чіпає серверного стану: записи під спільним акаунтом побачить наступний тест.
    • «app.example.com і api.example.com — різні сайти, кукі між ними не поїде». Насправді для кукі це один сайт; доставку вирішує Domain, а не SameSite.

    Підсумок

    «Ви залогінені» — фізично один запис у конкретному місці, найчастіше кука з ключем сесії, а рішення «слати чи ні» ухвалює браузер — за правилами-атрибутами, які задав сам застосунок. Інваріанти, які переживуть будь-яку версію будь-якого браузера:

    • Доказ «це знову я» їде в кожному запиті заново. Кукі додає браузер сам, токен — лише код застосунку (розділ «Чому сервер не памʼятає нас між запитами»).
    • Долю кукі вирішують атрибути на боці браузера — і назад вони не їдуть. Із заголовка Cookie сервер не бачить ні часу життя, ні прапорців; що браузер зберіг насправді — видно лише в Set-Cookie (розділи «Кукі» й «Атрибути»).
    • Межа кукі — домен, межа сховища — походження, і лише SameSite рахує сайт. Кукі не ізолюються ні за портом, ні за застосунком — межа їхньої довіри домен. Походження — «схема + хост + порт». Сайт для SameSite — «схема + реєстрований домен», без порту. Більшість «дивних» розбіжностей між піддоменами, портами і схемами пояснюється різницею цих кордонів (розділи «SameSite» і «Походження»).
    • Головна різниця кукі й сховища — автовідправка, а не обсяг. Web Storage та IndexedDB нікуди не їдуть самі — і не мають аналога HttpOnly (розділ «Сховища браузера»).
    • Ізоляція тестів — це клієнт і сервер разом. Знімок стану покриває не все, протухає мовчки й містить живі секрети; серверного стану він не чистить (розділ «Стан у тестах»).

    Окремо — чекліст на випадок «тест бачить користувача розлогіненим»:

    1. context.cookies() (або Application → Cookies): кукі взагалі є?
    2. Вкладка Cookies проблемного запиту в Network: кукі поїхала?
    3. Розрив між 1 і 2 → звірити Domain/Path/Secure/SameSite з хостом, шляхом і схемою запиту.
    4. Прапорці й одруківки перевіряти в самому Set-Cookie відповіді логіну.
    5. Сесія жила не в куці? sessionStorage та IndexedDB у знімок за замовчуванням не потрапляють.
    6. Стан знято з того самого стенду, на який ходить прогін? Файл стану — у теку, що чиститься.
    7. І лише після всього цього — гіпотези про бекенд і серверний стан.

    У глави лишився один борг, і він виріс просто з нашої кукі. Ми зробили session_id довгим, Secure, HttpOnly. Але сама зручність «браузер додає кукі сам» має зворотний бік: браузер додасть її й до запиту, який ініціювала чужа сторінка. Кукі доводить, що запит іде з вашого браузера, — а не що його хотіли ви. SameSite=Lax тут лише глибина оборони. Як ця атака працює і що її зупиняє — а заразом JWT, OAuth і коди 401/403 — наступна глава, «Автентифікація та авторизація». А як стан протікає між тестами й чому набір червоніє лише разом — глава «Практичні сценарії AQA: флак і синхронізація».

    Можливі питання

    • «Що таке cookie і навіщо вони, якщо HTTP без стану?» Чекають звʼязку «протокол не памʼятає → доказ несе кожен запит». Сильна відповідь додає, що рішення про відправку ухвалює браузер за атрибутами.
    • «Чим сесійна кукі відрізняється від постійної?» Перевіряють знання Expires/Max-Age; бали додає застереження про відновлення вкладок, через яке «закрив браузер — розлогінився» справджується не завжди.
    • «Навіщо HttpOnly і що він НЕ робить?» Слабка відповідь — «захищає від XSS». Сильна — «закриває читання значення скриптом, але не блокує ні XSS, ні відправку кукі із запитом, який ініціював JavaScript».
    • «Поясніть SameSite: Strict, Lax, None». Дивляться, чи знає кандидат про обовʼязковий Secure для None і про те, що дефолтний Lax дозвільніший за явний.
    • «Що таке "той самий сайт" і чи є піддомени same-site?» Відповідь: сайт — схема плюс реєстрований домен, порт не рахується; піддомени — один сайт, але різні походження, а http проти https — різні сайти.
    • «Чим localStorage відрізняється від sessionStorage і від cookie?» Чекають три осі: час життя, область (походження проти вкладки) і автовідправку на сервер.
    • «Де зберігати токен — у localStorage чи в cookie?» Перевіряють розуміння, що це вибір вектора атаки, а не смаку: сховище доступне будь-якому скрипту, HttpOnly-кукі браузер підставляє сам — і тому вона вразлива вже до CSRF.
    • «Наступний тест бачить користувача розлогіненим. Ваші дії?» Найпрактичніше питання глави. Сильна відповідь дає порядок: що є в контексті → що поїхало в заголовку Cookie → звірка атрибутів із хостом, шляхом і схемою — і лише потім гіпотези про бекенд.

    Джерела

    Чому сервер не памʼятає нас між запитами

    Кукі: сервер пропонує, браузер вирішує

    • RFC 6265 — HTTP State Management Mechanism — обмін Set-Cookie/Cookie, асиметрія атрибутів, алгоритм default-path, ліміти §6.1, порядок кукі й витіснення.
    • MDN — Using HTTP cookies — означення кукі, дефолти без атрибутів, запис і читання кукі з клієнтського коду через document.cookie.

    Атрибути: правила доставки, які виконує браузер

    • RFC 6265 — HTTP State Management MechanismDomain, Path, Expires, Max-Age, Secure, HttpOnly, ігнорування нерозпізнаних атрибутів, ключ кукі, видалення, перезапис Secure-кукі активним атакувальником.
    • MDN — Using HTTP cookiesSecure з винятком localhost, Domain як розширення області, сесійна проти постійної кукі й відновлення вкладок.
    • MDN — Set-Cookie — префікси імен та їхня межа, Set-Cookie як заборонений для читання заголовок, HttpOnly-кукі в запитах з JavaScript.
    • OWASP — HttpOnly — що саме робить прапорець і чому читання такої кукі дає порожній рядок.
    • OWASP — SecureCookieAttribute — пастка середовищ без TLS із завжди виставленим Secure і перевірка прапорця у відповіді, бо TLS буває офлоуднутий на балансер.

    SameSite: «той самий сайт» — не той самий хост

    Сторонні кукі: вирішує браузер, а не атрибути

    Сесія на сервері: у браузері лише ключ

    • RFC 6265 — HTTP State Management Mechanism — обмін «логін → запис сесії → ідентифікатор кукою → впізнавання за наступними запитами».
    • MDN — Using HTTP cookies — вимоги до ідентифікатора сесії й SameSite=Strict для автентифікаційних кукі.
    • OWASP — Session Management Cheat Sheet — тимчасова еквівалентність найсильнішому методу автентифікації; сервер не приймає ідентифікатора, якого сам не генерував.
    • OWASP — Cookie Theft Mitigation Cheat Sheet — bearer-природа: сервер перевіряє лише валідність значення, не питаючи, хто надіслав; крадіжка сесійної кукі рівна крадіжці облікових даних.
    • OWASP Top 10 2025 — A07 Authentication Failures — новий випадковий ідентифікатор із високою ентропією після логіну, не в URL, захищена кукі, інвалідація після виходу, простою й абсолютного таймауту; повторне використання ідентифікатора після логіну як ознака вразливості.
    • OWASP Top 10 2025 — A04 Cryptographic Failures — сценарій даунгрейду HTTPS→HTTP із крадіжкою сесійної кукі.
    • RFC 7519 — JSON Web Token (JWT) — токен як альтернатива серверному запису сесії.
    • MDN — Authorization header — передавання токена заголовком запиту.

    Сховища браузера: дані, які нікуди не їдуть

    • MDN — Web Storage API — область і час життя обох сховищ, синхронність, подія storage, сторонній скрипт і вимкнені сторонні кукі, порада щодо IndexedDB.
    • MDN — Storage quotas and eviction criteria — 10 МіБ стелі, до 5 МіБ на походження, QuotaExceededError, navigator.storage.estimate(), best-effort проти persistent, приватний режим, квоти великих сховищ від повного розміру диска, фінгерпринтинг і LRU.
    • W3C — Indexed Database API 3.0 — IndexedDB як транзакційна обʼєктна база з асинхронним API.
    • MDN — JSON (JavaScript reference) — серіалізація обʼєктів через JSON.stringify/JSON.parse.
    • MDN — Authorization header — заголовок, яким застосунок явно доставляє токен на сервер.
    • MDN — Cross-site scripting (XSS) — чому доступне будь-якому скрипту сховище вразливіше за HttpOnly-кукі.
    • RFC 6265 — HTTP State Management MechanismHttpOnly як межа доступу скриптів до кукі.

    Походження: чому сховище і кукі розділені по-різному

    Що з цього видно в DevTools

    Стан у тестах: кукі й сховище в Playwright

    • Playwright — Authentication — склад знімка стану, setup-проєкт, протухання й тека виводу, UI-режим, ролі й акаунт на воркер, відновлення sessionStorage, файл стану як секрет, очікування кінцевого URL.
    • Playwright — Projects — залежність проєктів, якою setup виконується перед тестами.
    • Playwright — API testing — взаємозамінність стану між контекстом браузера й контекстом API-запитів.
    • Playwright — class BrowserContextaddCookies, clearCookies, addInitScript, опція indexedDB у storageState.
    • Selenium — Working with cookies — кукі як частина WebDriver-API й вимога бути на домені кукі.
    • Playwright — головна сторінка — свіжий контекст на кожен тест як механізм ізоляції без накладних витрат.

    Пояснення

    «Поясни» працює з власним API-ключем Anthropic: запит іде з вашого браузера прямо до Anthropic.

    Свій API-ключ (BYOK)

    Вставте власний ключ Anthropic — пояснення працюватиме на реальній моделі. Ключ зберігається лише у вашому браузері (localStorage), нікуди не надсилається, крім api.anthropic.com, і не логується.