13 KiB
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:
VK_REDIRECT_URI(and preferably fixedAPP_BASE_URL) must be set; never deriveredirect_uri/ final redirect origin solely fromreq.nextUrl.origin/ Host.- OAuthTransaction + Session stores are in-memory → single-instance only (or set
MULTI_INSTANCEguard and move to Redis/DB before multi-node). - 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:
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
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_SECRETonly server-side env; notNEXT_PUBLIC_*.- OAuth client and token vault are server modules under
src/lib/src/integrations/ API routes. - Frontend
AuthButtonshould 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:
- requireAuthenticatedUser (+ CSRF)
- load giveaway
- if !organizerId → Forbidden (null never authorizes)
- 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:
- Concurrent same-state callback race (double consume possible).
- In-memory OAuth + Session stores (multi-instance unsafe).
- CSRF Host vs X-Forwarded-Host trust dependency.
- 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:
- Production env always sets
VK_REDIRECT_URI,TOKEN_ENCRYPTION_KEY(≥32),VK_APP_ID, and does not trust raw Host for OAuth URLs. - Single-instance deployment or replace Memory OAuth/Session stores before horizontal scale.
- 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.