randomayzer/claude_review/CLAUDE_SECURITY_REREVIEW_02a04df.md

15 KiB
Raw Permalink Blame History

Randomayzer — Независимый повторный аудит безопасности (Claude, Phase C-2)

Репозиторий: https://github.com/ochenstarik-ui/randomayzer Проверенный commit: 02a04df2719094e28db97575b9fbecb940b6ead3 Цель: независимо перепроверить, действительно ли закрыты два ранее найденных Critical (Broken Access Control, TokenVault public fallback), не доверяя отчёту Antigravity (Phase 2.2.1 / 2.2.2). Метод: клонирование репозитория, запуск тестов, чтение кода, написание и живой прогон собственного PoC-теста на реальном коде (не мок).


TL;DR

Finding Статус
CRITICAL-1: Broken Access Control PARTIALLY CLOSED — detail/mutating-эндпоинты защищены; листинг GET /api/giveaways открыт для анонимов
CRITICAL-2: TokenVault public fallback CLOSED
Release-ready? Нет, до фикса IDOR в листинге и добавления SQL-миграции

1. Таблица маршрутов Giveaway

Route Method Auth required Ownership required Guard Verdict
POST /api/giveaways POST — (создание) requireAuthenticatedUser OK
GET /api/giveaways GET (опционально) не работает для анонимов ручная фильтрация внутри route УЯЗВИМО
GET /api/giveaways/[id] GET requireGiveawayOwner OK
POST .../participants POST requireGiveawayOwner OK
GET .../participants GET requireGiveawayOwner OK
POST .../snapshot POST requireGiveawayOwner OK
POST .../draw POST requireGiveawayOwner OK
GET .../verify GET (by design) (by design) нет OK — публичная верификация, токены/PII не раскрываются
publish / update / delete таких routes не существует

2. requireGiveawayOwner — логика верна

Файл: src/lib/auth/auth-guard.ts

  • organizerId == null / ""ForbiddenError (403) — никогда не авторизует.
  • organizerId != session.user.id → 403.
  • organizerId == session.user.id → pass.
  • нет сессии → UnauthorizedError (401).

Подтверждено тестом null organizer giveaway must NEVER authorize any user (fails with 403 Forbidden) в tests/auth-guard.test.ts — прогнан живьём, 7/7 passed.


3. Создание — spoofing организатора невозможен

POST /api/giveaways:

const sessionUser = await requireAuthenticatedUser(req);
...
organizerId: sessionUser.id, // ignoring any client spoofing

Тест отправляет payload с organizerId: "usr_fake_spoofed_id" в теле — сервер игнорирует его и берёт ID из серверной сессии. Confirmed.


4. 🔴 Листинг — подтверждённый живым PoC IDOR

src/app/api/giveaways/route.ts:

const sessionUser = await getSessionFromRequest(req);
const summaries = await GiveawayStore.listSummaries();

const filteredSummaries = sessionUser
  ? summaries.filter(s => !s.organizerId || s.organizerId === sessionUser.id)
  : summaries; // ← если сессии нет — отдаётся ВСЁ

GiveawayStore.listSummaries() / listGiveawaysSummary() не принимает userId и не фильтрует на уровне репозитория (ни в Prisma-, ни в Memory-адаптере) — вся защита держится на одной строке в route-хендлере.

Написан и выполнен независимый тест против реального кода (не мок):

it('anonymous request sees every giveaway in the system (no session)', async () => {
  const req = new NextRequest('http://localhost:3000/api/giveaways'); // без cookie
  const res = await giveawaysList(req);
  const body = await res.json();
  expect(body.giveaways.map(g => g.title)).toContain('Victim Secret Giveaway');
});

Результат прогона:

ANONYMOUS SEES: [ 'Victim Secret Giveaway' ]
ATTACKER SEES: []   // авторизованный посторонний пользователь фильтруется корректно

