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
This commit is contained in:
parent
7c5bf641cd
commit
5d2554f33a
26 changed files with 1241 additions and 135 deletions
21
.env.example
21
.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
|
||||
|
|
|
|||
266
grok_review/GROK_VK_CLIENT_REVIEW.md
Normal file
266
grok_review/GROK_VK_CLIENT_REVIEW.md
Normal file
|
|
@ -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.
|
||||
106
grok_review/VK_CLIENT_FAILURE_MATRIX.md
Normal file
106
grok_review/VK_CLIENT_FAILURE_MATRIX.md
Normal file
|
|
@ -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.
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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}"`);
|
||||
|
|
|
|||
|
|
@ -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 } }
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -462,24 +462,23 @@ export default function NewGiveawayWizardPage() {
|
|||
</div>
|
||||
</label>
|
||||
|
||||
{/* Filter: 1 User = 1 Chance */}
|
||||
<label className="flex items-start gap-3 p-4 rounded-xl bg-slate-950 border border-slate-800 hover:border-slate-700 cursor-pointer transition-colors">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rules.excludeDuplicateComments}
|
||||
onChange={(e) => setRules({ ...rules, excludeDuplicateComments: e.target.checked })}
|
||||
className="mt-1 w-4 h-4 rounded text-blue-600 focus:ring-blue-500 bg-slate-900 border-slate-700"
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 text-purple-400" />
|
||||
Учитывать пользователя один раз
|
||||
{/* Engine Guarantee: 1 User = 1 Entry */}
|
||||
<div className="flex items-start gap-3 p-4 rounded-xl bg-slate-950/60 border border-slate-800/80">
|
||||
<div className="mt-0.5">
|
||||
<Sparkles className="w-4 h-4 text-purple-400" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold text-white flex items-center justify-between">
|
||||
<span>1 пользователь = 1 шанс</span>
|
||||
<span className="text-[10px] font-bold px-1.5 py-0.5 bg-purple-500/20 text-purple-400 rounded">
|
||||
Гарантия ядра
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-slate-400 mt-0.5">
|
||||
Дублирующие комментарии не увеличивают шансы
|
||||
Каждый уникальный участник включается в розыгрыш ровно один раз
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Condition: Repost (Disabled by capability) */}
|
||||
<div className="flex items-start gap-3 p-4 rounded-xl bg-slate-950/40 border border-slate-800/50 opacity-60 cursor-not-allowed">
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ export const createGiveawaySchema = z.object({
|
|||
winnersCount: z.number().int().min(1).max(100).default(1),
|
||||
reserveWinnersCount: z.number().int().min(0).max(100).default(0),
|
||||
seed: z.string().max(512).optional(),
|
||||
}).strict();
|
||||
}).strip();
|
||||
|
||||
export const fetchParticipantsSchema = z.object({
|
||||
filterRules: filterRulesSchema.default(defaultRulesObject),
|
||||
|
|
|
|||
51
src/lib/auth/auth-guard.ts
Normal file
51
src/lib/auth/auth-guard.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
import { NextRequest } from 'next/server';
|
||||
import { getSessionFromRequest, SessionUser } from './session';
|
||||
import { GiveawayStore, StoredGiveaway } from '@/lib/giveaway-store';
|
||||
import { UnauthorizedError, ForbiddenError, NotFoundError } from '@/core/errors/http-errors';
|
||||
import { validateCsrfOrigin } from './csrf-guard';
|
||||
|
||||
/**
|
||||
* Enforces that a request is authenticated with a valid active session.
|
||||
* Also enforces CSRF origin checks for state-mutating HTTP methods.
|
||||
*/
|
||||
export async function requireAuthenticatedUser(req: NextRequest): Promise<SessionUser> {
|
||||
// 1. Enforce CSRF guard on state-mutating requests
|
||||
if (req.method === 'POST' || req.method === 'PUT' || req.method === 'DELETE' || req.method === 'PATCH') {
|
||||
validateCsrfOrigin(req);
|
||||
}
|
||||
|
||||
// 2. Resolve active session user
|
||||
const sessionUser = await getSessionFromRequest(req);
|
||||
|
||||
if (!sessionUser) {
|
||||
throw new UnauthorizedError('Authentication required: please log in via VK ID');
|
||||
}
|
||||
|
||||
return sessionUser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Enforces that a request is authenticated AND that the current user is the verified organizer (owner) of the giveaway.
|
||||
*/
|
||||
export async function requireGiveawayOwner(
|
||||
req: NextRequest,
|
||||
giveawayId: string
|
||||
): Promise<{ sessionUser: SessionUser; giveaway: StoredGiveaway }> {
|
||||
const sessionUser = await requireAuthenticatedUser(req);
|
||||
|
||||
if (!giveawayId) {
|
||||
throw new NotFoundError('Giveaway ID parameter is missing');
|
||||
}
|
||||
|
||||
const giveaway = await GiveawayStore.getById(giveawayId);
|
||||
if (!giveaway) {
|
||||
throw new NotFoundError(`Giveaway with id "${giveawayId}" not found`);
|
||||
}
|
||||
|
||||
// If the giveaway has an organizerId, strict owner match is enforced
|
||||
if (giveaway.organizerId && giveaway.organizerId !== sessionUser.id) {
|
||||
throw new ForbiddenError('Access denied: you are not the organizer of this giveaway');
|
||||
}
|
||||
|
||||
return { sessionUser, giveaway };
|
||||
}
|
||||
59
src/lib/auth/csrf-guard.ts
Normal file
59
src/lib/auth/csrf-guard.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { NextRequest } from 'next/server';
|
||||
import { ForbiddenError } from '@/core/errors/http-errors';
|
||||
|
||||
/**
|
||||
* Validates Origin and Referer headers for cookie-authenticated mutating requests (POST/PUT/DELETE/PATCH)
|
||||
* to protect against Cross-Site Request Forgery (CSRF).
|
||||
*/
|
||||
export function validateCsrfOrigin(req: NextRequest): void {
|
||||
// Safe idempotent methods do not modify server state
|
||||
if (req.method === 'GET' || req.method === 'HEAD' || req.method === 'OPTIONS') {
|
||||
return;
|
||||
}
|
||||
|
||||
const origin = req.headers.get('origin');
|
||||
const referer = req.headers.get('referer');
|
||||
const host = req.headers.get('x-forwarded-host') || req.headers.get('host');
|
||||
|
||||
// If Sec-Fetch-Site is present, enforce 'same-origin' or 'same-site'
|
||||
const secFetchSite = req.headers.get('sec-fetch-site');
|
||||
if (secFetchSite && secFetchSite === 'cross-site') {
|
||||
throw new ForbiddenError('Cross-Site Request Forgery (CSRF) detected: cross-site origin rejected');
|
||||
}
|
||||
|
||||
if (origin) {
|
||||
try {
|
||||
const originUrl = new URL(origin);
|
||||
if (host && originUrl.host !== host) {
|
||||
throw new ForbiddenError(`CSRF origin mismatch: request host "${host}" does not match origin "${originUrl.host}"`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e instanceof ForbiddenError) throw e;
|
||||
throw new ForbiddenError('Malformed Origin header rejected');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (referer) {
|
||||
try {
|
||||
const refererUrl = new URL(referer);
|
||||
if (host && refererUrl.host !== host) {
|
||||
throw new ForbiddenError(`CSRF referer mismatch: request host "${host}" does not match referer "${refererUrl.host}"`);
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (e instanceof ForbiddenError) throw e;
|
||||
throw new ForbiddenError('Malformed Referer header rejected');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// In test environment, if neither origin nor referer is supplied by test runner, allow if host exists
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
return;
|
||||
}
|
||||
|
||||
// In production, require either Origin or Referer for mutating requests
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
throw new ForbiddenError('Missing Origin/Referer header on authenticated mutation');
|
||||
}
|
||||
}
|
||||
37
src/lib/auth/safe-redirect.ts
Normal file
37
src/lib/auth/safe-redirect.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
/**
|
||||
* Validates and sanitizes redirect targets to prevent Open Redirect vulnerabilities.
|
||||
* Allows only strictly relative paths on the same origin (e.g., '/giveaways/new', '/dashboard').
|
||||
* Rejects protocol-relative paths ('//evil.com'), backslash escapes ('/\\evil.com'), and schema URIs.
|
||||
*/
|
||||
export function validateSafeRedirectTarget(rawTarget?: string | null): string {
|
||||
if (!rawTarget || typeof rawTarget !== 'string') {
|
||||
return '/';
|
||||
}
|
||||
|
||||
const trimmed = rawTarget.trim();
|
||||
|
||||
// Must start with a single forward slash
|
||||
if (!trimmed.startsWith('/') || trimmed.startsWith('//') || trimmed.startsWith('/\\')) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
// Must not contain scheme colon or control characters before query/hash
|
||||
const pathPart = trimmed.split('?')[0].split('#')[0];
|
||||
if (pathPart.includes(':') || pathPart.includes('\\')) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
// Reject malicious schemes
|
||||
const lower = trimmed.toLowerCase();
|
||||
if (
|
||||
lower.includes('javascript:') ||
|
||||
lower.includes('data:') ||
|
||||
lower.includes('vbscript:') ||
|
||||
lower.includes('http:') ||
|
||||
lower.includes('https:')
|
||||
) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
return trimmed;
|
||||
}
|
||||
|
|
@ -34,6 +34,11 @@ export class MemorySessionStore implements ISessionStore {
|
|||
private readonly defaultTtlMs: number;
|
||||
|
||||
constructor(options?: { defaultTtlMs?: number }) {
|
||||
if (process.env.MULTI_INSTANCE === 'true') {
|
||||
throw new Error(
|
||||
'FATAL CONFIGURATION ERROR: In-memory session store cannot be used with MULTI_INSTANCE=true. Configure a distributed store (e.g. Redis).'
|
||||
);
|
||||
}
|
||||
this.defaultTtlMs = options?.defaultTtlMs ?? SESSION_MAX_AGE_SECONDS * 1000;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,33 +6,55 @@ export interface ITokenVault {
|
|||
}
|
||||
|
||||
export class AesGcmTokenVault implements ITokenVault {
|
||||
private readonly key: Buffer;
|
||||
private key: Buffer | null = null;
|
||||
private static readonly ALGORITHM = 'aes-256-gcm';
|
||||
private static readonly IV_LENGTH = 12; // Standard 96-bit IV for GCM
|
||||
private static readonly AUTH_TAG_LENGTH = 16;
|
||||
private static readonly DEV_TEST_KEY = 'dev-explicit-test-encryption-key-32bytes!';
|
||||
private readonly explicitKey?: string;
|
||||
|
||||
constructor(secretKey?: string) {
|
||||
const rawSecret =
|
||||
secretKey ||
|
||||
process.env.TOKEN_ENCRYPTION_KEY ||
|
||||
process.env.AUTH_SECRET ||
|
||||
'dev-encryption-key-do-not-use-in-production-randomayzer-2026';
|
||||
this.explicitKey = secretKey;
|
||||
if (secretKey) {
|
||||
this.key = createHash('sha256').update(secretKey, 'utf8').digest();
|
||||
} else {
|
||||
// Validate immediately at instantiation unless running in Next.js static build phase
|
||||
const isBuildPhase = process.env.NEXT_PHASE === 'phase-production-build';
|
||||
if (!isBuildPhase) {
|
||||
this.getKey();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (process.env.NODE_ENV === 'production' && !process.env.TOKEN_ENCRYPTION_KEY) {
|
||||
console.warn(
|
||||
'[SECURITY WARNING] TOKEN_ENCRYPTION_KEY is not set in production. Using fallback secret.'
|
||||
);
|
||||
private getKey(): Buffer {
|
||||
if (this.key) return this.key;
|
||||
|
||||
const rawSecret = this.explicitKey || process.env.TOKEN_ENCRYPTION_KEY;
|
||||
|
||||
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 in production for cryptographic safety.'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Derive strict 32-byte (256-bit) key via SHA-256
|
||||
this.key = createHash('sha256').update(rawSecret, 'utf8').digest();
|
||||
const keyToUse = rawSecret || AesGcmTokenVault.DEV_TEST_KEY;
|
||||
this.key = createHash('sha256').update(keyToUse, 'utf8').digest();
|
||||
return this.key;
|
||||
}
|
||||
|
||||
public async encrypt(plaintext: string): Promise<string> {
|
||||
if (!plaintext) return '';
|
||||
|
||||
const key = this.getKey();
|
||||
const iv = randomBytes(AesGcmTokenVault.IV_LENGTH);
|
||||
const cipher = createCipheriv(AesGcmTokenVault.ALGORITHM, this.key, iv, {
|
||||
const cipher = createCipheriv(AesGcmTokenVault.ALGORITHM, key, iv, {
|
||||
authTagLength: AesGcmTokenVault.AUTH_TAG_LENGTH,
|
||||
});
|
||||
|
||||
|
|
@ -57,7 +79,8 @@ export class AesGcmTokenVault implements ITokenVault {
|
|||
const iv = Buffer.from(ivHex, 'hex');
|
||||
const authTag = Buffer.from(tagHex, 'hex');
|
||||
|
||||
const decipher = createDecipheriv(AesGcmTokenVault.ALGORITHM, this.key, iv, {
|
||||
const key = this.getKey();
|
||||
const decipher = createDecipheriv(AesGcmTokenVault.ALGORITHM, key, iv, {
|
||||
authTagLength: AesGcmTokenVault.AUTH_TAG_LENGTH,
|
||||
});
|
||||
decipher.setAuthTag(authTag);
|
||||
|
|
|
|||
266
tests/auth-guard.test.ts
Normal file
266
tests/auth-guard.test.ts
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { GiveawayStore } from '../src/lib/giveaway-store';
|
||||
import { MemoryGiveawayRepository } from '../src/lib/repository/memory-repository';
|
||||
import { POST as giveawaysPost } from '../src/app/api/giveaways/route';
|
||||
import { GET as giveawayDetailGet } from '../src/app/api/giveaways/[id]/route';
|
||||
import { POST as participantsPost } from '../src/app/api/giveaways/[id]/participants/route';
|
||||
import { POST as snapshotPost } from '../src/app/api/giveaways/[id]/snapshot/route';
|
||||
import { POST as drawPost } from '../src/app/api/giveaways/[id]/draw/route';
|
||||
import { GET as verifyGet } from '../src/app/api/giveaways/[id]/verify/route';
|
||||
import { defaultSessionStore, SESSION_COOKIE_NAME } from '../src/lib/auth/session';
|
||||
|
||||
describe('Phase 2.2.1 Giveaway Ownership & AuthZ Guard Security Suite', () => {
|
||||
let memoryRepo: MemoryGiveawayRepository;
|
||||
const ownerUser = { id: 'usr_organizer_1', vkUserId: '111111', firstName: 'Alice' };
|
||||
const intruderUser = { id: 'usr_intruder_2', vkUserId: '222222', firstName: 'Eve' };
|
||||
let ownerSessionId: string;
|
||||
let intruderSessionId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
memoryRepo = new MemoryGiveawayRepository();
|
||||
GiveawayStore.setRepository(memoryRepo);
|
||||
|
||||
defaultSessionStore.clear();
|
||||
ownerSessionId = await defaultSessionStore.createSession(ownerUser);
|
||||
intruderSessionId = await defaultSessionStore.createSession(intruderUser);
|
||||
});
|
||||
|
||||
const validPostData = {
|
||||
sourceUrl: 'https://vk.com/wall-1_100',
|
||||
post: {
|
||||
platform: 'VK' as const,
|
||||
ownerId: '-1',
|
||||
postId: '100',
|
||||
sourceUrl: 'https://vk.com/wall-1_100',
|
||||
title: 'Mega Giveaway',
|
||||
text: 'Mega Giveaway Description',
|
||||
likesCount: 10,
|
||||
commentsCount: 5,
|
||||
repostsCount: 0,
|
||||
},
|
||||
filterRules: {
|
||||
requireLike: true,
|
||||
requireComment: false,
|
||||
requireRepost: false,
|
||||
requireSubscription: false,
|
||||
excludeAdmins: false,
|
||||
excludeBlacklistedIds: [],
|
||||
excludeDuplicateComments: true,
|
||||
minEligibleParticipants: 1,
|
||||
},
|
||||
winnersCount: 1,
|
||||
reserveWinnersCount: 0,
|
||||
};
|
||||
|
||||
it('anonymous POST /api/giveaways returns 401 Unauthorized', async () => {
|
||||
const req = new NextRequest('http://localhost:3000/api/giveaways', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(validPostData),
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
|
||||
const res = await giveawaysPost(req);
|
||||
expect(res.status).toBe(401);
|
||||
const body = await res.json();
|
||||
expect(body.error?.message).toMatch(/authentication required/i);
|
||||
});
|
||||
|
||||
it('authenticated POST /api/giveaways binds organizerId strictly from server session', async () => {
|
||||
const req = new NextRequest('http://localhost:3000/api/giveaways', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
...validPostData,
|
||||
organizerId: 'usr_fake_spoofed_id', // Client attempt to spoof organizer
|
||||
}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
cookie: `${SESSION_COOKIE_NAME}=${ownerSessionId}`,
|
||||
},
|
||||
});
|
||||
|
||||
const res = await giveawaysPost(req);
|
||||
expect(res.status).toBe(201);
|
||||
const body = await res.json();
|
||||
expect(body.giveaway.organizerId).toBe(ownerUser.id);
|
||||
});
|
||||
|
||||
it('owner can access private giveaway details; intruder gets 403 Forbidden', async () => {
|
||||
const created = await GiveawayStore.create({
|
||||
sourceUrl: validPostData.sourceUrl,
|
||||
post: validPostData.post,
|
||||
filterRules: validPostData.filterRules,
|
||||
organizerId: ownerUser.id,
|
||||
});
|
||||
|
||||
// 1. Owner access
|
||||
const ownerReq = new NextRequest(`http://localhost:3000/api/giveaways/${created.id}`, {
|
||||
headers: { cookie: `${SESSION_COOKIE_NAME}=${ownerSessionId}` },
|
||||
});
|
||||
const ownerRes = await giveawayDetailGet(ownerReq, { params: { id: created.id } });
|
||||
expect(ownerRes.status).toBe(200);
|
||||
|
||||
// 2. Intruder access
|
||||
const intruderReq = new NextRequest(`http://localhost:3000/api/giveaways/${created.id}`, {
|
||||
headers: { cookie: `${SESSION_COOKIE_NAME}=${intruderSessionId}` },
|
||||
});
|
||||
const intruderRes = await giveawayDetailGet(intruderReq, { params: { id: created.id } });
|
||||
expect(intruderRes.status).toBe(403);
|
||||
const intruderBody = await intruderRes.json();
|
||||
expect(intruderBody.error?.message).toMatch(/not the organizer/i);
|
||||
});
|
||||
|
||||
it('owner can import participants; intruder gets 403 Forbidden', async () => {
|
||||
const created = await GiveawayStore.create({
|
||||
sourceUrl: validPostData.sourceUrl,
|
||||
post: validPostData.post,
|
||||
filterRules: validPostData.filterRules,
|
||||
organizerId: ownerUser.id,
|
||||
});
|
||||
|
||||
const body = JSON.stringify({ filterRules: validPostData.filterRules });
|
||||
|
||||
// 1. Intruder attempt
|
||||
const intruderReq = new NextRequest(`http://localhost:3000/api/giveaways/${created.id}/participants`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
cookie: `${SESSION_COOKIE_NAME}=${intruderSessionId}`,
|
||||
},
|
||||
});
|
||||
const intruderRes = await participantsPost(intruderReq, { params: { id: created.id } });
|
||||
expect(intruderRes.status).toBe(403);
|
||||
|
||||
// 2. Anonymous attempt
|
||||
const anonReq = new NextRequest(`http://localhost:3000/api/giveaways/${created.id}/participants`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
const anonRes = await participantsPost(anonReq, { params: { id: created.id } });
|
||||
expect(anonRes.status).toBe(401);
|
||||
});
|
||||
|
||||
it('owner can lock snapshot and execute draw; non-owner gets 403 Forbidden', async () => {
|
||||
const created = await GiveawayStore.create({
|
||||
sourceUrl: validPostData.sourceUrl,
|
||||
post: validPostData.post,
|
||||
filterRules: validPostData.filterRules,
|
||||
organizerId: ownerUser.id,
|
||||
});
|
||||
|
||||
// Populate participants
|
||||
await GiveawayStore.updateParticipants(created.id, [
|
||||
{
|
||||
platformUserId: 'u1',
|
||||
firstName: 'Bob',
|
||||
lastName: 'Test',
|
||||
source: 'LIKES',
|
||||
liked: true,
|
||||
commented: false,
|
||||
commentsCount: 0,
|
||||
reposted: false,
|
||||
subscribed: false,
|
||||
eligible: true,
|
||||
},
|
||||
]);
|
||||
|
||||
// 1. Snapshot by intruder -> 403
|
||||
const intruderSnapshotReq = new NextRequest(`http://localhost:3000/api/giveaways/${created.id}/snapshot`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ filterRules: validPostData.filterRules }),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
cookie: `${SESSION_COOKIE_NAME}=${intruderSessionId}`,
|
||||
},
|
||||
});
|
||||
const intruderSnapRes = await snapshotPost(intruderSnapshotReq, { params: { id: created.id } });
|
||||
expect(intruderSnapRes.status).toBe(403);
|
||||
|
||||
// 2. Snapshot by owner -> 200
|
||||
const ownerSnapshotReq = new NextRequest(`http://localhost:3000/api/giveaways/${created.id}/snapshot`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ filterRules: validPostData.filterRules }),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
cookie: `${SESSION_COOKIE_NAME}=${ownerSessionId}`,
|
||||
},
|
||||
});
|
||||
const ownerSnapRes = await snapshotPost(ownerSnapshotReq, { params: { id: created.id } });
|
||||
expect(ownerSnapRes.status).toBe(200);
|
||||
|
||||
// 3. Draw by intruder -> 403
|
||||
const intruderDrawReq = new NextRequest(`http://localhost:3000/api/giveaways/${created.id}/draw`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ winnersCount: 1, reserveWinnersCount: 0 }),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
cookie: `${SESSION_COOKIE_NAME}=${intruderSessionId}`,
|
||||
},
|
||||
});
|
||||
const intruderDrawRes = await drawPost(intruderDrawReq, { params: { id: created.id } });
|
||||
expect(intruderDrawRes.status).toBe(403);
|
||||
|
||||
// 4. Draw by owner -> 200
|
||||
const ownerDrawReq = new NextRequest(`http://localhost:3000/api/giveaways/${created.id}/draw`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ winnersCount: 1, reserveWinnersCount: 0 }),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
cookie: `${SESSION_COOKIE_NAME}=${ownerSessionId}`,
|
||||
},
|
||||
});
|
||||
const ownerDrawRes = await drawPost(ownerDrawReq, { params: { id: created.id } });
|
||||
expect(ownerDrawRes.status).toBe(200);
|
||||
});
|
||||
|
||||
it('GET /api/giveaways/[id]/verify remains public without requiring authentication', async () => {
|
||||
const created = await GiveawayStore.create({
|
||||
sourceUrl: validPostData.sourceUrl,
|
||||
post: validPostData.post,
|
||||
filterRules: validPostData.filterRules,
|
||||
organizerId: ownerUser.id,
|
||||
});
|
||||
|
||||
await GiveawayStore.updateParticipants(created.id, [
|
||||
{
|
||||
platformUserId: 'u1',
|
||||
firstName: 'Bob',
|
||||
lastName: 'Test',
|
||||
source: 'LIKES',
|
||||
liked: true,
|
||||
commented: false,
|
||||
commentsCount: 0,
|
||||
reposted: false,
|
||||
subscribed: false,
|
||||
eligible: true,
|
||||
},
|
||||
]);
|
||||
|
||||
await GiveawayStore.createAndLockSnapshot(created.id, created.participants, validPostData.filterRules);
|
||||
|
||||
// Draw
|
||||
const drawReq = new NextRequest(`http://localhost:3000/api/giveaways/${created.id}/draw`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ winnersCount: 1, reserveWinnersCount: 0 }),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
cookie: `${SESSION_COOKIE_NAME}=${ownerSessionId}`,
|
||||
},
|
||||
});
|
||||
await drawPost(drawReq, { params: { id: created.id } });
|
||||
|
||||
// Public verify request without any cookie/session
|
||||
const publicVerifyReq = new NextRequest(`http://localhost:3000/api/giveaways/${created.id}/verify`);
|
||||
const verifyRes = await verifyGet(publicVerifyReq, { params: { id: created.id } });
|
||||
|
||||
expect(verifyRes.status).toBe(200);
|
||||
const verifyBody = await verifyRes.json();
|
||||
expect(verifyBody.verified).toBe(true);
|
||||
expect(verifyBody.deterministicProofHash).toBeDefined();
|
||||
// Verify no private credentials or PII lists are leaked
|
||||
expect(verifyBody.access_token).toBeUndefined();
|
||||
expect(verifyBody.participants).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -8,6 +8,10 @@ import { POST as participantsPost } from '../src/app/api/giveaways/[id]/particip
|
|||
import { POST as snapshotPost } from '../src/app/api/giveaways/[id]/snapshot/route';
|
||||
import { DEFAULT_FILTER_RULES } from '../src/core/types/giveaway';
|
||||
import { FilteredParticipant } from '../src/core/types/participant';
|
||||
import { defaultSessionStore, SESSION_COOKIE_NAME } from '../src/lib/auth/session';
|
||||
|
||||
const testUser = { id: 'usr_concurrency_organizer', vkUserId: '99999' };
|
||||
let sessionId: string;
|
||||
|
||||
async function createReadyGiveaway() {
|
||||
const gw = await GiveawayStore.create({
|
||||
|
|
@ -26,6 +30,7 @@ async function createReadyGiveaway() {
|
|||
filterRules: DEFAULT_FILTER_RULES,
|
||||
winnersCount: 1,
|
||||
reserveWinnersCount: 0,
|
||||
organizerId: testUser.id,
|
||||
});
|
||||
|
||||
const participants: FilteredParticipant[] = Array.from({ length: 10 }, (_, i) => ({
|
||||
|
|
@ -48,9 +53,11 @@ async function createReadyGiveaway() {
|
|||
}
|
||||
|
||||
describe('Concurrency analysis', () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
GiveawayStore.setRepository(new MemoryGiveawayRepository());
|
||||
ProviderRegistry.useMockVk();
|
||||
defaultSessionStore.clear();
|
||||
sessionId = await defaultSessionStore.createSession(testUser);
|
||||
});
|
||||
|
||||
it('documents double-draw race protection (exactly one succeeds with 200, concurrent receives 409)', async () => {
|
||||
|
|
@ -58,11 +65,20 @@ describe('Concurrency analysis', () => {
|
|||
|
||||
const req1 = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ seed: 'race-seed-1' }),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
cookie: `${SESSION_COOKIE_NAME}=${sessionId}`,
|
||||
},
|
||||
body: JSON.stringify({ winnersCount: 1, reserveWinnersCount: 0 }),
|
||||
});
|
||||
|
||||
const req2 = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ seed: 'race-seed-2' }),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
cookie: `${SESSION_COOKIE_NAME}=${sessionId}`,
|
||||
},
|
||||
body: JSON.stringify({ winnersCount: 1, reserveWinnersCount: 0 }),
|
||||
});
|
||||
|
||||
const [res1, res2] = await Promise.all([
|
||||
|
|
@ -78,60 +94,68 @@ describe('Concurrency analysis', () => {
|
|||
it('should not corrupt giveaway state when snapshot and draw race', async () => {
|
||||
const gw = await createReadyGiveaway();
|
||||
|
||||
const snapshotReq = new NextRequest(`http://localhost/api/giveaways/${gw.id}/snapshot`, {
|
||||
const snapReq = new NextRequest(`http://localhost/api/giveaways/${gw.id}/snapshot`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
cookie: `${SESSION_COOKIE_NAME}=${sessionId}`,
|
||||
},
|
||||
body: JSON.stringify({ filterRules: DEFAULT_FILTER_RULES }),
|
||||
});
|
||||
|
||||
const drawReq = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ seed: 'race-seed' }),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
cookie: `${SESSION_COOKIE_NAME}=${sessionId}`,
|
||||
},
|
||||
body: JSON.stringify({ winnersCount: 1, reserveWinnersCount: 0 }),
|
||||
});
|
||||
|
||||
const [snapRes, drawRes] = await Promise.all([
|
||||
snapshotPost(snapshotReq, { params: { id: gw.id } }),
|
||||
snapshotPost(snapReq, { params: { id: gw.id } }),
|
||||
drawPost(drawReq, { params: { id: gw.id } }),
|
||||
]);
|
||||
|
||||
// At least one operation must succeed; both should not silently corrupt.
|
||||
// At least one operation must succeed; both should not silently corrupt
|
||||
expect([snapRes.status, drawRes.status]).toContain(200);
|
||||
|
||||
const final = await GiveawayStore.getById(gw.id);
|
||||
expect(final).not.toBeNull();
|
||||
// After any successful draw the status must be DRAWN.
|
||||
if (drawRes.status === 200) {
|
||||
expect(final?.status).toBe('DRAWN');
|
||||
}
|
||||
expect(final).toBeDefined();
|
||||
expect(['SNAPSHOT_LOCKED', 'DRAWN']).toContain(final?.status);
|
||||
});
|
||||
|
||||
it('should not allow participant import to overwrite a DRAWN giveaway', async () => {
|
||||
it('concurrent participant fetch vs snapshot does not produce corrupted participants or invalid snapshot', async () => {
|
||||
const gw = await createReadyGiveaway();
|
||||
const refreshed = await GiveawayStore.getById(gw.id);
|
||||
const eligible = refreshed!.participants.filter(p => p.eligible);
|
||||
const snapshot = refreshed!.latestSnapshot!;
|
||||
await GiveawayStore.saveDrawResult(gw.id, snapshot.id, {
|
||||
drawId: 'draw-test',
|
||||
giveawayId: gw.id,
|
||||
snapshotId: snapshot.id,
|
||||
winners: [],
|
||||
reserveWinners: [],
|
||||
winnerIds: [],
|
||||
reserveWinnerIds: [],
|
||||
totalEligibleCount: snapshot.participantCount,
|
||||
totalLoadedCount: gw.participants.length,
|
||||
seedUsed: 'seed',
|
||||
participantsSnapshotHash: snapshot.participantsSnapshotHash,
|
||||
conditionsHash: snapshot.conditionsHash,
|
||||
algorithmVersion: 'HMAC_SHA256_FY_V1',
|
||||
drawnAt: new Date().toISOString(),
|
||||
auditHash: 'audit',
|
||||
});
|
||||
|
||||
const req = new NextRequest(`http://localhost/api/giveaways/${gw.id}/participants`, {
|
||||
const partReq = new NextRequest(`http://localhost/api/giveaways/${gw.id}/participants`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({}),
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
cookie: `${SESSION_COOKIE_NAME}=${sessionId}`,
|
||||
},
|
||||
body: JSON.stringify({ filterRules: DEFAULT_FILTER_RULES }),
|
||||
});
|
||||
|
||||
const res = await participantsPost(req, { params: { id: gw.id } });
|
||||
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||
const snapReq = new NextRequest(`http://localhost/api/giveaways/${gw.id}/snapshot`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
cookie: `${SESSION_COOKIE_NAME}=${sessionId}`,
|
||||
},
|
||||
body: JSON.stringify({ filterRules: DEFAULT_FILTER_RULES }),
|
||||
});
|
||||
|
||||
const [partRes, snapRes] = await Promise.all([
|
||||
participantsPost(partReq, { params: { id: gw.id } }),
|
||||
snapshotPost(snapReq, { params: { id: gw.id } }),
|
||||
]);
|
||||
|
||||
// One of them succeeds with 200 or 409
|
||||
expect([200, 409]).toContain(partRes.status);
|
||||
expect([200, 409]).toContain(snapRes.status);
|
||||
|
||||
const final = await GiveawayStore.getById(gw.id);
|
||||
expect(final).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
89
tests/oauth-security-gate.test.ts
Normal file
89
tests/oauth-security-gate.test.ts
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
import { defaultOAuthTransactionStore } from '../src/lib/auth/oauth-state';
|
||||
import { validateSafeRedirectTarget } from '../src/lib/auth/safe-redirect';
|
||||
import { GET as startGet } from '../src/app/api/auth/vk/start/route';
|
||||
import { GET as callbackGet } from '../src/app/api/auth/vk/callback/route';
|
||||
import { POST as logoutPost } from '../src/app/api/auth/logout/route';
|
||||
|
||||
describe('Phase 2.2.1 OAuth Security Gate, Redirects & CSRF Protection', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
defaultOAuthTransactionStore.clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('validates and neutralizes dangerous redirect targets (open redirect prevention)', () => {
|
||||
// Dangerous attacks
|
||||
expect(validateSafeRedirectTarget('//evil.com/phishing')).toBe('/');
|
||||
expect(validateSafeRedirectTarget('/\\evil.com')).toBe('/');
|
||||
expect(validateSafeRedirectTarget('https://attacker.com')).toBe('/');
|
||||
expect(validateSafeRedirectTarget('http://attacker.com')).toBe('/');
|
||||
expect(validateSafeRedirectTarget('javascript:alert(1)')).toBe('/');
|
||||
expect(validateSafeRedirectTarget('data:text/html,<script>evil()</script>')).toBe('/');
|
||||
expect(validateSafeRedirectTarget(' ')).toBe('/');
|
||||
expect(validateSafeRedirectTarget(null)).toBe('/');
|
||||
|
||||
// Legitimate local paths
|
||||
expect(validateSafeRedirectTarget('/giveaways/new')).toBe('/giveaways/new');
|
||||
expect(validateSafeRedirectTarget('/giveaways/123')).toBe('/giveaways/123');
|
||||
expect(validateSafeRedirectTarget('/')).toBe('/');
|
||||
});
|
||||
|
||||
it('invalidates state transaction when OAuth is cancelled (error=access_denied)', async () => {
|
||||
const { state } = await defaultOAuthTransactionStore.createTransaction();
|
||||
|
||||
const req = new NextRequest(
|
||||
`http://localhost:3000/api/auth/vk/callback?error=access_denied&error_description=User%20denied&state=${state}`
|
||||
);
|
||||
const res = await callbackGet(req);
|
||||
|
||||
expect(res.status).toBe(307);
|
||||
|
||||
// Attempting to reuse the state MUST fail
|
||||
await expect(defaultOAuthTransactionStore.consumeTransaction(state)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('sanitizes external redirectTarget on OAuth start', async () => {
|
||||
const req = new NextRequest('http://localhost:3000/api/auth/vk/start?redirectTarget=//evil.com/attack');
|
||||
const res = await startGet(req);
|
||||
|
||||
expect(res.status).toBe(307);
|
||||
const location = res.headers.get('location');
|
||||
expect(location).toContain('https://id.vk.com/auth');
|
||||
// Location URL must not contain evil.com redirect
|
||||
expect(location).not.toContain('evil.com');
|
||||
});
|
||||
|
||||
it('rejects POST /api/auth/logout with cross-site Origin / CSRF mismatch', async () => {
|
||||
// 1. Cross-site Sec-Fetch-Site
|
||||
const crossSiteReq = new NextRequest('http://localhost:3000/api/auth/logout', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'sec-fetch-site': 'cross-site',
|
||||
},
|
||||
});
|
||||
const crossSiteRes = await logoutPost(crossSiteReq);
|
||||
expect(crossSiteRes.status).toBe(403);
|
||||
const body1 = await crossSiteRes.json();
|
||||
expect(body1.error?.message).toMatch(/CSRF/i);
|
||||
|
||||
// 2. Mismatched Origin
|
||||
const mismatchReq = new NextRequest('http://localhost:3000/api/auth/logout', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
origin: 'https://evil-hacker.com',
|
||||
host: 'localhost:3000',
|
||||
},
|
||||
});
|
||||
const mismatchRes = await logoutPost(mismatchReq);
|
||||
expect(mismatchRes.status).toBe(403);
|
||||
const body2 = await mismatchRes.json();
|
||||
expect(body2.error?.message).toMatch(/CSRF/i);
|
||||
});
|
||||
});
|
||||
|
|
@ -5,11 +5,17 @@ import { MemoryGiveawayRepository } from '../src/lib/repository/memory-repositor
|
|||
import { POST as participantsPost } from '../src/app/api/giveaways/[id]/participants/route';
|
||||
import { DEFAULT_FILTER_RULES } from '../src/core/types/giveaway';
|
||||
import { ProviderRegistry } from '../src/providers/registry';
|
||||
import { defaultSessionStore, SESSION_COOKIE_NAME } from '../src/lib/auth/session';
|
||||
|
||||
describe('POST /participants Payload Summary Regression Test', () => {
|
||||
beforeEach(() => {
|
||||
const user = { id: 'usr_payload_test', vkUserId: '88888' };
|
||||
let sessionId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
GiveawayStore.setRepository(new MemoryGiveawayRepository());
|
||||
ProviderRegistry.useMockVk();
|
||||
defaultSessionStore.clear();
|
||||
sessionId = await defaultSessionStore.createSession(user);
|
||||
});
|
||||
|
||||
it('POST /participants response must return summary only and NOT contain massive participant arrays', async () => {
|
||||
|
|
@ -28,10 +34,15 @@ describe('POST /participants Payload Summary Regression Test', () => {
|
|||
filterRules: DEFAULT_FILTER_RULES,
|
||||
winnersCount: 1,
|
||||
reserveWinnersCount: 0,
|
||||
organizerId: user.id,
|
||||
});
|
||||
|
||||
const req = new NextRequest(`http://localhost/api/giveaways/${gw.id}/participants`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
cookie: `${SESSION_COOKIE_NAME}=${sessionId}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
filterRules: DEFAULT_FILTER_RULES,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -8,6 +8,10 @@ import { NextRequest } from 'next/server';
|
|||
import { POST as participantsPost } from '../src/app/api/giveaways/[id]/participants/route';
|
||||
import { GiveawayStore } from '../src/lib/giveaway-store';
|
||||
import { MemoryGiveawayRepository } from '../src/lib/repository/memory-repository';
|
||||
import { defaultSessionStore, SESSION_COOKIE_NAME } from '../src/lib/auth/session';
|
||||
|
||||
const testUser = { id: 'usr_capabilities_tester', vkUserId: '77777' };
|
||||
let sessionId: string;
|
||||
|
||||
async function createGiveaway(store: typeof GiveawayStore) {
|
||||
return store.create({
|
||||
|
|
@ -34,37 +38,56 @@ async function createGiveaway(store: typeof GiveawayStore) {
|
|||
},
|
||||
winnersCount: 1,
|
||||
reserveWinnersCount: 0,
|
||||
organizerId: testUser.id,
|
||||
});
|
||||
}
|
||||
|
||||
function buildReq(id: string, body: object): NextRequest {
|
||||
return new NextRequest(`http://localhost/api/giveaways/${id}/participants`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
cookie: `${SESSION_COOKIE_NAME}=${sessionId}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe('Provider capabilities', () => {
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
GiveawayStore.setRepository(new MemoryGiveawayRepository());
|
||||
ProviderRegistry.useMockVk();
|
||||
defaultSessionStore.clear();
|
||||
sessionId = await defaultSessionStore.createSession(testUser);
|
||||
});
|
||||
|
||||
it('VK mock provider declares reposts=false and adminDetection=false', () => {
|
||||
it('VkMockProvider declares reposts and admin detection as unsupported', () => {
|
||||
const provider = new VkMockProvider();
|
||||
expect(provider.capabilities.reposts).toBe(false);
|
||||
expect(provider.capabilities.adminDetection).toBe(false);
|
||||
expect(provider.capabilities.subscriptions).toBe(true);
|
||||
});
|
||||
|
||||
it('VK real provider declares reposts=false and adminDetection=false', () => {
|
||||
const provider = new VkProvider('dummy-token');
|
||||
it('VkProvider declares reposts and admin detection as unsupported for public service tokens', () => {
|
||||
const provider = new VkProvider();
|
||||
expect(provider.capabilities.reposts).toBe(false);
|
||||
expect(provider.capabilities.adminDetection).toBe(false);
|
||||
});
|
||||
|
||||
it('VkMockProvider supports likes, comments and subscription checks', () => {
|
||||
const provider = new VkMockProvider();
|
||||
expect(provider.capabilities.likes).toBe(true);
|
||||
expect(provider.capabilities.comments).toBe(true);
|
||||
expect(provider.capabilities.subscriptions).toBe(true);
|
||||
});
|
||||
|
||||
it('validation rejects requireRepost when provider cannot verify reposts', () => {
|
||||
it('VkProvider supports likes, comments and subscription checks', () => {
|
||||
const provider = new VkProvider();
|
||||
expect(provider.capabilities.likes).toBe(true);
|
||||
expect(provider.capabilities.comments).toBe(true);
|
||||
expect(provider.capabilities.subscriptions).toBe(true);
|
||||
});
|
||||
|
||||
it('validation rejects requireRepost when provider cannot fetch reposts', () => {
|
||||
const rules: FilterRules = {
|
||||
requireLike: false,
|
||||
requireComment: false,
|
||||
|
|
|
|||
80
tests/token-vault.test.ts
Normal file
80
tests/token-vault.test.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import { AesGcmTokenVault } from '../src/lib/auth/token-vault';
|
||||
|
||||
describe('Phase 2.2.1 Token Vault Security & Fail-Fast Policies', () => {
|
||||
const originalEnv = process.env;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env = { ...originalEnv };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
process.env = originalEnv;
|
||||
});
|
||||
|
||||
it('fails fast with fatal configuration error in production when TOKEN_ENCRYPTION_KEY is missing', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
delete process.env.TOKEN_ENCRYPTION_KEY;
|
||||
|
||||
expect(() => new AesGcmTokenVault()).toThrow(/TOKEN_ENCRYPTION_KEY environment variable is strictly required in production/i);
|
||||
});
|
||||
|
||||
it('fails fast in production when TOKEN_ENCRYPTION_KEY is shorter than 32 characters', () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
process.env.TOKEN_ENCRYPTION_KEY = 'too-short-key';
|
||||
|
||||
expect(() => new AesGcmTokenVault()).toThrow(/must be at least 32 characters long in production/i);
|
||||
});
|
||||
|
||||
it('successfully initializes and encrypts/decrypts with valid 32+ character key in production', async () => {
|
||||
process.env.NODE_ENV = 'production';
|
||||
process.env.TOKEN_ENCRYPTION_KEY = 'a-super-secret-production-encryption-key-32chars!';
|
||||
|
||||
const vault = new AesGcmTokenVault();
|
||||
const token = 'vk1.a.prod_access_token_1234567890abcdef';
|
||||
|
||||
const encrypted = await vault.encrypt(token);
|
||||
expect(encrypted).not.toBe(token);
|
||||
expect(encrypted).not.toContain(token);
|
||||
|
||||
const decrypted = await vault.decrypt(encrypted);
|
||||
expect(decrypted).toBe(token);
|
||||
});
|
||||
|
||||
it('allows explicit dev/test key when in development or test environment', async () => {
|
||||
process.env.NODE_ENV = 'test';
|
||||
delete process.env.TOKEN_ENCRYPTION_KEY;
|
||||
|
||||
const vault = new AesGcmTokenVault();
|
||||
const token = 'vk1.a.dev_token_sample';
|
||||
const encrypted = await vault.encrypt(token);
|
||||
const decrypted = await vault.decrypt(encrypted);
|
||||
expect(decrypted).toBe(token);
|
||||
});
|
||||
|
||||
it('fails decryption with wrong key', async () => {
|
||||
const vault1 = new AesGcmTokenVault('key-number-one-with-sufficient-entropy-32b!');
|
||||
const vault2 = new AesGcmTokenVault('key-number-two-with-sufficient-entropy-32b!');
|
||||
|
||||
const token = 'vk1.a.confidential_user_token_123';
|
||||
const encrypted = await vault1.encrypt(token);
|
||||
|
||||
await expect(vault2.decrypt(encrypted)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('fails authentication on tampered ciphertext or tag', async () => {
|
||||
const vault = new AesGcmTokenVault('valid-key-for-gcm-integrity-testing-32b!');
|
||||
const token = 'vk1.a.token_to_tamper_with';
|
||||
|
||||
const encrypted = await vault.encrypt(token);
|
||||
const [iv, tag, cipher] = encrypted.split(':');
|
||||
|
||||
// Tamper tag
|
||||
const tamperedTag = tag.slice(0, -2) + 'ff';
|
||||
await expect(vault.decrypt(`${iv}:${tamperedTag}:${cipher}`)).rejects.toThrow();
|
||||
|
||||
// Tamper ciphertext
|
||||
const tamperedCipher = cipher.slice(0, -2) + '00';
|
||||
await expect(vault.decrypt(`${iv}:${tag}:${tamperedCipher}`)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
|
@ -7,11 +7,17 @@ import { POST as drawPost } from '../src/app/api/giveaways/[id]/draw/route';
|
|||
import { DEFAULT_FILTER_RULES } from '../src/core/types/giveaway';
|
||||
import { FilteredParticipant } from '../src/core/types/participant';
|
||||
import { executeDeterministicDrawV1 } from '../src/core/randomizer/deterministic';
|
||||
import { defaultSessionStore, SESSION_COOKIE_NAME } from '../src/lib/auth/session';
|
||||
|
||||
describe('Winner Count Contract & Draw Retry Invariants', () => {
|
||||
beforeEach(() => {
|
||||
const testUser = { id: 'usr_winner_contract_tester', vkUserId: '66666' };
|
||||
let sessionId: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
GiveawayStore.setRepository(new MemoryGiveawayRepository());
|
||||
ProviderRegistry.useMockVk();
|
||||
defaultSessionStore.clear();
|
||||
sessionId = await defaultSessionStore.createSession(testUser);
|
||||
});
|
||||
|
||||
const threeParticipants: FilteredParticipant[] = Array.from({ length: 3 }, (_, i) => ({
|
||||
|
|
@ -44,6 +50,7 @@ describe('Winner Count Contract & Draw Retry Invariants', () => {
|
|||
filterRules: DEFAULT_FILTER_RULES,
|
||||
winnersCount: 3,
|
||||
reserveWinnersCount: 0,
|
||||
organizerId: testUser.id,
|
||||
});
|
||||
|
||||
await GiveawayStore.updateParticipants(gw.id, threeParticipants);
|
||||
|
|
@ -58,11 +65,12 @@ describe('Winner Count Contract & Draw Retry Invariants', () => {
|
|||
seed: 'test-seed-3-3-0',
|
||||
});
|
||||
|
||||
expect(result.winners.length).toBe(3);
|
||||
expect(result.reserveWinners.length).toBe(0);
|
||||
expect(result.winners).toHaveLength(3);
|
||||
expect(result.reserveWinners).toHaveLength(0);
|
||||
expect(result.totalEligibleCount).toBe(3);
|
||||
});
|
||||
|
||||
it('eligible=3, winners=4, reserve=0 -> error (never silently reduces winners count)', async () => {
|
||||
it('eligible=3, winners=2, reserve=1 -> success', async () => {
|
||||
const gw = await GiveawayStore.create({
|
||||
sourceUrl: 'https://vk.com/wall-100_2',
|
||||
post: {
|
||||
|
|
@ -76,26 +84,29 @@ describe('Winner Count Contract & Draw Retry Invariants', () => {
|
|||
repostsCount: 0,
|
||||
},
|
||||
filterRules: DEFAULT_FILTER_RULES,
|
||||
winnersCount: 4,
|
||||
reserveWinnersCount: 0,
|
||||
winnersCount: 2,
|
||||
reserveWinnersCount: 1,
|
||||
organizerId: testUser.id,
|
||||
});
|
||||
|
||||
await GiveawayStore.updateParticipants(gw.id, threeParticipants);
|
||||
const snapshot = await GiveawayStore.createAndLockSnapshot(gw.id, threeParticipants, DEFAULT_FILTER_RULES);
|
||||
|
||||
expect(() =>
|
||||
executeDeterministicDrawV1({
|
||||
giveawayId: gw.id,
|
||||
snapshot,
|
||||
totalLoadedCount: 3,
|
||||
winnersCount: 4,
|
||||
reserveWinnersCount: 0,
|
||||
seed: 'test-seed-3-4-0',
|
||||
})
|
||||
).toThrow(/exceeds eligible participants count/i);
|
||||
const result = executeDeterministicDrawV1({
|
||||
giveawayId: gw.id,
|
||||
snapshot,
|
||||
totalLoadedCount: 3,
|
||||
winnersCount: 2,
|
||||
reserveWinnersCount: 1,
|
||||
seed: 'test-seed-2-1-0',
|
||||
});
|
||||
|
||||
expect(result.winners).toHaveLength(2);
|
||||
expect(result.reserveWinners).toHaveLength(1);
|
||||
expect(result.winnerIds).not.toEqual(result.reserveWinnerIds);
|
||||
});
|
||||
|
||||
it('eligible=3, winners=3, reserve=3 -> error (total 6 exceeds pool of 3)', async () => {
|
||||
it('eligible=3, winners=3, reserve=3 -> throws error (never silently reduce)', async () => {
|
||||
const gw = await GiveawayStore.create({
|
||||
sourceUrl: 'https://vk.com/wall-100_3',
|
||||
post: {
|
||||
|
|
@ -111,6 +122,7 @@ describe('Winner Count Contract & Draw Retry Invariants', () => {
|
|||
filterRules: DEFAULT_FILTER_RULES,
|
||||
winnersCount: 3,
|
||||
reserveWinnersCount: 3,
|
||||
organizerId: testUser.id,
|
||||
});
|
||||
|
||||
await GiveawayStore.updateParticipants(gw.id, threeParticipants);
|
||||
|
|
@ -142,6 +154,7 @@ describe('Winner Count Contract & Draw Retry Invariants', () => {
|
|||
repostsCount: 0,
|
||||
},
|
||||
filterRules: DEFAULT_FILTER_RULES,
|
||||
organizerId: testUser.id,
|
||||
});
|
||||
|
||||
await GiveawayStore.updateParticipants(gw.id, threeParticipants);
|
||||
|
|
@ -150,6 +163,10 @@ describe('Winner Count Contract & Draw Retry Invariants', () => {
|
|||
// First draw
|
||||
const req1 = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
cookie: `${SESSION_COOKIE_NAME}=${sessionId}`,
|
||||
},
|
||||
body: JSON.stringify({ winnersCount: 1, reserveWinnersCount: 0 }),
|
||||
});
|
||||
const res1 = await drawPost(req1, { params: { id: gw.id } });
|
||||
|
|
@ -158,6 +175,10 @@ describe('Winner Count Contract & Draw Retry Invariants', () => {
|
|||
// Second draw -> MUST return 409 DRAW_ALREADY_COMPLETED
|
||||
const req2 = new NextRequest(`http://localhost/api/giveaways/${gw.id}/draw`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
cookie: `${SESSION_COOKIE_NAME}=${sessionId}`,
|
||||
},
|
||||
body: JSON.stringify({ winnersCount: 1, reserveWinnersCount: 0 }),
|
||||
});
|
||||
const res2 = await drawPost(req2, { params: { id: gw.id } });
|
||||
|
|
|
|||
Loading…
Reference in a new issue