Документ для журі й для того, хто підтримуватиме код. Усе нижче можна відтворити: кожен із захистів перевіряється автоматично, а тести атак запускаються в CI на кожен pull request проти локального стека Supabase (жодного запиту до живого проєкту).
Зловмисник — анонімний користувач інтернету, який має публічний anon-ключ (репозиторій публічний, ключ видно в зібраному застосунку). Він може: писати прямі запити до API й Edge Functions; підробляти журнали натискань; читати й писати в чужі рядки; підміняти токени; засипати запитами; надсилати людині посилання зі шкідливими параметрами; покласти шкідливий нік чи назву групи; (у найгіршому випадку) змінити сховище браузера. Секретів, які він міг би вкрасти, у браузері немає: service-role ключ живе лише в Edge Functions.
| Шар | Що робить | Де |
|---|---|---|
| Заголовки | CSP без unsafe-inline/eval (script-src 'self', object-src 'none', frame-ancestors 'none', connect-src лише власне походження й Supabase), HSTS на 2 роки, nosniff, X-Frame-Options: DENY, Permissions-Policy без камери, мікрофона, геолокації |
vercel.json; vite preview віддає ті самі в e2e |
| Немає HTML-синків | У коді немає dangerouslySetInnerHTML, innerHTML, eval, new Function; React екранує все, що показує |
tools/html-sinks.test.ts |
| Рядкова безпека (RLS) | Кожна таблиця з даними має політику «лише свої»; прямий запис у таблиці заборонено, записи — лише через security definer RPC та Edge Functions; profiles оновлюються лише в колонках нік, мова, налаштування |
supabase/migrations/ |
| Службові функції | Усі внутрішні функції (race_notify, settle_race, rate_race, ліміти, тригери…) відкликані в anon й authenticated |
server_hardening.sql та інші |
| Перерахунок | submit-attempt ніколи не довіряє числам клієнта: метрики перераховуються тим самим кодом packages/metrics; перевірки правдоподібності (швидкість, інтервали, час, довжина) |
supabase/functions/submit-attempt |
| Відтворення гонки | finish-race відтворює журнал по тексту кімнати (не клієнтському), не читає прапорці correct клієнта, результат незмінний |
supabase/functions/finish-race |
| Ліміти | 60 запитів за 10 хвилин на користувача й функцію (429 з Retry-After); батч ≤ 100 спроб; тіло ≤ 8 МБ (2 МБ для гонки); журнал ≤ 20 000 подій; форма тіла перевіряється до читання |
Edge Functions, rate-limit.ts |
| Realtime | Канал кімнати приватний: слухати й писати можуть лише учасники кімнати | політики realtime.messages |
| Приватність гостя | Тренування гостя (анонімного користувача) не завантажуються на сервер; гість сам видаляє серверний профіль; невикористані гостьові профілі видаляються через 30 днів | sync/cloud.ts, delete-account, cron |
| Видалення | «Видалити мої дані» видаляє лише того, чий токен у запиті, а не того, кого названо в тілі | delete-account |
| Сховище браузера | Збережені спроби, налаштування й записи читаються лише коли мають очікувану форму; решта відкидається, а не ламає застосунок | seams/store.ts |
Два набори атак, обидва в CI (e2e against a local Supabase); кожен крок записує запит, очікуване й
отримане в звіт Playwright.
Бекенд — локальний стек, піднятий з міграцій і функцій цього репозиторію. Набір відмовляється
працювати з будь-чим, крім 127.0.0.1.
- Хто може викликати функції: без токена, anon-ключ як токен, сміття, справжній токен із підміненим
sub→ завжди 401 (дляsubmit-attempt,finish-race,delete-account). submit-attempt: чесна спроба проходить; повтор нічого не додає; чужий id не перезаписує; заявлені метрики ігноруються; швидкість руки (5 мс між клавішами), 100 мс тривалості, журнал, що не дає текст, час із майбутнього, неіснуюча вправа, id завдовжки 100 КБ → відхилення з типізованою причиною; 12 перекручених тіл (рядок замість списку,null, порожній об’єкт, не-uuid, дата поза діапазоном, насіння завелике, мова не збігається з розкладкою…) → 4xx/відхилення, не 500; батч із 1500 спроб і журнал на 200 000 подій → відмова; 70 запитів поспіль → 429.finish-race: підроблений журнал (1 мс на клавішу) → не підтверджено, і вердикт остаточний; чесний журнал → підтверджено; Backspace після правильної літери → підтверджено; стороння людина → 403; кімната без старту → 409; неправильний id → 4xx; величезний журнал → відмова без довгих обчислень.- RLS й RPC: відвідувач із anon-ключем не читає жодної з 15 таблиць; інший користувач не бачить,
не змінює й не видаляє чужі рядки; нік іншого не можна забрати;
is_test,id,created_atне змінити; прямийINSERTу спроби, результати, рейтинги, таблиці лідерів, групи, учасників і кімнати заборонений; 15 внутрішніх функцій недоступні ні користувачу, ні відвідувачу; операції кімнат і груп вимагають сесії й правильного місця (гість не стартує чужу кімнату, учасник не видаляє власника й не змінює код групи, сторонній не читає групу чи кімнату); публічна таблиця лідерів містить лише документовані колонки. - Realtime: учасник слухає канал своєї кімнати, сторонній і чужа кімната — ні.
- Видалення акаунта: тіло з чужим id видаляє лише власника токена.
- Повний набір заголовків на 13 маршрутах, включно з
/sw.jsі неіснуючим. - Шкідливі адреси: скрипт у параметрі маршруту, query, фрагменті, полях помилки повернення з
Google, у
?go/lang/stage→ нічого не виконується, розмітка не з’являється. - Повернення з Google: шість видів «запам’ятаної адреси» поза власним походженням (
https://evil…,//evil…,/\evil…,javascript:,data:) → користувач лишається на своєму сайті; справжній шлях відновлюється. - Підмінене сховище: IndexedDB зі спробами неправильних типів, налаштуваннями поза діапазоном,
мегабайтним id вправи, ключами
__proto__/constructor;localStorageіз шкідливим ніком, зламаною сесією й величезним іменем → застосунок стартує, нічого не виконується,Object.prototypeне забруднений. - Вставка, перетягування й команди редагування в поверхню друку → нічого не набирається й вправа не завершується.
- Шкідливий нік із сервера → показано текстом.
| Знахідка | Тяжкість | Виправлення |
|---|---|---|
| Анонімний гість завантажував спроби з журналами натискань на сервер, хоча сторінка «Приватність» обіцяла лише локальне зберігання | Висока (приватність) | синхронізація лише для постійного акаунта; гість може видалити серверний профіль, лишивши прогрес |
finish-race ігнорував Backspace і відхиляв чесну гонку, де гравець виправився після правильної літери |
Середня (справедливість) | відтворення рухає курсор так само, як рушій клієнта |
submit-attempt на кривому тілі (null, рядок замість списку, дата поза діапазоном) падав із 500 і міг втягнути весь батч |
Середня | перевірка форми кожної спроби; відхилення замість падіння |
| Батч, тіло й журнал не мали верхньої межі | Середня (витрата ресурсів) | ліміти 100 спроб, 8/2 МБ, 20 000 подій |
Відповідь 413 за Content-Length без читання тіла тримала з’єднання |
Низька | тіло читається до перевірки розміру |
| Одна зіпсована збережена спроба або налаштування поза діапазоном перетворювали Головну на екран помилки (або зависання) | Низька (потрібен запис у сховище), стійкість | сховище читає лише коректні записи, налаштування обмежені |
- Тренувальні спроби самозасвідчувані. Текст вправи надсилає клієнт; сервер перевіряє правдоподібність і перераховує метрики, але не може довести, що текст був саме такий. Це рішення ADR-0006: рейтинги будуються лише на гонках, які відтворюються по тексту кімнати.
- Прогрес гравця в смузі гонки передається клієнтом по каналу кімнати без перевірки; це лише
візуалізація, у результат вона не потрапляє (результат дає
finish-race). userIdу відкритій таблиці лідерів. Це випадковий uuid, який потрібен клієнту, щоб виділити власний рядок; він нічого не відкриває, але це єдиний ідентифікатор, який читає відвідувач.- Анонімні входи обмежені налаштуванням Supabase (30 на годину з однієї IP-адреси), без CAPTCHA.
- Навантажувальне тестування й DoS поза межами: перевіряється лише, що ліміти спрацьовують.
- Набір атак не доводить відсутності вразливостей; він доводить, що перелічені атаки не працюють, і ловить повернення старих помилок.