Skip to content

Security: prodelt/typing-race-hackathon

Security

docs/security.md

Безпека: що захищено, чим і як це перевірено

Документ для журі й для того, хто підтримуватиме код. Усе нижче можна відтворити: кожен із захистів перевіряється автоматично, а тести атак запускаються в 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.

Сервер (e2e/security-server.spec.ts)

Бекенд — локальний стек, піднятий з міграцій і функцій цього репозиторію. Набір відмовляється працювати з будь-чим, крім 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 видаляє лише власника токена.

Браузер (e2e/security.spec.ts)

  • Повний набір заголовків на 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 поза межами: перевіряється лише, що ліміти спрацьовують.
  • Набір атак не доводить відсутності вразливостей; він доводить, що перелічені атаки не працюють, і ловить повернення старих помилок.

There aren't any published security advisories