105 lines
6.2 KiB
Markdown
105 lines
6.2 KiB
Markdown
# Randomayzer — Production Guards & Security Architecture
|
|
|
|
This document details the production integrity guards, concurrency safeguards, rate limiting, and idempotency semantics implemented in **Randomayzer**.
|
|
|
|
---
|
|
|
|
## 1. Idempotency Hardening & Request Fingerprinting
|
|
|
|
### Contract
|
|
The API supports the standard `Idempotency-Key` HTTP header on state-mutating endpoints:
|
|
- `POST /api/giveaways` (Giveaway creation)
|
|
- `POST /api/giveaways/[id]/participants` (Participant import & enrichment)
|
|
- `POST /api/giveaways/[id]/snapshot` (Participant snapshot locking)
|
|
|
|
### Semantics
|
|
1. **Scoped Storage Key**:
|
|
Keys are composite and scoped by `operation`, `giveawayId`, and `idempotencyKey`:
|
|
`format: ${operation}:${giveawayId || 'global'}:${key}`
|
|
This prevents cross-endpoint and cross-giveaway key collision.
|
|
2. **Request Fingerprinting**:
|
|
Every request computes a canonical SHA-256 fingerprint:
|
|
`requestFingerprint = SHA256(canonicalStringify(requestPayload))`
|
|
3. **Replay vs Conflict Handling**:
|
|
- **Same Key + Same Request**: Returns the previously cached status code and response body without re-executing.
|
|
- **Same Key + Different Request**: Throws HTTP `409 Conflict` with error code `IDEMPOTENCY_KEY_REUSED`.
|
|
4. **Key Validation & TTL**:
|
|
- Maximum key length is 128 characters (exceeding length returns `400 VALIDATION_ERROR`).
|
|
- Default TTL is 5 minutes with proactive cleanup and upper memory bounds.
|
|
|
|
---
|
|
|
|
## 2. Rate Limiting & Client Identity Resolution
|
|
|
|
### Client Identity Scoping Architecture
|
|
- **Authenticated Routes (`/api/giveaways*`)**:
|
|
Rate limits are keyed strictly by trusted server-side `sessionUser.id` (e.g. `draw-execute:${sessionUser.id}:${id}`, `giveaways-list:${sessionUser.id}`) **after** session authentication and ownership checks.
|
|
This ensures:
|
|
1. Different organizers have isolated rate limit buckets and never block each other, even when `req.ip` is unpopulated.
|
|
2. Unauthenticated attackers receive `401 Unauthorized` before reaching the rate limiter and cannot drain any organizer's quota.
|
|
- **Anonymous / Hybrid Routes (`/api/posts/preview`, `/api/auth/vk/start`, `/api/giveaways/[id]/verify`)**:
|
|
- `POST /api/posts/preview`: Uses `post-preview:user:${sessionUser.id}` if a valid session exists, and `post-preview:anon:${clientIp}` if anonymous. An anonymous attacker consuming the IP limit cannot affect authenticated organizers.
|
|
- `GET /api/auth/vk/start`: Rate-limited per resolved client IP (`oauth-start:${clientIp}`).
|
|
- `GET /api/giveaways/[id]/verify`: Rate-limited per resolved client IP and giveaway (`verify-get:${clientIp}:${id}`).
|
|
|
|
### Centralized Client IP Resolution (`src/lib/client-ip.ts`)
|
|
- **Untrusted Proxy Mode (Default)**:
|
|
When `TRUST_PROXY !== 'true'`, user-supplied `X-Forwarded-For`, `X-Real-IP`, or `CF-Connecting-IP` headers are **strictly ignored** to prevent IP spoofing attacks. The direct socket connection `req.ip` is used.
|
|
- *Production Behavior*: If `NODE_ENV=production`, `TRUST_PROXY !== 'true'`, and direct `req.ip` is unavailable (e.g. in self-hosted Node.js / `next start` behind a reverse proxy), the server emits a `[SECURITY CONFIGURATION WARNING]` and falls back to `'direct-client'`. For production deployments behind reverse proxies, setting `TRUST_PROXY=true` is required.
|
|
- **Trusted Proxy Mode (`TRUST_PROXY=true`)**:
|
|
When deployed behind a verified reverse proxy (e.g. Nginx, Cloudflare, AWS ALB), `TRUST_PROXY=true` must be set. The resolver:
|
|
- Enforces a maximum header length of 1024 characters (oversized headers are rejected as malformed).
|
|
- Parses multi-value proxy chains (`client, proxy1, proxy2`), extracting and validating the leftmost IP.
|
|
- Normalizes IPv4, IPv6 (including IPv4-mapped IPv6 `::ffff:192.0.2.1` and loopbacks `::1`).
|
|
- Validates IP syntax against IPv4/IPv6 standards.
|
|
|
|
### Memory Limiter vs Multi-Instance Deployments
|
|
- The built-in `SlidingWindowRateLimiter` is optimized for single-instance, serverless dev, and test environments.
|
|
- In multi-instance or horizontal cluster deployments, rate limiting must be offloaded to an edge layer (e.g., Cloudflare Rate Limiting, Nginx limit_req) or a shared distributed cache (Redis/Valkey).
|
|
|
|
---
|
|
|
|
## 3. Winner Count Contract (Zero Under-Delivery)
|
|
|
|
### Contract
|
|
Before conducting any draw, the system enforces:
|
|
$$\text{winnersCount} + \text{reserveWinnersCount} \le \text{eligibleParticipantsCount}$$
|
|
|
|
If the requested winners count plus reserve exceeds the locked snapshot's eligible participants:
|
|
- The system returns HTTP `400 VALIDATION_ERROR` (or `409 CONFLICT`).
|
|
- **The system NEVER silently clamps or reduces the winners count.**
|
|
|
|
---
|
|
|
|
## 4. Draw Concurrency & Terminal Retry Contract
|
|
|
|
### Atomic Conditional Execution
|
|
- Draw execution (`POST /api/giveaways/[id]/draw`) requires the giveaway to be in `SNAPSHOT_LOCKED` status with an existing snapshot.
|
|
- The state transition from `SNAPSHOT_LOCKED` to `DRAWN` occurs atomically inside a database transaction (`updateMany({ where: { id, status: 'SNAPSHOT_LOCKED' } })`).
|
|
- In a concurrent race (e.g. 20 or 100 simultaneous draw requests), exactly **one** request acquires the lock and transitions to `DRAWN`. All competing requests receive `409 Conflict`.
|
|
|
|
### Terminal State Replay (`DRAW_ALREADY_COMPLETED`)
|
|
- Once a giveaway is in `DRAWN` or `PUBLISHED` status, subsequent draw attempts return:
|
|
```json
|
|
{
|
|
"success": false,
|
|
"error": {
|
|
"code": "DRAW_ALREADY_COMPLETED",
|
|
"message": "Giveaway has already been drawn and finalized. Repeat draws are not permitted."
|
|
}
|
|
}
|
|
```
|
|
- Clients and UI treat `DRAW_ALREADY_COMPLETED` as a terminal final status.
|
|
|
|
---
|
|
|
|
## 5. Summary of Environment Variables
|
|
|
|
| Variable | Type | Default | Description |
|
|
|---|---|---|---|
|
|
| `NODE_ENV` | string | `development` | Environment mode (`production`, `test`, `development`) |
|
|
| `TRUST_PROXY` | boolean (`true`/`false`) | `false` | Enable only behind trusted upstream proxies |
|
|
| `ALLOW_MEMORY_IDEMPOTENCY` | boolean | `false` | Silence production warning for memory idempotency in single-instance |
|
|
| `ALLOW_MEMORY_RATE_LIMITER` | boolean | `false` | Silence production warning for memory rate limiter in single-instance |
|
|
| `USE_VK_MOCK` | boolean | `false` | Explicitly enable VkMockProvider (forbidden in production unless true) |
|
|
| `VK_SERVICE_TOKEN` | string | `undefined` | VK Application Service Access Token |
|