tao-shen's picture
fix: allow HuggingFace Spaces iframe embedding (cookies, CSP, CSRF)
d1ade07
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/).*)']
}