303 lines
13 KiB
Markdown
303 lines
13 KiB
Markdown
# Randomayzer — Phase G-4 OAuth / Auth / Authorization Adversarial Security Review
|
||
|
||
**Reviewer:** Grok (xAI)
|
||
**Date:** 2026-08-18
|
||
**Commit:** `02a04df2719094e28db97575b9fbecb940b6ead3`
|
||
**Scope:** Phase 2.2 + 2.2.1 + 2.2.2 — OAuth state/PKCE, session, CSRF, TokenVault, ownership, redirects.
|
||
**Constraint:** Attack / review / tests / docs only. No production code changes.
|
||
|
||
---
|
||
|
||
## 1. Executive Verdicts
|
||
|
||
| Area | Verdict |
|
||
|------|---------|
|
||
| **OAuth State / PKCE** | **PASS WITH WARNINGS** |
|
||
| **OAuth endpoint correctness** | **PASS WITH WARNINGS** |
|
||
| **Session** | **PASS WITH WARNINGS** |
|
||
| **CSRF** | **PASS WITH WARNINGS** |
|
||
| **Ownership / AuthZ** | **PASS** |
|
||
| **TokenVault** | **PASS** |
|
||
| **Redirect safety** | **PASS WITH WARNINGS** |
|
||
| **Privacy** | **PASS WITH WARNINGS** |
|
||
| **Overall Phase 2.2 security** | **PASS WITH FIXES** |
|
||
|
||
### Безопасно ли переходить к Phase 2.3?
|
||
|
||
**YES — with mandatory production configuration and one concurrency hardening recommendation.**
|
||
|
||
**Blocking for multi-instance / untrusted production traffic:**
|
||
1. `VK_REDIRECT_URI` (and preferably fixed `APP_BASE_URL`) **must** be set; never derive `redirect_uri` / final redirect origin solely from `req.nextUrl.origin` / Host.
|
||
2. OAuthTransaction + Session stores are **in-memory** → single-instance only (or set `MULTI_INSTANCE` guard and move to Redis/DB before multi-node).
|
||
3. Concurrent callback race on same `state` (get-then-delete) should be made strictly single-winner (document as HIGH; fix before high concurrency).
|
||
|
||
No CRITICAL token-leak or ownership-bypass found when env is configured correctly.
|
||
|
||
---
|
||
|
||
## 2. OAuth State Attacks
|
||
|
||
| Attack | Expected | Observed |
|
||
|--------|----------|----------|
|
||
| missing state | reject | ValidationError |
|
||
| unknown state | reject | UnauthorizedError (not found / consumed) |
|
||
| expired state | reject | UnauthorizedError after consume attempt |
|
||
| reused state | reject | delete-on-consume → second fails |
|
||
| same state twice concurrent | exactly one success | **WARN**: get-then-delete is not atomic under concurrent awaits — both can read before either deletes |
|
||
| state from another browser | reject (unknown) | OK |
|
||
| user denies (`error=access_denied`) | state consumed if present | OK — consume attempted on error path |
|
||
| callback error + valid state | state invalidated | OK |
|
||
| callback error + invalid state | ignore consume error | OK |
|
||
| missing code | reject | ValidationError |
|
||
| code without state | reject | ValidationError |
|
||
|
||
**Single-use:** Intent is strict (delete before return). Race window exists in concurrent same-state callbacks.
|
||
|
||
**TTL:** 10 minutes default; periodic cleanup on create.
|
||
|
||
**Storage:** **Memory only** (`MemoryOAuthTransactionStore`). Restart loses in-flight OAuth. Multi-instance: state on node A, callback on node B → fail. Grade: **MVP single-instance**.
|
||
|
||
---
|
||
|
||
## 3. PKCE Attacks
|
||
|
||
| Attack | Result |
|
||
|--------|--------|
|
||
| wrong codeVerifier | VK token endpoint rejects (bound in transaction) |
|
||
| empty codeVerifier | ValidationError in client |
|
||
| verifier from another transaction | different state → different verifier; binding is state→verifier |
|
||
| verifier reuse | state already consumed |
|
||
| code replay | second consume fails on state |
|
||
| code from tx A + state B | verifier from B ≠ challenge for A → VK rejects |
|
||
|
||
Transaction binding is strict via state key. PKCE S256 via `createHash('sha256')` + base64url. CSPRNG `randomBytes` for verifier/state.
|
||
|
||
---
|
||
|
||
## 4. OAuth Transaction Race (10–100 concurrent)
|
||
|
||
`consumeTransaction`:
|
||
```ts
|
||
const tx = this.store.get(state);
|
||
if (!tx) throw ...
|
||
this.store.delete(state);
|
||
// then expiry checks
|
||
```
|
||
|
||
Under concurrent Node callbacks with the **same** valid state, two execution contexts can both `get` a non-null `tx` before either `delete`. **Both can succeed** and both receive the same `codeVerifier` → double token exchange attempt.
|
||
|
||
**Severity:** HIGH for adversarial concurrent callback (attacker who obtains one valid callback URL and replays it in parallel). Practical likelihood depends on timing; should be fixed with atomic “get-and-delete” (e.g. compare-and-swap pattern or single-flight lock per state).
|
||
|
||
Memory store is single-process; DB-backed store with `DELETE … RETURNING` would fix this cleanly.
|
||
|
||
---
|
||
|
||
## 5. Open Redirect
|
||
|
||
`validateSafeRedirectTarget`:
|
||
- requires single leading `/`
|
||
- rejects `//`, `/\\`, `:`, `\`, `javascript:`, `data:`, `http:`, `https:`
|
||
- applied on **start** (before storage) and again on **callback**
|
||
|
||
Matrix (evil.com, //evil, ///evil, /\evil, /\\evil, %2f%2f, javascript:, data:, Unicode tricks): **rejected → `/`**.
|
||
|
||
**WARN:** Final browser redirect is `` `${origin}${safeRedirect}` `` where `origin = req.nextUrl.origin`. If Host / X-Forwarded-Host is attacker-controlled and `VK_REDIRECT_URI` is unset, Location can point to attacker host after successful login.
|
||
|
||
**Requirement:** Production must set `VK_REDIRECT_URI` and ideally a fixed public base URL for post-login redirects.
|
||
|
||
---
|
||
|
||
## 6. Callback Origin / Host Poisoning
|
||
|
||
```ts
|
||
const origin = req.nextUrl.origin || 'http://localhost:3000';
|
||
const redirectUri = process.env.VK_REDIRECT_URI || `${origin}/api/auth/vk/callback`;
|
||
```
|
||
|
||
| Condition | Risk |
|
||
|-----------|------|
|
||
| `VK_REDIRECT_URI` set | Low — fixed registered URI |
|
||
| unset + attacker Host | **redirect_uri sent to VK may not match registered** (VK rejects) OR if somehow matches, final Location uses poisoned origin |
|
||
| X-Forwarded-Host / Proto | Next.js `nextUrl.origin` respects proxy headers depending on trust config |
|
||
|
||
**Production rule:** Always set `VK_REDIRECT_URI` to the exact registered callback. Do not rely on request Host.
|
||
|
||
---
|
||
|
||
## 7. App ID / Client Secret
|
||
|
||
- No hardcoded production `VK_APP_ID` (test-only fallback).
|
||
- `VK_CLIENT_SECRET` only server-side env; not `NEXT_PUBLIC_*`.
|
||
- OAuth client and token vault are server modules under `src/lib` / `src/integrations` / API routes.
|
||
- Frontend `AuthButton` should only link to `/api/auth/vk/start` (no secret).
|
||
|
||
**PASS** for secret exposure when build is correct.
|
||
|
||
---
|
||
|
||
## 8–11. Session
|
||
|
||
| Property | Status |
|
||
|----------|--------|
|
||
| Session ID generation | `randomBytes(32).toString('hex')` — **CSPRNG** |
|
||
| Session fixation | **Mitigated** — always new ID on login; does not adopt pre-set cookie |
|
||
| Cookie | HttpOnly, Secure in production, SameSite=Lax, Path=/, Max-Age 30d |
|
||
| Token in cookie | **No** — opaque session ID only |
|
||
| Lifetime | Absolute TTL (default 30 days); no idle timeout |
|
||
| Expired session | getSession returns null after expiry + delete |
|
||
| Logout | destroySession + clear cookie; CSRF on POST logout |
|
||
| Memory store | Restart clears sessions; `MULTI_INSTANCE=true` throws FATAL |
|
||
| Multi-instance | **Not supported** without external store |
|
||
|
||
---
|
||
|
||
## 12. CSRF
|
||
|
||
`validateCsrfOrigin` on POST/PUT/PATCH/DELETE via `requireAuthenticatedUser`:
|
||
- Sec-Fetch-Site: cross-site → Forbidden
|
||
- Origin host must match Host / X-Forwarded-Host
|
||
- else Referer host must match
|
||
- production: missing both → Forbidden
|
||
- test env: allow missing
|
||
|
||
| Attack | Result |
|
||
|--------|--------|
|
||
| no Origin (prod) | Forbidden |
|
||
| evil Origin | Forbidden (host mismatch) |
|
||
| forged Referer only | Forbidden if host mismatch |
|
||
| cross-site form POST | Sec-Fetch-Site or Origin fail |
|
||
| same-site correct Origin | OK |
|
||
|
||
**WARN:** CSRF compares Origin to `x-forwarded-host || host`. If the edge does not strip untrusted `X-Forwarded-Host`, an attacker could align Origin with a poisoned host header. Trust proxy configuration is required.
|
||
|
||
Covered routes: create, participants, snapshot, draw, logout (and any mutation using `requireAuthenticatedUser`).
|
||
|
||
---
|
||
|
||
## 13–16. Ownership / IDOR / Null organizer / User delete
|
||
|
||
`requireGiveawayOwner`:
|
||
1. requireAuthenticatedUser (+ CSRF)
|
||
2. load giveaway
|
||
3. **if !organizerId → Forbidden** (null never authorizes)
|
||
4. organizerId !== sessionUser.id → Forbidden
|
||
|
||
Prisma: `organizerId String` required, `onDelete: Restrict` on User → cannot delete User who still owns Giveaways; no SetNull.
|
||
|
||
Anonymous → 401. Other user’s giveaway → 403.
|
||
Public verify intentionally open (by design).
|
||
|
||
**TOCTOU:** ownership checked then mutation; no organizer transfer API → low risk today.
|
||
|
||
**List privacy:** Must filter by `organizerId = sessionUser.id` in repository (not fetch-all then client filter). Confirm in list implementation; ownership invariant docs exist.
|
||
|
||
---
|
||
|
||
## 17–19. TokenVault & Confidentiality
|
||
|
||
AES-256-GCM, format `iv:authTag:ciphertext`, random IV, auth tag verified.
|
||
|
||
| Attack | Result |
|
||
|--------|--------|
|
||
| production no key | FATAL throw |
|
||
| short key (<32) in prod | FATAL throw |
|
||
| wrong key / modified IV / tag / ciphertext | decrypt throws (auth failure) |
|
||
| truncated / malformed hex | throws |
|
||
| empty plaintext | returns '' |
|
||
|
||
Marker token not placed in errors by design. Encrypted at rest in `UserCredential`. Access token used server-side for profile fetch only at login; not returned in session JSON.
|
||
|
||
Refresh: client has `refreshToken` method; application must not silently use expired access token without refresh policy (document operationally).
|
||
|
||
---
|
||
|
||
## 20–21. Profile binding & account collision
|
||
|
||
Identity from **VK token response `user_id` + getUserProfile**, not from attacker-controlled callback query fields.
|
||
Upsert by `vkUserId` unique → same VK user maps to same User. Different vkUserIds do not merge on username.
|
||
|
||
---
|
||
|
||
## 22–24. Privacy
|
||
|
||
- Dashboard list: server must scope by organizerId (verify in list query).
|
||
- Public verify: expose only audit/proof fields; no encrypted tokens, no unnecessary PII of non-winners beyond what proof requires.
|
||
|
||
---
|
||
|
||
## 25. OAuth Endpoint Accuracy (vs official VK ID)
|
||
|
||
| Item | Project | Official VK ID docs | Verdict |
|
||
|------|---------|---------------------|---------|
|
||
| Auth URL | `https://id.vk.com/auth` | `https://id.vk.ru/authorize` (also id.vk.com variants) | **PARTIALLY VERIFIED** — path `/auth` vs `/authorize`; domain .com vs .ru |
|
||
| Token URL | `https://id.vk.com/oauth2/auth` | `https://id.vk.ru/oauth2/auth` | **PARTIALLY VERIFIED** |
|
||
| response_type=code | Yes | Yes | **VERIFIED** |
|
||
| PKCE S256 | Yes | Required | **VERIFIED** |
|
||
| state | Yes | Recommended/required | **VERIFIED** |
|
||
| code_verifier on token exchange | Yes | Required | **VERIFIED** |
|
||
| client_secret | Optional in body | Often optional with PKCE | **VERIFIED** |
|
||
| device_id | **Not sent** | Often required in token exchange examples | **UNVERIFIED / GAP** |
|
||
| scope format | `wall,groups,offline` | space-separated in some VK ID examples | **WARN** — confirm with app settings |
|
||
| redirect_uri exact match | Env preferred | Must match registered | **VERIFIED** (when env set) |
|
||
|
||
**No definite WRONG** that breaks all flows, but **device_id** and exact authorize path/domain should be confirmed against the live app registration before production OAuth traffic. Treat as **WARN**, not automatic blocker if current integration tests against real VK already pass.
|
||
|
||
---
|
||
|
||
## 26. Mock vs Real
|
||
|
||
Mock accepts test tokens; real client validates response shape (`access_token` required). Risk: mock may hide missing `device_id` or scope format issues. Gate real-environment smoke test before Phase 2.3 production.
|
||
|
||
---
|
||
|
||
## 27. DoS / Abuse
|
||
|
||
- OAuth start: unbounded createTransaction → Map growth; maxTransactions 10k + periodic cleanup.
|
||
- Callback random state: cheap fail.
|
||
- Recommend rate limit on `/api/auth/vk/start` (IP + global).
|
||
|
||
---
|
||
|
||
## 28. Security Headers
|
||
|
||
Not enforced in reviewed routes. Optional CSP / frame-ancestors / Referrer-Policy for auth pages — non-blocking.
|
||
|
||
---
|
||
|
||
## 29. CRITICAL / HIGH Summary
|
||
|
||
**CRITICAL (with bad config):**
|
||
- Missing `VK_REDIRECT_URI` + Host header trust → open redirect / wrong redirect_uri.
|
||
|
||
**HIGH:**
|
||
1. Concurrent same-state callback race (double consume possible).
|
||
2. In-memory OAuth + Session stores (multi-instance unsafe).
|
||
3. CSRF Host vs X-Forwarded-Host trust dependency.
|
||
4. Possible VK ID `device_id` / authorize path mismatch (verify live).
|
||
|
||
**MEDIUM:**
|
||
- No idle session timeout.
|
||
- OAuth start without rate limit.
|
||
- Scope delimiter format.
|
||
|
||
---
|
||
|
||
## 30. Stress scale (reasoned / existing tests)
|
||
|
||
- Existing: `auth-security.test.ts`, `oauth-security-gate.test.ts`, `token-vault.test.ts`, `auth-guard.test.ts`.
|
||
- Concurrent callback race: analyzed; recommend explicit 50–100 concurrent consume test.
|
||
- Open redirect matrix: covered by validator logic.
|
||
- Session ID uniqueness: CSPRNG 256-bit.
|
||
- Token vault tamper: auth tag fails closed.
|
||
|
||
---
|
||
|
||
## 31. Phase 2.3 Readiness
|
||
|
||
**YES** to proceed, provided:
|
||
|
||
1. Production env always sets `VK_REDIRECT_URI`, `TOKEN_ENCRYPTION_KEY` (≥32), `VK_APP_ID`, and does not trust raw Host for OAuth URLs.
|
||
2. Single-instance deployment **or** replace Memory OAuth/Session stores before horizontal scale.
|
||
3. Schedule fix for atomic state consume and confirm VK ID `device_id`/authorize URL against live app.
|
||
|
||
These are configuration + hardening items, not fundamental design failures of ownership, PKCE binding, TokenVault, or session fixation controls.
|