Вывод: достаточно не отправить cookie сессии (открыть эндпоинт в приватном окне / curl без авторизации), чтобы получить полный список всех giveaways в системе — title, sourceUrl, organizerId, winnersCount, статистику розыгрыша. Для авторизованных посторонних пользователей фильтрация работает верно; проблема — именно в ветке "нет сессии".

Ни один из 7 тестов в auth-guard.test.ts не покрывает GET /api/giveaways — поэтому регресс остался незамеченным.

Рекомендация: listGiveawaysSummary должен принимать organizerId и фильтровать на уровне репозитория (SQL WHERE organizerId = ?), а не в route постфактум; для запросов без сессии — возвращать пустой список либо требовать авторизацию (401), а не отдавать общий список.


5. Public verify boundary — OK

GET /api/giveaways/[id]/verify намеренно публичен (provably-fair верификация). В ответе только winnerIds, reserveWinnerIds, хэши (deterministicProofHash, auditEventHash), snapshotId, algorithmVersion. Токенов, session-данных, credential, codeVerifier — нет. Соответствует продуктовой модели.


6. DB ownership invariant — закрыт (для чистой БД)

prisma/schema.prisma:

model Giveaway {
  organizerId String
  organizer   User   @relation(fields: [organizerId], references: [id], onDelete: Restrict)
}

organizerId — не nullable, FK с onDelete: Restrict.

MemoryGiveawayRepository.createGiveaway:

if (!input.organizerId) {
  throw new Error('FATAL: organizerId is strictly required to create a giveaway in repository');
}

Оба адаптера (Prisma и Memory) консистентны и не позволяют создать giveaway без владельца.


7. 🟠 Реальной SQL-миграции нет

find prisma/migrations — пусто. Есть только docs/MIGRATION_OWNERSHIP_INVARIANT.md с описанием плана миграции (SQL как документация, не как исполняемый Prisma migration file).

  • Для новой чистой БД (CI, dev, свежий прод) — не проблема: prisma db push/migrate dev создаст схему сразу с нужными constraints.
  • Для уже существующей продовой БД с legacy nullable organizerId — потребуется вручную выполнять ALTER TABLE ... SET NOT NULL, автоматического migration file для этого нет.

Классификация: HIGH release blocker для существующей БД, но не является незакрытым Broken Access Control для чистой БД.


89. Token Vault — fail-fast подтверждён

src/lib/auth/token-vault.ts:

if (process.env.NODE_ENV === 'production') {
  if (!rawSecret) throw new Error('FATAL CONFIGURATION ERROR: TOKEN_ENCRYPTION_KEY environment variable is strictly required in production.');
  if (rawSecret.length < 32) throw new Error('FATAL CONFIGURATION ERROR: TOKEN_ENCRYPTION_KEY must be at least 32 characters long...');
}
  • Отсутствие ключа в проде → throw (не warn, как было раньше).
  • Ключ короче 32 символов в проде → throw.
  • AUTH_SECRET-фолбэк и хардкодный dev-encryption-key-do-not-use-in-production-... из первой версии полностью убраны. Grep по репозиторию не находит старый ключ нигде вне тестовых файлов.

Build-phase bypass (NEXT_PHASE === 'phase-production-build') — легитимный, стандартный для Next.js паттерн: пропуск проверки только во время next build (статический анализ), а не во время обслуживания реальных запросов. Синглтон defaultTokenVault создаётся при импорте модуля; при реальном старте сервера в проде без ключа модуль упадёт сразу при импорте. Байпас рантайма не подтверждён.


10. Качество ключа — задокументировано неточно (LOW)

Код и .env.example честно проверяют только length >= 32 (символы, не биты энтропии) — строка "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" формально пройдёт проверку.

docs/TOKEN_STORAGE.md при этом всё ещё утверждает:

"256-bit key derived via SHA-256 from TOKEN_ENCRYPTION_KEY or AUTH_SECRET"

Упоминание AUTH_SECRET-фолбэка устарело — в текущем коде его нет. Документация не синхронизирована с кодом. Minor hardening/doc issue, не блокер.


11. Env safety — чисто

