import { cookies } from 'next/headers'; import { timingSafeEqual } from 'crypto'; import { apiError, apiSuccess } from '@/lib/server/api-response'; import { createAccessToken } from '@/lib/server/access-token'; import { accessCodeRateLimiter, resolveAccessCodeRateLimitConfig, resolveAccessCodeRateLimitKey, } from '@/lib/server/access-code-rate-limit'; import { ACCESS_CODE_COOKIE_NAME, resolveAccessCodeSessionTtlSeconds, } from '@/lib/server/access-token-policy'; import { capBodyStream } from '@/lib/server/capped-stream'; import { hasValidOpsMutationOrigin } from '@/lib/server/ops-access'; const ACCESS_CODE_REQUEST_MAX_BYTES = 8 * 1024; function exceedsDeclaredRequestLimit(request: Request): boolean { const declaredLength = request.headers.get('content-length')?.trim(); if (!declaredLength || !/^\d+$/.test(declaredLength)) { return false; } return Number(declaredLength) > ACCESS_CODE_REQUEST_MAX_BYTES; } async function readAccessCodeBody( request: Request, ): Promise<{ body: { code?: string } } | { response: Response }> { if (exceedsDeclaredRequestLimit(request)) { return { response: apiError('INVALID_REQUEST', 413, 'Access code request is too large'), }; } if (!request.body) { return { response: apiError('INVALID_REQUEST', 400, 'Invalid JSON body') }; } const cappedBody = capBodyStream(request.body, ACCESS_CODE_REQUEST_MAX_BYTES); let rawBody: string; try { rawBody = await new Response(cappedBody.stream).text(); } catch { if (cappedBody.exceeded()) { return { response: apiError('INVALID_REQUEST', 413, 'Access code request is too large'), }; } return { response: apiError('INVALID_REQUEST', 400, 'Invalid JSON body') }; } try { const parsed: unknown = JSON.parse(rawBody); return { body: parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? (parsed as { code?: string }) : {}, }; } catch { return { response: apiError('INVALID_REQUEST', 400, 'Invalid JSON body') }; } } export async function POST(request: Request) { const accessCode = process.env.ACCESS_CODE; if (!accessCode) { return apiSuccess({ valid: true }); } // Reject browser cross-site/simple requests before touching the conservative // shared limiter bucket. Otherwise any external page could lock every // operator out with a handful of no-CORS text/plain requests. if (!hasValidOpsMutationOrigin(request)) { return apiError('FORBIDDEN', 403, 'Cross-origin access code request denied'); } if (!request.headers.get('content-type')?.toLowerCase().startsWith('application/json')) { return apiError('INVALID_REQUEST', 415, 'Access code request requires application/json'); } const bodyResult = await readAccessCodeBody(request); if ('response' in bodyResult) { return bodyResult.response; } const { body } = bodyResult; const clientKey = resolveAccessCodeRateLimitKey(request); const rateLimit = accessCodeRateLimiter.consume(clientKey, resolveAccessCodeRateLimitConfig()); if (!rateLimit.allowed) { const response = apiError('RATE_LIMITED', 429, 'Too many access code attempts'); response.headers.set('Retry-After', String(rateLimit.retryAfterSeconds)); return response; } // Constant-time comparison if (!body.code) { return apiError('INVALID_REQUEST', 401, 'Invalid access code'); } const encoder = new TextEncoder(); const a = encoder.encode(body.code); const b = encoder.encode(accessCode); if (a.byteLength !== b.byteLength || !timingSafeEqual(a, b)) { return apiError('INVALID_REQUEST', 401, 'Invalid access code'); } accessCodeRateLimiter.reset(clientKey); const ttlSeconds = resolveAccessCodeSessionTtlSeconds( process.env.ACCESS_CODE_SESSION_TTL_SECONDS, ); const token = createAccessToken(accessCode); const cookieStore = await cookies(); cookieStore.set(ACCESS_CODE_COOKIE_NAME, token, { httpOnly: true, sameSite: 'lax', path: '/', maxAge: ttlSeconds, secure: process.env.NODE_ENV === 'production', }); return apiSuccess({ valid: true }); }