Руководство по безопасности Killerpool. Документ описывает реальное состояние кода и базы данных, включая осознанные компромиссы. Планируемые, но не реализованные меры вынесены в отдельный раздел Planned / Not Implemented.
- Обзор безопасности
- Модель безопасности
- Authentication & Authorization
- Route Protection
- Row Level Security (RLS)
- Realtime & Spectator Mode
- Security Headers
- Environment Variables
- Frontend Security
- Planned / Not Implemented
- Security Checklist
- Incident Response
- Reporting Vulnerabilities
Что реально включено:
- ✅ HTTPS enforced (Vercel)
- ✅ Row Level Security (RLS) на всех таблицах Supabase
- ✅ Auth через Supabase (Google OAuth PKCE + Magic Link), сессии в cookies через
@supabase/ssr - ✅ Секреты только в environment variables,
.env*в.gitignore - ✅ Базовые security headers в
vercel.json(3 штуки, см. Security Headers) - ✅ Запись ачивок только через
SECURITY DEFINERRPC — клиент не может выдать себе ачивку напрямую
Чего нет (см. Planned / Not Implemented):
- ❌ Content Security Policy (CSP)
- ❌ Собственный rate limiting (только встроенные лимиты Supabase Auth)
- ❌ Audit logging
- ❌ Схемная валидация входных данных (zod и т.п.)
Killerpool — client-heavy приложение:
- Серверного API нет. Директория
app/apiне существует. Единственный route handler —app/auth/callback/route.ts(OAuth callback). Все операции с данными идут через Supabase JS-клиент с публичным anon key, а авторизация обеспечивается RLS-политиками PostgreSQL. - Состояние игры живёт на клиенте — в localStorage (
killerpool_current_game,killerpool_game_history,killerpool_pending_sync,killerpool_guest_idи др., см.lib/storage.ts). В Supabase попадают только завершённые игры (autoSyncGameвlib/sync.ts) и игры с включённым live sharing (syncActiveGameToSupabase). - Следствие: периметр безопасности — это RLS-политики Supabase плюс auth. Клиентский код по определению недоверенный; любые данные, которые он может записать, ограничиваются только политиками из раздела RLS.
Поддерживаемые методы (app/auth/page.tsx):
- ✅ Google OAuth —
supabase.auth.signInWithOAuth({ provider: 'google', options: { redirectTo: ${window.location.origin}/auth/callback } }); PKCE flow обеспечивается@supabase/ssr - ✅ Magic Link —
supabase.auth.signInWithOtp({ email, options: { emailRedirectTo: ... } }) - ✅ Guest mode — кнопка «Continue as Guest» просто уводит на
/. Auth-записи нет: гость работает под рольюanon, а его стабильный UUID хранится в localStorage под ключомkillerpool_guest_id(lib/storage.ts)
Email/password auth не реализован.
OAuth callback (app/auth/callback/route.ts):
export async function GET(request: Request) {
const requestUrl = new URL(request.url)
const code = requestUrl.searchParams.get('code')
const origin = requestUrl.origin
if (code) {
const supabase = await createClient()
await supabase.auth.exchangeCodeForSession(code)
}
// URL to redirect to after sign in process completes
return NextResponse.redirect(`${origin}/`)
}Redirect всегда на / — параметр next/redirect_to из query не читается, поэтому open redirect через callback невозможен.
Сессии управляются @supabase/ssr:
lib/supabase/client.ts—createBrowserClient()для Client Componentslib/supabase/server.ts—createServerClient()с cookie-адаптером поверхnext/headersдля Server Components и Route Handlerslib/supabase/middleware.ts—updateSession(request)обновляет (refresh) сессию на каждый запрос через proxy
Токены хранятся в cookies и автоматически обновляются. JWT имеет ограниченный срок жизни, refresh происходит прозрачно.
В Next.js 16 вместо middleware.ts используется proxy.ts (конвенция Next 16). Его единственная задача — вызвать updateSession():
export async function proxy(request: NextRequest) {
return await updateSession(request)
}Внутри updateSession (lib/supabase/middleware.ts) защита роутов выглядит так — защищён только /profile:
// Protected routes - require authentication
const protectedRoutes = ['/profile']
const isProtectedRoute = protectedRoutes.some(route =>
request.nextUrl.pathname.startsWith(route)
)
if (!user && isProtectedRoute) {
const url = request.nextUrl.clone()
url.pathname = '/auth'
return NextResponse.redirect(url)
}
// Redirect authenticated users away from auth page
if (user && request.nextUrl.pathname === '/auth') {
const url = request.nextUrl.clone()
url.pathname = '/'
return NextResponse.redirect(url)
}Честное следствие: /game, /history и остальные страницы доступны без авторизации — это осознанно, потому что игра работает в guest mode и состояние живёт в localStorage. Защищать там нечего: доступ к данным в Supabase ограничивается RLS, а не роутингом. Ещё нюанс: если env-переменные Supabase не заданы, updateSession пропускает запрос без auth-проверки (fail-open) — при этом клиент Supabase всё равно не создастся, так что деградация только для redirect-логики.
RLS включён на всех таблицах (00001_initial_schema.sql). Ниже — финальное состояние политик после всех миграций (00001–00011). История правок: 00006 открыл публичное чтение игр, 00008 добавил политики live sharing, 00009 снёс все политики games и пересоздал начисто, 00011 ужесточил user_achievements.
-- SELECT: Anyone can view any game (required for spectator mode)
CREATE POLICY "games_select_all"
ON games FOR SELECT
TO anon, authenticated
USING (true);
-- INSERT: Authenticated users can create games
CREATE POLICY "games_insert_authenticated"
ON games FOR INSERT
TO authenticated
WITH CHECK (created_by = auth.uid());
-- INSERT: Anonymous users can create games (must have created_by = NULL)
CREATE POLICY "games_insert_anon"
ON games FOR INSERT
TO anon
WITH CHECK (created_by IS NULL);
-- UPDATE: Authenticated users can update their own games or games without owner
CREATE POLICY "games_update_authenticated"
ON games FOR UPDATE
TO authenticated
USING (created_by = auth.uid())
WITH CHECK (created_by = auth.uid());
-- UPDATE: Anonymous users can update games without owner
CREATE POLICY "games_update_anon"
ON games FOR UPDATE
TO anon
USING (created_by IS NULL
AND (status <> 'completed' OR updated_at > now() - interval '1 hour'))
WITH CHECK (created_by IS NULL);
-- DELETE: Authenticated users can delete their own games
CREATE POLICY "games_delete_authenticated"
ON games FOR DELETE
TO authenticated
USING (created_by = auth.uid());Честные следствия этой модели:
- Любая игра читается кем угодно (включая роль
anon) — это фича spectator mode: зритель по ссылке с UUID игры видит её состояние. Обратная сторона: UUID игры — единственный «секрет». Перебор UUID v4 практически невозможен, но кто получил ссылку — видит всё содержимое игры (имена игроков, историю ходов). Не кладите чувствительные данные в имена игроков. - Идущую гостевую игру (
created_by IS NULL) может изменить любой, кто знает её ID. Хост и зритель на уровне БД неразличимы: у обоих есть ровно ссылка, поэтому никакое условие над строкой их не разделит — для этого нужен секрет хоста. Пока партия идёт, чужая правка самозалечивается: хост при каждом ходе перезаписывает полное состояние. После завершения игра перестаёт принимать записи через час (миграция 00014), чтобы старая ссылка не оставалась правом записи навсегда. - Авторизованный пишет только свои игры (миграция 00014):
WITH CHECK (created_by = auth.uid())на INSERT и UPDATE,USINGбезOR created_by IS NULL. До 00014 любой залогиненный мог присвоить себе чужую гостевую игру одним запросом — и настоящий хост, будучиanon, терял доступ к ней навсегда. Побочное следствие: игру, сыгранную до регистрации, аккаунт присвоить уже не может (в строке нет ничего, что доказывало бы владельца), поэтому/syncпоказывает такие игры отдельной строкой «нельзя загрузить», а не ошибкой. - Удалять игры может только владелец (
created_by = auth.uid()). Гостевые игры через клиент не удаляются вообще (DELETE-политики дляanonнет).
-- Профили читаются всеми
CREATE POLICY "Public profiles are viewable by everyone"
ON player_profiles FOR SELECT
USING (true);
-- Вставка своего профиля (или анонимного)
CREATE POLICY "Users can insert their own profile"
ON player_profiles FOR INSERT
WITH CHECK (auth.uid() = user_id OR user_id IS NULL);
-- Update/Delete — только своего профиля
CREATE POLICY "Users can update their own profile"
ON player_profiles FOR UPDATE
USING (auth.uid() = user_id)
WITH CHECK (auth.uid() = user_id);
CREATE POLICY "Users can delete their own profile"
ON player_profiles FOR DELETE
USING (auth.uid() = user_id);Rulesets: SELECT для всех (USING (true)), INSERT — только authenticated (WITH CHECK (true)). UPDATE/DELETE-политик нет — переписать сид-рулсет через клиент нельзя.
Финальное состояние:
- SELECT — единственная политика
"Users can view all achievements"(USING (true)): ачивки публичны (нужны для профилей и лидерборда). - INSERT-политик нет. Миграция 00011 удалила политику
"Service role can insert achievements", которая на самом деле позволяла любому авторизованному пользователю вставлять себе произвольные ачивки (WITH CHECK auth.uid() = user_id) — это был чит-вектор. Теперь запись возможна только через RPCcheck_achievements(p_user_id UUID, p_game_id UUID)— функцияSECURITY DEFINER(обходит RLS). 00011 также отзывает дефолтныйEXECUTEуPUBLIC/anon(в Postgres он выдаётся автоматически при создании функции — 00007 этого не делала) и оставляет его толькоauthenticated/service_role. Внутри функция требуетp_user_id = auth.uid()— начислить ачивки чужому пользователю нельзя, — затем проверяет, что победивший участник игры принадлежит вызывающему, и начисляет только заслуженные типы. - Клиент вызывает её через
checkAchievements(userId, gameId)(lib/achievements.ts) после завершения игры.
Остаточный риск: check_achievements доверяет полю participants[].userId в JSONB, которое пишет клиент. Пользователь, вручную записавший выигранную игру с собственным userId у победителя, получит ачивку. Это принято как допустимый риск для игры без ставок.
get_leaderboard(limit_count) — тоже SECURITY DEFINER RPC, GRANT EXECUTE ... TO authenticated, anon (миграция 00005). Считает только completed-игры. Лидерборд публичный по дизайну.
- Таблица
gamesдобавлена в публикациюsupabase_realtime(миграция 00009). - Зрители подписываются на
postgres_changes(eventUPDATE,filter: id=eq.{gameId}) в каналеgame:{gameId}— см.subscribeToGameвlib/realtime.ts. - Хост при каждом действии upsert'ит полную строку игры (
syncActiveGameToSupabaseвlib/sync.ts). Broadcast-каналов и presence нет — единственный источник правды для зрителя это строка в таблицеgames. - Поскольку
games_select_allразрешает чтение ролиanon, realtime-подписка работает и для неавторизованных зрителей. Это намеренно.
Заголовки задаются только в vercel.json — next.config.js функции headers() не содержит. Реальный конфиг целиком:
"headers": [
{
"source": "/(.*)",
"headers": [
{ "key": "X-Content-Type-Options", "value": "nosniff" },
{ "key": "X-Frame-Options", "value": "DENY" },
{ "key": "X-XSS-Protection", "value": "1; mode=block" }
]
}
]Это всё: три заголовка. Content-Security-Policy не настроен — см. Planned / Not Implemented. Referrer-Policy и Permissions-Policy также не заданы. HTTPS форсируется платформой Vercel.
Реальный список (.env.local.example):
# Публичные (доступны в браузере)
NEXT_PUBLIC_SUPABASE_URL=your-project-url
NEXT_PUBLIC_SUPABASE_ANON_KEY=your-anon-key
NEXT_PUBLIC_APP_URL=http://localhost:3000
# Приватные (только на сервере)
# ⚠️ WARNING: This key bypasses Row Level Security - use with caution
SUPABASE_SERVICE_ROLE_KEY=your-service-role-keyanon keyпубличен по дизайну — вся защита данных лежит на RLS.SUPABASE_SERVICE_ROLE_KEYобъявлен в примере, но в коде приложения нигде не используется (серверных операций с ним нет). Держите его без префиксаNEXT_PUBLIC_— иначе он утечёт в браузерный бандл и обнулит RLS..env,.env.local,.env.*.local— в.gitignore. Никогда не коммитьте секреты.
Ротация ключей: создайте новый ключ в Supabase → обновите env в Vercel → redeploy → отзовите старый ключ.
- React экранирует контент по умолчанию;
dangerouslySetInnerHTMLв кодовой базе не используется. - Пользовательский ввод (имена игроков, email) рендерится только через JSX-интерполяцию.
- Server Actions и собственных мутирующих API-эндпоинтов нет — CSRF-поверхность классического вида отсутствует. Мутации идут в Supabase c JWT из cookie-сессии, управляемой
@supabase/ssr.
- Все запросы — через query builder Supabase (
.from('games').select().eq(...)) с параметризацией. Raw SQL с пользовательским вводом в клиентском коде нет. В RPC-функциях клиентский JSONB защищён от каст-исключений по-разному:check_achievements(00011) сравнивает id какtext = textи гейтит все::INTEGER-касты числовым regex;get_leaderboard(00005) кастуетuserIdвuuidтолько после UUID-regex, ноparticipant->>'id'кастует без защиты — не-UUID id участника уронит вызов (известное ограничение v3).
next.config.jsнастраивает@ducanh2912/next-pwa: ответы Supabase API кэшируются стратегиейNetworkFirstдо 24 часов (cacheName: 'supabase-api'). На общих устройствах данные игр могут оставаться в Cache Storage браузера после выхода из аккаунта — учитывайте при работе на чужих устройствах.
Всё в этом разделе — рекомендации и планы. В коде этого нет.
CSP сейчас отсутствует. Рекомендуемая стартовая конфигурация — добавить в vercel.json (или в headers() в next.config.js):
Content-Security-Policy:
default-src 'self';
script-src 'self' 'unsafe-eval' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
font-src 'self' https://fonts.gstatic.com;
connect-src 'self' https://*.supabase.co wss://*.supabase.co;
frame-ancestors 'none';
Примечания: connect-src должен включать wss://*.supabase.co для realtime-подписок; font-src — Google Fonts (кэшируются service worker'ом). Вводить лучше через Content-Security-Policy-Report-Only.
Referrer-Policy: strict-origin-when-cross-origin, Permissions-Policy: camera=(), microphone=(), geolocation=().
Сейчас — только встроенные лимиты Supabase Auth. Кастомный rate limiting (например, на upsert игр в proxy.ts или через Vercel WAF) не реализован.
Таблицы audit_log нет. При росте проекта стоит логировать удаление игр и начисление ачивок.
Схемной валидации (zod) нет — ограничения задаются только CHECK-констрейнтами БД (display_name_length, valid_participants, valid_history, valid_params в 00001). Валидация формы игры на клиенте — рекомендация.
- 2FA (TOTP) через
supabase.auth.mfa— не включено. - Удаление аккаунта / экспорт данных (GDPR-тулинг) — UI-флоу не реализован; удаление возможно только вручную через Supabase.
- Секрет хоста для расшаренной игры: отдельная таблица с хешем секрета и узкий SECURITY DEFINER RPC для записи — единственный способ отличить хоста от зрителя, пока партия идёт. Пока не сделано.
- Защита лидерборда от подделки:
anonпо-прежнему может вставить выдуманную завершённую игру с любымparticipants[].userId. Лечится вget_leaderboard(засчитыватьuserIdтолько если он совпадает сcreated_by), а не в RLS.
-
.env.localдля локальной разработки,.env*в.gitignore - TypeScript strict mode
-
npm auditрегулярно - Обновление зависимостей (dependabot)
- Environment variables заданы в Vercel
- Service role key не используется на клиенте (не используется вообще)
- RLS-политики настроены (миграции 00001–00011)
- Базовые security headers (
vercel.json) - HTTPS enforced (Vercel)
- CSP настроен
- Rate limiting настроен
- Мониторинг ошибок (Sentry)
- Регулярные backups БД (Supabase-managed)
- Ротация ключей каждые 90 дней
- Периодические security audits
Если обнаружена уязвимость:
- Не паникуйте — оцените серьезность
- Изолируйте — отключите затронутый функционал
- Исправьте — deploy hotfix
- Уведомите — пользователей если необходимо
- Документируйте — post-mortem
| Level | Описание | Response Time |
|---|---|---|
| Critical | RCE, data breach, обход RLS | < 1 hour |
| High | Auth bypass, SQL injection | < 4 hours |
| Medium | XSS, CSRF | < 24 hours |
| Low | Info disclosure | < 1 week |
Если вы нашли уязвимость:
- НЕ создавайте публичный issue
- Отправьте email: security@killerpool.app (или создайте private security advisory на GitHub)
- Опишите:
- Тип уязвимости
- Шаги для воспроизведения
- Потенциальный impact
- Предложенный fix (опционально)
Мы обязуемся:
- Ответить в течение 48 часов
- Исправить critical уязвимости в течение 7 дней
- Упомянуть вас в credits (с вашего согласия)
- OWASP Top 10
- Supabase Security
- Supabase Row Level Security
- Next.js Security Headers
- Content Security Policy
- npm audit — Dependency vulnerabilities
- Snyk — Real-time monitoring
- OWASP ZAP — Penetration testing
- SSL Labs — SSL/TLS testing
Документ обновлен: 2026-07-07
Последний security audit: Не проводился