import { NextResponse, type NextRequest } from "next/server"; import { STUDIO_TOKEN_COOKIE, STUDIO_TOKEN_HEADER, presentedToken, resolveAccessPosture, timingSafeStringEqual, } from "@/lib/auth/access"; import { CSRF_COOKIE, CSRF_BOOTSTRAP_HEADER, CSRF_HEADER, evaluateRequestBoundary, splitAllowedValues, } from "@/lib/security/request-boundary"; const PROCESS_CSRF_TOKEN = crypto.randomUUID(); function denyResponse(isApi: boolean, status: number, message: string): NextResponse { if (isApi) { return new NextResponse(JSON.stringify({ error: message }), { status, headers: { "content-type": "application/json" }, }); } return new NextResponse(message, { status }); } function isAccessExchange(request: NextRequest): boolean { return request.nextUrl.pathname === "/api/auth/session" && request.method === "POST"; } function permitsAccessEntry(request: NextRequest): boolean { const method = request.method.toUpperCase(); return ( (request.nextUrl.pathname === "/access" && (method === "GET" || method === "HEAD")) || isAccessExchange(request) ); } function queryTokenResponse(request: NextRequest): NextResponse | null { const clean = request.nextUrl.clone(); const tokenKeys = [...clean.searchParams.keys()].filter((key) => key.toLowerCase() === "token"); if (tokenKeys.length === 0) return null; for (const key of tokenKeys) clean.searchParams.delete(key); const isApi = request.nextUrl.pathname.startsWith("/api/"); if (!isApi && (request.method === "GET" || request.method === "HEAD")) { return NextResponse.redirect(clean, 303); } return denyResponse(isApi, 400, "Query-string access tokens are not accepted."); } function enforceAccess(request: NextRequest): NextResponse | null { const posture = resolveAccessPosture(); const isApi = request.nextUrl.pathname.startsWith("/api/"); if (posture.kind === "configuration-error") { return denyResponse(isApi, 503, posture.message); } if (posture.kind === "allow" || permitsAccessEntry(request)) return null; const presented = presentedToken( request.headers.get(STUDIO_TOKEN_HEADER), request.cookies.get(STUDIO_TOKEN_COOKIE)?.value, ); if (presented && timingSafeStringEqual(presented, posture.token)) return null; if (!isApi) return NextResponse.redirect(new URL("/access", request.url), 303); return denyResponse(true, 401, "Unauthorized"); } export function proxy(request: NextRequest) { const csrfExempt = isAccessExchange(request); const boundary = evaluateRequestBoundary({ method: request.method, host: request.headers.get("host"), forwardedHost: request.headers.get("x-forwarded-host"), forwardedProto: request.headers.get("x-forwarded-proto"), origin: request.headers.get("origin"), fetchSite: request.headers.get("sec-fetch-site"), csrfCookie: csrfExempt ? PROCESS_CSRF_TOKEN : (request.cookies.get(CSRF_COOKIE)?.value ?? null), csrfHeader: csrfExempt ? PROCESS_CSRF_TOKEN : request.headers.get(CSRF_HEADER), tailscaleUser: request.headers.get("tailscale-user-login"), requestProtocol: request.nextUrl.protocol, allowedTailscaleHosts: splitAllowedValues(process.env.ALLOWED_TAILSCALE_HOSTS), allowedTailscaleUsers: splitAllowedValues(process.env.ALLOWED_TAILSCALE_USERS), csrfToken: PROCESS_CSRF_TOKEN, }); if (!boundary.ok) { return denyResponse( request.nextUrl.pathname.startsWith("/api/"), boundary.status, boundary.error, ); } const queryResponse = queryTokenResponse(request); if (queryResponse) return queryResponse; const denied = enforceAccess(request); if (denied) return denied; const startedAt = Date.now(); const forwardedHeaders = new Headers(request.headers); forwardedHeaders.set(CSRF_BOOTSTRAP_HEADER, PROCESS_CSRF_TOKEN); const response = NextResponse.next({ request: { headers: forwardedHeaders } }); writeAccessLog(request, Date.now() - startedAt); applySecurityHeaders(request, response); return response; } function clientIpOf(request: NextRequest): string { return ( request.headers.get("CF-Connecting-IP") || request.headers.get("X-Forwarded-For")?.split(",")[0]?.trim() || request.headers.get("X-Real-IP") || "unknown" ); } function redactedQuery(request: NextRequest): string { const sanitizedUrl = request.nextUrl.clone(); for (const sensitiveKey of ["api_key", "key", "token", "access_token"]) { if (sanitizedUrl.searchParams.has(sensitiveKey)) { sanitizedUrl.searchParams.set(sensitiveKey, "[redacted]"); } } return sanitizedUrl.search || ""; } function safeReferer(request: NextRequest): string { const raw = request.headers.get("Referer") || "-"; if (raw === "-") return "-"; try { const parsed = new URL(raw); return `${parsed.origin}${parsed.pathname}`.slice(0, 200); } catch { return "[invalid]"; } } function writeAccessLog(request: NextRequest, duration: number): void { if (process.env.LOCAL_STUDIO_ACCESS_LOGS !== "true") return; const referer = safeReferer(request); const logParts = [ `ip=${clientIpOf(request)}`, `country=${request.headers.get("CF-IPCountry") || "-"}`, `method=${request.method}`, `path=${request.nextUrl.pathname}${redactedQuery(request)}`, `duration=${duration}ms`, `auth=${request.headers.get("Authorization") ? "present" : "none"}`, `ua=${request.headers.get("User-Agent")?.slice(0, 100) || "unknown"}`, ]; if (referer !== "-") logParts.push(`referer=${referer}`); console.log(`${new Date().toISOString()} ACCESS ${logParts.join(" | ")}`); } function applySecurityHeaders(request: NextRequest, response: NextResponse): void { response.headers.set("X-Content-Type-Options", "nosniff"); response.headers.set("X-Frame-Options", "DENY"); response.headers.set("X-XSS-Protection", "1; mode=block"); response.headers.set("Referrer-Policy", "no-referrer"); const effectiveProto = request.headers.get("x-forwarded-proto")?.split(",")[0]?.trim().toLowerCase() || request.nextUrl.protocol.replace(/:$/, ""); response.cookies.set(CSRF_COOKIE, PROCESS_CSRF_TOKEN, { httpOnly: false, sameSite: "strict", secure: effectiveProto === "https", path: "/", }); } export default proxy; export const config = { matcher: [ "/api/:path*", "/((?!api/|_next/static|_next/image|favicon.ico|icons/|.*\\.(?:svg|png|jpg|jpeg|gif|webp)$).*)", ], };