Spaces:
Sleeping
Sleeping
File size: 8,186 Bytes
1b11b46 b6ecafa bbbc03f 805a069 c9c4e2a bbbc03f 1b11b46 1dc7696 1b11b46 1dc7696 1b11b46 1dc7696 bbbc03f b6ecafa bbbc03f b6ecafa bbbc03f a6b2e2f 805a069 a6b2e2f d1ade07 a6b2e2f 805a069 b6ecafa bfccd36 250a974 1b11b46 bbbc03f b6ecafa bbbc03f b6ecafa bbbc03f 250a974 b6ecafa bbbc03f b6ecafa bbbc03f 391842a d1ade07 391842a 447138e d1ade07 b6ecafa 391842a 943fe08 d94b281 943fe08 a6b2e2f bbbc03f c9c4e2a bbbc03f 250a974 b6ecafa a6b2e2f bbbc03f b6ecafa bbbc03f a6b2e2f bbbc03f b6ecafa bbbc03f b6ecafa bbbc03f | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 | 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/).*)']
}
|