123 lines
4.0 KiB
TypeScript
123 lines
4.0 KiB
TypeScript
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 });
|
|
}
|