import crypto from 'node:crypto' import os from 'node:os' import { NextResponse } from 'next/server' import type { NextRequest } from 'next/server' import { buildMissionControlCsp, buildNonceRequestHeaders } from '@/lib/csp' import { MC_SESSION_COOKIE_NAME, LEGACY_MC_SESSION_COOKIE_NAME } from '@/lib/session-cookie' /** Constant-time string comparison using Node.js crypto. */ function safeCompare(a: string, b: string): boolean { if (typeof a !== 'string' || typeof b !== 'string') return false const bufA = Buffer.from(a) const bufB = Buffer.from(b) if (bufA.length !== bufB.length) return false return crypto.timingSafeEqual(bufA, bufB) } function envFlag(name: string): boolean { const raw = process.env[name] if (raw === undefined) return false const v = String(raw).trim().toLowerCase() return v === '1' || v === 'true' || v === 'yes' || v === 'on' } function normalizeHostname(raw: string): string { return raw.trim().replace(/^\[|\]$/g, '').split(':')[0].replace(/\.$/, '').toLowerCase() } function parseForwardedHost(forwarded: string | null): string[] { if (!forwarded) return [] const hosts: string[] = [] for (const part of forwarded.split(',')) { const match = /(?:^|;)\s*host="?([^";]+)"?/i.exec(part) if (match?.[1]) hosts.push(match[1]) } return hosts } function getRequestHostCandidates(request: NextRequest): string[] { const rawCandidates = [ ...(request.headers.get('x-forwarded-host') || '').split(','), ...(request.headers.get('x-original-host') || '').split(','), ...(request.headers.get('x-forwarded-server') || '').split(','), ...parseForwardedHost(request.headers.get('forwarded')), request.headers.get('host') || '', request.nextUrl.host || '', request.nextUrl.hostname || '', ] const candidates = rawCandidates .map(normalizeHostname) .filter(Boolean) return [...new Set(candidates)] } function getImplicitAllowedHosts(): string[] { const candidates = [ 'localhost', '127.0.0.1', '::1', normalizeHostname(os.hostname()), ].filter(Boolean) return [...new Set(candidates)] } function hostMatches(pattern: string, hostname: string): boolean { const p = normalizeHostname(pattern) const h = normalizeHostname(hostname) if (!p || !h) return false // "*.example.com" matches "a.example.com" (but not bare "example.com") if (p.startsWith('*.')) { const suffix = p.slice(2) return h.endsWith(`.${suffix}`) } // "100.*" matches "100.64.0.1" if (p.endsWith('.*')) { const prefix = p.slice(0, -1) return h.startsWith(prefix) } return h === p } function nextResponseWithNonce(request: NextRequest): { response: NextResponse; nonce: string } { const nonce = crypto.randomBytes(16).toString('base64') const googleEnabled = !!(process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || process.env.GOOGLE_CLIENT_ID) const requestHeaders = buildNonceRequestHeaders({ headers: request.headers, nonce, googleEnabled, }) const response = NextResponse.next({ request: { headers: requestHeaders, }, }) return { response, nonce } } function addSecurityHeaders(response: NextResponse, _request: NextRequest, nonce?: string): NextResponse { const requestId = crypto.randomUUID() response.headers.set('X-Request-Id', requestId) response.headers.set('X-Content-Type-Options', 'nosniff') response.headers.set('X-Frame-Options', 'ALLOWALL') response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin') const googleEnabled = !!(process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID || process.env.GOOGLE_CLIENT_ID) const effectiveNonce = nonce || crypto.randomBytes(16).toString('base64') response.headers.set('Content-Security-Policy', buildMissionControlCsp({ nonce: effectiveNonce, googleEnabled })) return response } function extractApiKeyFromRequest(request: NextRequest): string { const direct = (request.headers.get('x-api-key') || '').trim() if (direct) return direct const authorization = (request.headers.get('authorization') || '').trim() if (!authorization) return '' const [scheme, ...rest] = authorization.split(/\s+/) if (!scheme || rest.length === 0) return '' const normalized = scheme.toLowerCase() if (normalized === 'bearer' || normalized === 'apikey' || normalized === 'token') { return rest.join(' ').trim() } return '' } export function proxy(request: NextRequest) { // Network access control. // In production: default-deny unless explicitly allowed. // In dev/test: allow all hosts unless overridden. const requestHosts = getRequestHostCandidates(request) const allowAnyHost = envFlag('MC_ALLOW_ANY_HOST') || process.env.NODE_ENV !== 'production' const allowedPatterns = String(process.env.MC_ALLOWED_HOSTS || '') .split(',') .map((s) => s.trim()) .filter(Boolean) const implicitAllowedHosts = getImplicitAllowedHosts() const enforceAllowlist = !allowAnyHost && allowedPatterns.length > 0 const isAllowedHost = !enforceAllowlist || requestHosts.some((hostName) => implicitAllowedHosts.some((candidate) => hostMatches(candidate, hostName)) || allowedPatterns.some((pattern) => hostMatches(pattern, hostName)) ) if (!isAllowedHost) { return addSecurityHeaders(new NextResponse('Forbidden', { status: 403 }), request) } const { pathname } = request.nextUrl // CSRF Origin validation for mutating requests // Allow HuggingFace Spaces iframe embedding (cross-origin POST from huggingface.co) const method = request.method.toUpperCase() if (['POST', 'PUT', 'DELETE', 'PATCH'].includes(method)) { const origin = request.headers.get('origin') if (origin) { let originHost: string try { originHost = new URL(origin).host } catch { originHost = '' } const requestHost = request.headers.get('host')?.split(',')[0]?.trim() || request.nextUrl.host || '' const isTrustedOrigin = originHost.endsWith('.hf.space') || originHost === 'huggingface.co' if (originHost && requestHost && originHost !== requestHost && !isTrustedOrigin) { return addSecurityHeaders(NextResponse.json({ error: 'CSRF origin mismatch' }, { status: 403 }), request) } } } // Allow login, setup, auth API, docs, and container health probe without session const isPublicHealthProbe = pathname === '/api/status' && request.nextUrl.searchParams.get('action') === 'health' if (pathname === '/login' || pathname === '/setup' || pathname.startsWith('/api/auth/') || pathname === '/api/setup' || pathname === '/api/docs' || pathname === '/docs' || isPublicHealthProbe) { const { response, nonce } = nextResponseWithNonce(request) return addSecurityHeaders(response, request, nonce) } // Check for session cookie const sessionToken = request.cookies.get(MC_SESSION_COOKIE_NAME)?.value || request.cookies.get(LEGACY_MC_SESSION_COOKIE_NAME)?.value // API routes: accept session cookie OR API key if (pathname.startsWith('/api/')) { const configuredApiKey = (process.env.API_KEY || '').trim() const apiKey = extractApiKeyFromRequest(request) const hasValidApiKey = Boolean(configuredApiKey && apiKey && safeCompare(apiKey, configuredApiKey)) // Agent-scoped keys are validated in route auth (DB-backed) and should be // allowed to pass through proxy auth gate. const looksLikeAgentApiKey = /^mca_[a-f0-9]{48}$/i.test(apiKey) if (sessionToken || hasValidApiKey || looksLikeAgentApiKey) { const { response, nonce } = nextResponseWithNonce(request) return addSecurityHeaders(response, request, nonce) } return addSecurityHeaders(NextResponse.json({ error: 'Unauthorized' }, { status: 401 }), request) } // Page routes: redirect to login if no session if (sessionToken) { const { response, nonce } = nextResponseWithNonce(request) return addSecurityHeaders(response, request, nonce) } // Redirect to login const loginUrl = request.nextUrl.clone() loginUrl.pathname = '/login' return addSecurityHeaders(NextResponse.redirect(loginUrl), request) } export const config = { matcher: ['/((?!_next/static|_next/image|favicon.ico|brand/).*)'] }