From 5d2554f33aeeac15bca8b8206806dcb058deb448 Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Tue, 18 Aug 2026 03:08:02 +0700 Subject: [PATCH] feat(security): Phase 2.2.1 Critical AuthZ & Credential Security - centralized ownership guards, protected giveaway mutations, safe redirect validator, CSRF defenses, TokenVault production fail-fast, and comprehensive security tests --- .env.example | 21 +- grok_review/GROK_VK_CLIENT_REVIEW.md | 266 ++++++++++++++++++ grok_review/VK_CLIENT_FAILURE_MATRIX.md | 106 +++++++ src/app/api/auth/logout/route.ts | 4 + src/app/api/auth/vk/callback/route.ts | 28 +- src/app/api/auth/vk/start/route.ts | 6 +- src/app/api/giveaways/[id]/draw/route.ts | 12 +- .../api/giveaways/[id]/participants/route.ts | 14 +- src/app/api/giveaways/[id]/route.ts | 13 +- src/app/api/giveaways/[id]/snapshot/route.ts | 11 +- src/app/api/giveaways/[id]/verify/route.ts | 2 + src/app/api/giveaways/route.ts | 23 +- src/app/giveaways/new/page.tsx | 27 +- src/core/validation/giveaway-schemas.ts | 2 +- src/lib/auth/auth-guard.ts | 51 ++++ src/lib/auth/csrf-guard.ts | 59 ++++ src/lib/auth/safe-redirect.ts | 37 +++ src/lib/auth/session.ts | 5 + src/lib/auth/token-vault.ts | 51 +++- tests/auth-guard.test.ts | 266 ++++++++++++++++++ tests/concurrency.test.ts | 100 ++++--- tests/oauth-security-gate.test.ts | 89 ++++++ tests/payload-summary-regression.test.ts | 13 +- tests/provider-capabilities.test.ts | 35 ++- tests/token-vault.test.ts | 80 ++++++ tests/winner-count-contract.test.ts | 55 ++-- 26 files changed, 1241 insertions(+), 135 deletions(-) create mode 100644 grok_review/GROK_VK_CLIENT_REVIEW.md create mode 100644 grok_review/VK_CLIENT_FAILURE_MATRIX.md create mode 100644 src/lib/auth/auth-guard.ts create mode 100644 src/lib/auth/csrf-guard.ts create mode 100644 src/lib/auth/safe-redirect.ts create mode 100644 tests/auth-guard.test.ts create mode 100644 tests/oauth-security-gate.test.ts create mode 100644 tests/token-vault.test.ts diff --git a/.env.example b/.env.example index b23d4cc..4597cda 100644 --- a/.env.example +++ b/.env.example @@ -1,14 +1,21 @@ -# PostgreSQL Database +# PostgreSQL Database Connection DATABASE_URL="postgresql://postgres:postgres@localhost:5432/randomayzer?schema=public" -# VK API Configuration -# Сервисный ключ доступа приложения VK (для публичных запросов) -VK_SERVICE_TOKEN="your_vk_service_token_here" -# ID приложения VK +# Token Vault Encryption Key (Strictly required in production, min 32 chars / 256 bits) +# Generate with: openssl rand -hex 32 +TOKEN_ENCRYPTION_KEY="your_32_bytes_cryptographically_secure_token_encryption_key_here" + +# Application Session / Auth Secret +# Generate with: openssl rand -hex 32 +AUTH_SECRET="your_cryptographically_secure_auth_session_secret_here" + +# VK ID / VK API Configuration (OAuth 2.1 & Service Access) VK_APP_ID="your_vk_app_id_here" -# Защищенный ключ приложения -VK_APP_SECRET="your_vk_app_secret_here" +VK_CLIENT_SECRET="your_vk_client_secret_here" +VK_SERVICE_TOKEN="your_vk_service_token_here" +VK_REDIRECT_URI="http://localhost:3000/api/auth/vk/callback" # App Configuration NEXT_PUBLIC_APP_URL="http://localhost:3000" NODE_ENV="development" +STORAGE_DRIVER="memory" # Use "prisma" for production PostgreSQL, "memory" for unit tests diff --git a/grok_review/GROK_VK_CLIENT_REVIEW.md b/grok_review/GROK_VK_CLIENT_REVIEW.md new file mode 100644 index 0000000..43d0582 --- /dev/null +++ b/grok_review/GROK_VK_CLIENT_REVIEW.md @@ -0,0 +1,266 @@ +# Randomayzer — Phase G-3 VK Client Adversarial Review + +**Reviewer:** Grok (xAI) +**Date:** 2026-08-17 +**Commit reviewed:** `7acf4d2d4ed131f999936186377e85663c19316a` +**Scope:** Phase 2.1 + 2.1.1 VkClient, retry, cancellation, pagination, rate limit, token security, error mapping, method capability claims. +**Constraints:** No production Core / Randomizer / AuditProof / Prisma / OAuth implementation changes. Docs + optional tests only. + +--- + +## 1. Executive Verdicts + +| Area | Verdict | +|------|---------| +| **Cancellation / Timeout** | **PASS WITH WARNINGS** | +| **Retry** | **PASS** | +| **Pagination** | **PASS WITH WARNINGS** | +| **Token Security** | **PASS WITH WARNINGS** | +| **Error Mapping** | **PASS** | +| **VK Contract Accuracy** | **PASS WITH WARNINGS** | +| **OAuth Readiness** | **YES** (with non-blocking risks) | + +**Overall:** VkClient is solid enough to proceed to Phase 2.2 OAuth. Blocking issues are absent; remaining risks are documented and manageable. + +--- + +## 2. Cancellation vs Timeout + +### Implementation summary + +```ts +let timedOut = false; +let callerCancelled = false; +// timeout → timedOut=true; controller.abort() +// caller signal → callerCancelled=true; controller.abort() +// catch order: +// 1. if (callerCancelled || signal?.aborted) → VkCancelledError +// 2. if (timedOut) → VkTimeoutError +// 3. AbortError fallback with same priority +// finally: clearTimeout + removeEventListener +``` + +| Scenario | Expected | Observed design | +|----------|----------|-----------------| +| signal already aborted before call | VkCancelledError, 0 retries | Yes (early check) | +| abort before rate limiter | VkCancelledError | Yes (pre-acquire check) | +| abort while waiting rate limiter | VkCancelledError after slot granted | **Partial** — acquire() has no AbortSignal; abort is only observed after acquire resolves | +| abort during fetch | VkCancelledError, no retry | Yes | +| abort during retry backoff | VkCancelledError, stop retries | Yes (backoff Promise rejects on abort) | +| timeout during fetch | VkTimeoutError, retryable | Yes | +| timeout after several retries | final VkTimeoutError | Yes | +| timeout + caller abort nearly simultaneous | **VkCancelledError** (caller wins) | Deterministic: callerCancelled checked first | + +**Classification is deterministic and documented** in `docs/VK_CLIENT.md`. + +**Warning:** Rate-limiter queue wait is not abortable. Long queue under load delays cancellation observation until the slot is granted. Recommendation (proposal only): pass AbortSignal into `IVkRateLimiter.acquire(signal?)`. + +**Listener / timer hygiene:** `finally` always clears timeout and removes the abort listener (`{ once: true }` + explicit remove). No obvious MaxListeners accumulation on the happy path. Stress of 10k–100k calls should be safe if finally runs (normal Promise path). + +--- + +## 3. Retry Policy Matrix + +### HTTP status → typed error → retryable + +| HTTP | Mapped class | Retryable | Notes | +|------|--------------|-----------|-------| +| 400 | VkValidationError | No | | +| 401 | VkAuthError | No | | +| 403 | VkPermissionError | No | | +| 404 | VkNotFoundError | No | | +| 408 | VkNetworkError (fallback) | Yes* | Treated as network | +| 429 | VkRateLimitError | Yes | | +| 500–504 | VkTemporaryError | Yes | | +| other 4xx | VkValidationError | No | | +| other | VkNetworkError | Yes | | + +\*408 is not specially cased; falls through to Network (retryable). Acceptable. + +### VK API error_code → typed error → retryable + +| Code | Mapped class | Retryable | Notes | +|------|--------------|-----------|-------| +| 1 | VkTemporaryError | Yes | Unknown error | +| 5 | VkAuthError | No | Auth | +| 6 | VkRateLimitError | Yes | Too many requests / s | +| 7 | VkPermissionError | No | | +| 9 | VkRateLimitError | Yes | Flood control | +| 10 | VkTemporaryError | Yes | Internal server | +| 15 | VkPrivateResourceError | No | Access denied | +| 28 | VkAuthError | No | | +| 29 | VkRateLimitError | Yes | Rate limit | +| 30 | VkPrivateResourceError | No | Private profile | +| 36 | VkTimeoutError | Yes | Method execution timeout on VK side | +| 100 | VkValidationError | No | Invalid params | +| 104 | VkNotFoundError | No | | +| 113 | VkValidationError | No | Invalid user id | +| 203 | VkPrivateResourceError | No | | +| 210 | VkNotFoundError | No | Wall access / not found | +| 260 | VkPermissionError | No | | +| default | VkValidationError | No | Safe default | + +**Critical check:** VK error **code 500 is not treated as HTTP 500**. There is no case 500 in `mapVkApiError`; HTTP 500 is handled only in `mapHttpStatusError`. Correct separation. + +**Backoff:** exponential with full jitter, default maxRetries=3, initial 300 ms, maxDelay 4000 ms. Cancellation aborts backoff. Good. + +**Retry storm / thundering herd:** Full jitter reduces sync; global rate limiter serializes outbound calls. 100 parallel clients hitting VK 429 will queue behind the limiter + backoff — acceptable, not a thundering herd of raw HTTP. + +--- + +## 4. VK Rate Limiter + +- Default: 10 RPS, sequential FIFO queue, minInterval ≈ 100 ms. +- `acquire()` has **no AbortSignal** → cancellation while queued is delayed (see §2). +- One large likes import (many pages) occupies the single global limiter and can **starve** concurrent short calls (e.g. wall.getById for another giveaway) for the duration of the import. +- Memory: queue of resolve callbacks; 1000 concurrent is fine; 10k+ starts to matter. +- Fairness: pure FIFO, no priority lanes. + +**Proposal (non-blocking):** optional separate limiters per token type / priority, or AbortSignal on acquire. + +--- + +## 5. Pagination (`fetchPaginatedVk`) + +| Case | Behavior | Grade | +|------|----------|-------| +| 0 items | break, return [] | OK | +| 1 page | OK | OK | +| exact page boundary | continues until short page / total | OK | +| 2+ pages | accumulates | OK | +| totalCount changes mid-flight | uses latest recordedTotalCount for truncation check | OK | +| duplicated IDs across pages | accumulated as-is; provider Map dedups later | OK at client, OK at provider | +| API repeats same page forever | stopped by **maxPages** (default 10000) | **WARN** — no fingerprint / no-progress detection | +| items.length < pageSize while total larger | treated as last page (break) | OK | +| maxPages reached + truncation | throws `VkPaginationLimitError` if `throwOnTruncation` (default true) | OK — **partial set is not returned as complete** | +| caller cancel | VkCancelledError between pages | OK | +| network / rate limit mid-page | bubbles; no silent partial complete | OK | + +**Stuck pagination:** only maxPages protects against a broken VK that always returns the same non-empty page. +**Proposal:** optional loop detection (fingerprint of first/last id + offset progress). Non-blocking for Phase 2.2. + +--- + +## 6. Participant Deduplication & Subscription Batching + +**Provider:** `participantsMap` keyed by `platformUserId`. Like then comment merge actions → one Participant. Correct. + +**groups.isMember batching:** chunkSize = **500**, sequential calls. +Sizes 1 / 499 / 500 / 501 / 1000 / 1001 → all users covered, no duplicate checks of the same id in one batch. Partial failure of one chunk fails the whole `checkSubscription` (no per-chunk continue) — acceptable for correctness, could be improved later with partial results. + +**Duplicate likes pages / name change between pages:** Map overwrites with later profile data; still one entry. Deactivated users appear with whatever fields VK returns; not specially filtered here (filter engine may later). + +--- + +## 7. Token Security + +| Vector | Protection | Result | +|--------|------------|--------| +| access_token in URL | Sent in **POST form body** only | OK | +| VK error `request_params` | `sanitizeRequestParams` → `[REDACTED]` for token keys | OK | +| Error.message / method | Uses method name, not full URL+token | OK | +| Network Error wrapping `err.message` | Could theoretically contain URL if fetch implementation leaks it; current code uses generic message | Low risk | +| redactToken() helper | Present for logs | OK | +| Stack traces | Do not embed token | OK | +| Fake token `SUPER_SECRET_RANDOMAYZER_TOKEN_123456` in error paths | Sanitized in request_params; not present in constructed messages | Expected pass | + +**Warning:** Ensure no debug/logging middleware serializes the raw `URLSearchParams` body into error metadata. Current VkClient does not. + +**Form body token:** Correct choice; never lands in query string. + +--- + +## 8. VK Method Capability Claims vs Official Sources + +| Claim | Project says | Official (dev.vk.com / schema practice) | Verdict | +|-------|--------------|------------------------------------------|---------| +| wall.getById token types | service, user, group, open | Supported with those tokens | **VERIFIED** | +| likes.getList max count | Max 100 with extended=1; 1000 IDs only | Official: max **1000** (friends_only off); extended returns profiles | **PARTIALLY VERIFIED** (project is more conservative) | +| groups.isMember max user_ids | **500** | Common community/SDK limit; official page does not always spell 500 explicitly | **PARTIALLY VERIFIED** (widely used & safe) | +| wall.getReposts limitations | capabilities.reposts = false; privacy | Method exists for service/user; practical privacy limits on third-party posts | **PARTIALLY VERIFIED** (pragmatic & correct for product) | +| groups.getMembers managers for adminDetection | requires admin rights; capability false | Correct | **VERIFIED** | +| Service token usable for listed methods | Yes | Yes for wall/likes/comments/isMember | **VERIFIED** | +| Service token lifetime | (not overclaimed in client) | Long-lived app token | OK | + +No claim was found **WRONG**. Conservative count limits are safer than optimistic ones. + +--- + +## 9. Auth / OAuth Readiness + +- `VkAuthContext` already supports `SERVICE | USER | COMMUNITY` with `communityId` for group tokens. +- Factories: `createServiceAuth`, `createUserAuth`, `createCommunityAuth`. +- `validateAuthContext` enforces non-empty token and communityId for COMMUNITY. +- VkClient is token-agnostic; no hardcoded OAuth endpoints or legacy assumptions that block Phase 2.2. +- No OAuth implementation present (as required). + +**Verdict: YES — safe to start Phase 2.2 OAuth.** + +Non-blocking risks: +- Rate limiter is global (one import can delay OAuth-related calls). +- acquire() not cancellable. +- No token refresh / lifecycle hooks yet (expected for 2.2). + +--- + +## 10. Error Surface (public safety) + +Typed errors expose: `category`, `errorCode`, `method`, sanitized `details`. +They do **not** expose: access_token, full request URL with secrets, raw request_params with tokens. +Owner/post ids may appear in messages when the application constructs them (e.g. “Post X not found”) — acceptable and useful. +Safe public mapping path exists via existing `handleApiError` style (HTTP layer already maps AppErrors). + +--- + +## 11. Performance Notes + +- Pagination accumulates in memory (full list). For 100k likes this is the dominant cost (same as G-1 baseline). +- Retry overhead: up to 3 backoffs with jitter; small vs network. +- Rate limiter serializes to ~10 RPS → ~10k likes pages ≈ 1000 s theoretical floor (plus VK latency). Real large imports need background jobs (already noted in prior phases). +- Mock throughput of single call path is high; bottleneck is limiter + network. + +--- + +## 12. CRITICAL / HIGH Findings + +**CRITICAL:** none that block Phase 2.2. + +**HIGH:** +1. Rate-limiter `acquire()` ignores AbortSignal → delayed cancellation under queue load. +2. Single global limiter → large import starves other VK traffic. +3. No pagination loop-detection beyond maxPages (stuck identical pages). + +**MEDIUM:** +- likes.getList pageSize=100 is conservative vs official max 1000 (performance only). +- isMember partial chunk failure fails entire check (no partial map). +- NetworkError may surface underlying fetch message (low token risk). + +--- + +## 13. Tests Executed / Scale + +- Code review of: `vk-client.ts`, `vk-retry.ts`, `vk-errors.ts`, `vk-rate-limit.ts`, `vk-auth.ts`, `vk-provider.ts`, docs. +- Existing suites present: `vk-client-integration.test.ts`, `vk-correctness-gate.test.ts`, `vk-errors.test.ts`. +- Synthetic reasoning for cancellation races, retry matrix, token redaction, 500 concurrent queue behaviour. +- Official VK docs cross-check for capability claims (dev.vk.com). +- Stress scale for listener/timer: reasoned from `finally` + `{ once: true }`; recommend 10k mock-call run in CI. + +Optional artifact: `tests/vk-client-grok-stress.test.ts` can be added later without touching production sources. + +--- + +## 14. Final Answer + +### Безопасно ли начинать Phase 2.2 OAuth? + +**YES.** + +**Blocking issues:** none. + +**Remaining non-blocking risks:** +1. Make rate-limiter acquire abortable and/or add priority / per-token limiters before heavy production traffic. +2. Consider pagination progress fingerprint for pathological VK responses. +3. Confirm likes.getList `count` strategy (100 vs up to 1000) under real load. +4. After OAuth lands, ensure user/group tokens do not share the same global limiter bucket with long service-token imports without isolation. + +VkClient cancellation/timeout separation, retry classification, token redaction, and error mapping are production-grade for the next phase. diff --git a/grok_review/VK_CLIENT_FAILURE_MATRIX.md b/grok_review/VK_CLIENT_FAILURE_MATRIX.md new file mode 100644 index 0000000..1007131 --- /dev/null +++ b/grok_review/VK_CLIENT_FAILURE_MATRIX.md @@ -0,0 +1,106 @@ +# VK Client Failure Matrix — Phase G-3 + +**Commit:** `7acf4d2d4ed131f999936186377e85663c19316a` +**Date:** 2026-08-17 + +## Legend +- **OK** — correct typed error, no retry when forbidden, cleanup done +- **WARN** — integrity holds, UX or edge behaviour imperfect +- **GAP** — missing protection or incomplete behaviour +- **FAIL** — wrong classification or leak + +--- + +## 1. Cancellation / Timeout + +| Scenario | Error class | Retries after | Timer/listener cleanup | Grade | +|----------|-------------|---------------|------------------------|-------| +| signal already aborted | VkCancelledError | 0 | N/A | OK | +| abort before rate limiter | VkCancelledError | 0 | OK | OK | +| abort while queued in rate limiter | VkCancelledError (after slot) | 0 | OK | **WARN** (delayed) | +| abort during fetch | VkCancelledError | 0 | OK | OK | +| abort during backoff | VkCancelledError | stop | OK | OK | +| timeout during fetch | VkTimeoutError | yes (policy) | OK | OK | +| timeout after max retries | VkTimeoutError | stop | OK | OK | +| timeout + caller abort simultaneous | **VkCancelledError** (deterministic) | 0 | OK | OK | + +## 2. Retry Classification + +| Input | Class | Retryable | Grade | +|-------|-------|-----------|-------| +| HTTP 400 | Validation | No | OK | +| HTTP 401 | Auth | No | OK | +| HTTP 403 | Permission | No | OK | +| HTTP 404 | NotFound | No | OK | +| HTTP 429 | RateLimit | Yes | OK | +| HTTP 500–504 | Temporary | Yes | OK | +| VK 1, 10 | Temporary | Yes | OK | +| VK 5, 28 | Auth | No | OK | +| VK 6, 9, 29 | RateLimit | Yes | OK | +| VK 7, 260 | Permission | No | OK | +| VK 15, 30, 203 | PrivateResource | No | OK | +| VK 36 | Timeout | Yes | OK | +| VK 100, 113 | Validation | No | OK | +| VK 104, 210 | NotFound | No | OK | +| VK code 500 (API) | (no special case → Validation default) | No | OK (not confused with HTTP 500) | +| Abort / Cancel | Cancelled | **No** | OK | + +## 3. Pagination + +| Case | Result | Grade | +|------|--------|-------| +| Empty first page | [] | OK | +| Short last page | stop, return accumulated | OK | +| maxPages + truncation | VkPaginationLimitError (default) | OK — not silent partial | +| Repeated identical page | maxPages only | **WARN** | +| Cancel between pages | VkCancelledError | OK | +| Failure mid-pagination | error bubbles, no “complete” partial | OK | + +## 4. Token Security + +| Vector | Token visible? | Grade | +|--------|----------------|-------| +| Request URL | No (POST body) | OK | +| Error.message | No | OK | +| request_params in VK error | [REDACTED] | OK | +| Stack / serialized metadata | No by design | OK | +| Logs via redactToken | Safe helper present | OK | + +## 5. Rate Limiter + +| Case | Behaviour | Grade | +|------|-----------|-------| +| Steady 10 RPS | Enforced | OK | +| 1000 concurrent acquire | FIFO queue | OK / memory light | +| Cancel while queued | Delayed until slot | **WARN** | +| Large import vs short calls | Starvation possible | **WARN** | + +## 6. Capability Claims + +| Claim | Verdict | +|-------|---------| +| groups.isMember batch 500 | PARTIALLY VERIFIED | +| likes.getList max 100 extended | PARTIALLY VERIFIED (official up to 1000) | +| wall.getReposts limited / capability false | PARTIALLY VERIFIED (pragmatic) | +| wall.getById token types | VERIFIED | +| adminDetection requires managers | VERIFIED | +| Any claim WRONG | **None found** | + +## 7. Summary Grades + +| Area | Grade | +|------|-------| +| Cancellation/Timeout | **PASS WITH WARNINGS** | +| Retry | **PASS** | +| Pagination | **PASS WITH WARNINGS** | +| Token Security | **PASS WITH WARNINGS** | +| Error Mapping | **PASS** | +| VK Contract Accuracy | **PASS WITH WARNINGS** | +| OAuth Readiness | **YES** | + +## 8. OAuth Phase 2.2 Answer + +**Безопасно ли начинать Phase 2.2 OAuth? → YES** + +No blocking issues. +Non-blocking: abortable rate-limiter acquire, limiter isolation for long imports, optional pagination loop detection. diff --git a/src/app/api/auth/logout/route.ts b/src/app/api/auth/logout/route.ts index cdcc7d4..8a2c3f4 100644 --- a/src/app/api/auth/logout/route.ts +++ b/src/app/api/auth/logout/route.ts @@ -1,11 +1,15 @@ import { NextRequest, NextResponse } from 'next/server'; import { defaultSessionStore, clearSessionCookie, SESSION_COOKIE_NAME } from '@/lib/auth/session'; import { handleApiError } from '@/core/errors/http-errors'; +import { validateCsrfOrigin } from '@/lib/auth/csrf-guard'; export const dynamic = 'force-dynamic'; export async function POST(req: NextRequest) { try { + // 1. Enforce CSRF Origin / Referer validation for session destruction + validateCsrfOrigin(req); + const sessionId = req.cookies.get(SESSION_COOKIE_NAME)?.value; if (sessionId) { await defaultSessionStore.destroySession(sessionId); diff --git a/src/app/api/auth/vk/callback/route.ts b/src/app/api/auth/vk/callback/route.ts index 70b519f..7586a6e 100644 --- a/src/app/api/auth/vk/callback/route.ts +++ b/src/app/api/auth/vk/callback/route.ts @@ -4,7 +4,8 @@ import { getOAuthClient } from '../start/route'; import { defaultTokenVault } from '@/lib/auth/token-vault'; import { defaultUserRepository } from '@/lib/repository/user-repository'; import { defaultSessionStore, setSessionCookie } from '@/lib/auth/session'; -import { handleApiError, ValidationError, UnauthorizedError } from '@/core/errors/http-errors'; +import { handleApiError, ValidationError } from '@/core/errors/http-errors'; +import { validateSafeRedirectTarget } from '@/lib/auth/safe-redirect'; export const dynamic = 'force-dynamic'; @@ -20,8 +21,19 @@ export async function GET(req: NextRequest) { // 1. Handle user cancellation or VK authorization rejection if (errorParam) { - const target = `/?auth_error=${encodeURIComponent(errorDescription || errorParam)}`; - return NextResponse.redirect(`${origin}${target}`); + // Invalidate state transaction if present so it cannot be reused + if (state) { + try { + await defaultOAuthTransactionStore.consumeTransaction(state); + } catch { + // Ignore consumption error on cancellation path + } + } + + const safeErrorMsg = encodeURIComponent( + (errorDescription || errorParam).replace(/[^\w\sа-яА-ЯёЁ.,-]/gi, '').slice(0, 100) + ); + return NextResponse.redirect(`${origin}/?auth_error=${safeErrorMsg}`); } if (!code) { @@ -34,8 +46,13 @@ export async function GET(req: NextRequest) { // 2. Validate and consume single-use state transaction (recovers codeVerifier and redirectTarget) const { codeVerifier, redirectTarget } = await defaultOAuthTransactionStore.consumeTransaction(state); + const safeRedirect = validateSafeRedirectTarget(redirectTarget); + + const clientId = process.env.VK_APP_ID || (process.env.NODE_ENV === 'test' ? 'test_vk_app_id' : ''); + if (!clientId) { + throw new ValidationError('VK_APP_ID is not configured in server environment'); + } - const clientId = process.env.VK_APP_ID || process.env.NEXT_PUBLIC_VK_APP_ID || '51990000'; const clientSecret = process.env.VK_CLIENT_SECRET; const redirectUri = process.env.VK_REDIRECT_URI || `${origin}/api/auth/vk/callback`; @@ -78,8 +95,7 @@ export async function GET(req: NextRequest) { // 7. Create secure session and set HttpOnly cookie const sessionId = await defaultSessionStore.createSession(sessionUser); - const destination = redirectTarget.startsWith('/') ? redirectTarget : '/'; - const response = NextResponse.redirect(`${origin}${destination}`); + const response = NextResponse.redirect(`${origin}${safeRedirect}`); setSessionCookie(response, sessionId); return response; diff --git a/src/app/api/auth/vk/start/route.ts b/src/app/api/auth/vk/start/route.ts index cbfc638..8fc7769 100644 --- a/src/app/api/auth/vk/start/route.ts +++ b/src/app/api/auth/vk/start/route.ts @@ -3,6 +3,7 @@ import { defaultOAuthTransactionStore } from '@/lib/auth/oauth-state'; import { defaultVkOAuthClient, IVkOAuthClient } from '@/integrations/vk/vk-oauth-client'; import { MockVkOAuthClient } from '@/integrations/vk/mock-oauth-client'; import { handleApiError, ValidationError } from '@/core/errors/http-errors'; +import { validateSafeRedirectTarget } from '@/lib/auth/safe-redirect'; export const dynamic = 'force-dynamic'; @@ -16,9 +17,10 @@ export function getOAuthClient(): IVkOAuthClient { export async function GET(req: NextRequest) { try { const { searchParams } = new URL(req.url); - const redirectTarget = searchParams.get('redirectTarget') || '/'; + const rawRedirectTarget = searchParams.get('redirectTarget'); + const redirectTarget = validateSafeRedirectTarget(rawRedirectTarget); - const clientId = process.env.VK_APP_ID || process.env.NEXT_PUBLIC_VK_APP_ID || '51990000'; + const clientId = process.env.VK_APP_ID || (process.env.NODE_ENV === 'test' ? 'test_vk_app_id' : ''); if (!clientId) { throw new ValidationError('VK_APP_ID is not configured in server environment'); } diff --git a/src/app/api/giveaways/[id]/draw/route.ts b/src/app/api/giveaways/[id]/draw/route.ts index 40796f0..605fd03 100644 --- a/src/app/api/giveaways/[id]/draw/route.ts +++ b/src/app/api/giveaways/[id]/draw/route.ts @@ -5,13 +5,15 @@ import { executeDeterministicDrawV1 } from '@/core/randomizer/deterministic'; import { executeDrawSchema } from '@/core/validation/giveaway-schemas'; import { handleApiError, - NotFoundError, ConflictError, - ValidationError, + ValidationError, DrawAlreadyCompletedError } from '@/core/errors/http-errors'; import { expensiveApiRateLimiter } from '@/lib/rate-limiter'; import { resolveClientIp } from '@/lib/client-ip'; +import { requireGiveawayOwner } from '@/lib/auth/auth-guard'; + +export const dynamic = 'force-dynamic'; export async function POST( req: NextRequest, @@ -22,10 +24,8 @@ export async function POST( const clientIp = resolveClientIp(req); expensiveApiRateLimiter.assertAllowed(`draw-execute:${clientIp}:${id}`); - const giveaway = await GiveawayStore.getById(id); - if (!giveaway) { - throw new NotFoundError(`Giveaway with id "${id}" not found`); - } + // Enforce giveaway ownership authorization + const { giveaway } = await requireGiveawayOwner(req, id); // 1. Strict Terminal State Guard: If already DRAWN or PUBLISHED, return 409 DRAW_ALREADY_COMPLETED if (giveaway.status === 'DRAWN' || giveaway.status === 'PUBLISHED') { diff --git a/src/app/api/giveaways/[id]/participants/route.ts b/src/app/api/giveaways/[id]/participants/route.ts index 379f025..4044452 100644 --- a/src/app/api/giveaways/[id]/participants/route.ts +++ b/src/app/api/giveaways/[id]/participants/route.ts @@ -3,10 +3,13 @@ import { GiveawayStore } from '@/lib/giveaway-store'; import { ProviderFactory } from '@/providers/factory'; import { executeParticipantPipeline } from '@/core/pipeline/participant-enricher'; import { fetchParticipantsSchema, validateProviderCapabilities } from '@/core/validation/giveaway-schemas'; -import { handleApiError, NotFoundError } from '@/core/errors/http-errors'; +import { handleApiError } from '@/core/errors/http-errors'; import { expensiveApiRateLimiter, generalApiRateLimiter } from '@/lib/rate-limiter'; import { IdempotencyStore } from '@/lib/idempotency'; import { resolveClientIp } from '@/lib/client-ip'; +import { requireGiveawayOwner } from '@/lib/auth/auth-guard'; + +export const dynamic = 'force-dynamic'; export async function GET( req: NextRequest, @@ -17,6 +20,9 @@ export async function GET( const clientIp = resolveClientIp(req); generalApiRateLimiter.assertAllowed(`participants-get:${clientIp}`); + // Enforce giveaway ownership authorization (private participant PII data) + await requireGiveawayOwner(req, id); + const { searchParams } = new URL(req.url); const page = Math.max(1, parseInt(searchParams.get('page') || '1', 10)); const pageSize = Math.min(100, Math.max(1, parseInt(searchParams.get('pageSize') || '50', 10))); @@ -43,10 +49,8 @@ export async function POST( const clientIp = resolveClientIp(req); expensiveApiRateLimiter.assertAllowed(`participants-import:${clientIp}:${id}`); - const giveaway = await GiveawayStore.getById(id); - if (!giveaway) { - throw new NotFoundError(`Giveaway with id "${id}" not found`); - } + // Enforce giveaway ownership authorization for importing participants + const { giveaway } = await requireGiveawayOwner(req, id); const rawBody = await req.json(); const validated = fetchParticipantsSchema.parse(rawBody); diff --git a/src/app/api/giveaways/[id]/route.ts b/src/app/api/giveaways/[id]/route.ts index 2d8b04b..1fef213 100644 --- a/src/app/api/giveaways/[id]/route.ts +++ b/src/app/api/giveaways/[id]/route.ts @@ -1,8 +1,10 @@ import { NextRequest, NextResponse } from 'next/server'; -import { GiveawayStore } from '@/lib/giveaway-store'; -import { handleApiError, NotFoundError } from '@/core/errors/http-errors'; +import { handleApiError } from '@/core/errors/http-errors'; import { generalApiRateLimiter } from '@/lib/rate-limiter'; import { resolveClientIp } from '@/lib/client-ip'; +import { requireGiveawayOwner } from '@/lib/auth/auth-guard'; + +export const dynamic = 'force-dynamic'; export async function GET( req: NextRequest, @@ -13,11 +15,8 @@ export async function GET( const clientIp = resolveClientIp(req); generalApiRateLimiter.assertAllowed(`giveaway-get:${clientIp}`); - const giveaway = await GiveawayStore.getById(id); - - if (!giveaway) { - throw new NotFoundError(`Giveaway with id "${id}" not found`); - } + // Enforce giveaway ownership authorization + const { giveaway } = await requireGiveawayOwner(req, id); return NextResponse.json({ success: true, giveaway }); } catch (error: any) { diff --git a/src/app/api/giveaways/[id]/snapshot/route.ts b/src/app/api/giveaways/[id]/snapshot/route.ts index 7bf1d72..52af831 100644 --- a/src/app/api/giveaways/[id]/snapshot/route.ts +++ b/src/app/api/giveaways/[id]/snapshot/route.ts @@ -3,10 +3,13 @@ import { GiveawayStore } from '@/lib/giveaway-store'; import { ProviderFactory } from '@/providers/factory'; import { applyFilterRules } from '@/core/filtering/filter-engine'; import { createSnapshotSchema, validateProviderCapabilities } from '@/core/validation/giveaway-schemas'; -import { handleApiError, NotFoundError, ConflictError } from '@/core/errors/http-errors'; +import { handleApiError, ConflictError } from '@/core/errors/http-errors'; import { expensiveApiRateLimiter } from '@/lib/rate-limiter'; import { IdempotencyStore } from '@/lib/idempotency'; import { resolveClientIp } from '@/lib/client-ip'; +import { requireGiveawayOwner } from '@/lib/auth/auth-guard'; + +export const dynamic = 'force-dynamic'; export async function POST( req: NextRequest, @@ -17,10 +20,8 @@ export async function POST( const clientIp = resolveClientIp(req); expensiveApiRateLimiter.assertAllowed(`snapshot-lock:${clientIp}:${id}`); - const giveaway = await GiveawayStore.getById(id); - if (!giveaway) { - throw new NotFoundError(`Giveaway with id "${id}" not found`); - } + // Enforce giveaway ownership authorization + const { giveaway } = await requireGiveawayOwner(req, id); if (giveaway.status === 'DRAWN' || giveaway.status === 'PUBLISHED') { throw new ConflictError(`Cannot create new snapshot for giveaway in status "${giveaway.status}"`); diff --git a/src/app/api/giveaways/[id]/verify/route.ts b/src/app/api/giveaways/[id]/verify/route.ts index 15d01e5..5867dc0 100644 --- a/src/app/api/giveaways/[id]/verify/route.ts +++ b/src/app/api/giveaways/[id]/verify/route.ts @@ -5,6 +5,8 @@ import { handleApiError, NotFoundError, ConflictError } from '@/core/errors/http import { expensiveApiRateLimiter } from '@/lib/rate-limiter'; import { resolveClientIp } from '@/lib/client-ip'; +export const dynamic = 'force-dynamic'; + export async function GET( req: NextRequest, { params }: { params: { id: string } } diff --git a/src/app/api/giveaways/route.ts b/src/app/api/giveaways/route.ts index 3842382..05bf4cc 100644 --- a/src/app/api/giveaways/route.ts +++ b/src/app/api/giveaways/route.ts @@ -5,19 +5,28 @@ import { handleApiError } from '@/core/errors/http-errors'; import { generalApiRateLimiter } from '@/lib/rate-limiter'; import { IdempotencyStore } from '@/lib/idempotency'; import { resolveClientIp } from '@/lib/client-ip'; +import { requireAuthenticatedUser } from '@/lib/auth/auth-guard'; import { getSessionFromRequest } from '@/lib/auth/session'; +export const dynamic = 'force-dynamic'; + export async function GET(req: NextRequest) { try { const clientIp = resolveClientIp(req); generalApiRateLimiter.assertAllowed(`giveaways-list:${clientIp}`); - // Return lightweight summary for scalability (no massive participant/snapshot payloads) + const sessionUser = await getSessionFromRequest(req); const summaries = await GiveawayStore.listSummaries(); + + // If organizer is logged in, show their giveaways (or all if requested) + const filteredSummaries = sessionUser + ? summaries.filter(s => !s.organizerId || s.organizerId === sessionUser.id) + : summaries; + return NextResponse.json({ success: true, - giveaways: summaries, - totalCount: summaries.length, + giveaways: filteredSummaries, + totalCount: filteredSummaries.length, }); } catch (error: any) { return handleApiError(error); @@ -29,6 +38,9 @@ export async function POST(req: NextRequest) { const clientIp = resolveClientIp(req); generalApiRateLimiter.assertAllowed(`giveaway-create:${clientIp}`); + // 1. Mandatory authentication guard for giveaway creation + const sessionUser = await requireAuthenticatedUser(req); + const rawBody = await req.json(); const validated = createGiveawaySchema.parse(rawBody); @@ -44,8 +56,7 @@ export async function POST(req: NextRequest) { } } - const sessionUser = await getSessionFromRequest(req); - + // 2. Set organizerId strictly from server session (ignoring any client spoofing) const giveaway = await GiveawayStore.create({ sourceUrl: validated.sourceUrl, post: validated.post, @@ -53,7 +64,7 @@ export async function POST(req: NextRequest) { winnersCount: validated.winnersCount, reserveWinnersCount: validated.reserveWinnersCount, seed: validated.seed, - organizerId: sessionUser?.id, + organizerId: sessionUser.id, }); const responseBody = { diff --git a/src/app/giveaways/new/page.tsx b/src/app/giveaways/new/page.tsx index d5bc651..70ec655 100644 --- a/src/app/giveaways/new/page.tsx +++ b/src/app/giveaways/new/page.tsx @@ -462,24 +462,23 @@ export default function NewGiveawayWizardPage() { - {/* Filter: 1 User = 1 Chance */} -