Секретов в репозитории не найдено (grep по TOKEN_ENCRYPTION_KEY=, AUTH_SECRET= вне .env.example — пусто). .env.example содержит все нужные переменные с инструкцией генерации (openssl rand -hex 32).


12. Session/Authorization coupling — корректно

sessionId = randomBytes(32).toString('hex') — opaque random identifier, не сериализованный userId. Сервер резолвит его через MemorySessionStore (getSessionFromRequest). Подделать userId через клиентский cookie нельзя — клиент не может передать произвольный sessionId, который сервер бы принял за чужого пользователя.


13. Тесты

Существующее покрытие (tests/auth-guard.test.ts, tests/token-vault.test.ts, tests/oauth-security-gate.test.ts):

  • anonymous create → 401
  • organizer spoofing через body → игнорируется
  • owner vs intruder на detail / participants / snapshot / draw → 403
  • null owner → 403
  • public verify без авторизации
  • TokenVault: missing/short key в проде → fail-fast
  • TokenVault: tampered ciphertext → fail
  • CSRF origin mismatch на logout

Отсутствует: тест на GET /api/giveaways (listing IDOR, см. п.4) — что и позволило регрессу остаться незамеченным.


Финальный вердикт

CRITICAL-1 (Broken Access Control): PARTIALLY CLOSED

Все detail/mutating-эндпоинты (GET/POST [id], participants, snapshot, draw) защищены корректно, проверено чтением кода и живым тестом. Но GET /api/giveaways (листинг) — открытый IDOR: анонимный запрос получает полный список чужих кампаний. Это тот же класс уязвимости, просто на другом эндпоинте.

CRITICAL-2 (Token Vault public fallback): CLOSED

Fail-fast в проде подтверждён кодом и тестами, хардкодный ключ убран полностью.

Новые находки

Severity Finding
🔴 CRITICAL (новая) IDOR в GET /api/giveaways для запросов без сессии — полный список чужих giveaways
🟠 HIGH Нет исполняемого SQL-migration file для NOT NULL-инварианта organizerId (риск для существующих продовых БД)
🟡 MEDIUM docs/TOKEN_STORAGE.md устарела (упоминает убранный AUTH_SECRET-фолбэк)
🟢 LOW Проверка ключа — только длина (32+ символов), не факт истинной 256-битной энтропии

Ответ на главный вопрос

"Можно ли после commit 02a04df считать два первоначальных Critical закрытыми?"

NO.

CRITICAL-2 закрыт полностью. CRITICAL-1 закрыт только частично: до релиза нужно поправить фильтрацию в GET /api/giveawayslistGiveawaysSummary должен принимать organizerId и фильтровать на уровне репозитория, а не постфактум в route; для запросов без сессии эндпоинт должен требовать авторизацию (401) или возвращать пустой список, а не общий список всех кампаний.


Метаданные проверки

  • Reviewed commit: 02a04df2719094e28db97575b9fbecb940b6ead3
  • Tests run: 169 passed. 9 файлов недоступны в песочнице из-за блокировки скачивания Prisma query-engine (binaries.prisma.sh не в allow-list сети) — инфраструктурное ограничение среды проверки, не дефект кода; подтверждено обходным путём через локальный stub Prisma-клиента: tests/auth-guard.test.ts — 7/7 passed.
  • Live PoC: написан и выполнен собственный тест GET /api/giveaways без cookie сессии → подтверждена утечка чужих giveaways.
  • Files inspected: src/lib/auth/auth-guard.ts, csrf-guard.ts, token-vault.ts, session.ts; все routes под src/app/api/giveaways/**; src/lib/repository/prisma-repository.ts, memory-repository.ts; prisma/schema.prisma; docs/MIGRATION_OWNERSHIP_INVARIANT.md, docs/TOKEN_STORAGE.md; .env.example; тесты auth-guard, token-vault, oauth-security-gate.
  • Production release recommendation: Не готово к релизу до исправления IDOR в листинге и добавления исполняемого migration file для продовых БД. После этого — можно.