randomayzer/claude_review/CLAUDE_SECURITY_REREVIEW_02a04df.md

228 lines
15 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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`:
```ts
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`:
```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-хендлере.
**Написан и выполнен независимый тест против реального кода** (не мок):
```ts
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`:
```prisma
model Giveaway {
organizerId String
organizer User @relation(fields: [organizerId], references: [id], onDelete: Restrict)
}
```
`organizerId` — не nullable, FK с `onDelete: Restrict`.
`MemoryGiveawayRepository.createGiveaway`:
```ts
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`:
```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/giveaways``listGiveawaysSummary` должен принимать `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 для продовых БД. После этого — можно.