import { Effect } from "effect"; import { runEffectPromise, tryPromise } from "./effect-runtime"; import { LOCAL_WEB_PEER_HEADER } from "./local-peer"; export { LOCAL_WEB_PEER_HEADER } from "./local-peer"; const LOCAL_HOSTS = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); export function jsonResponse(data: unknown, init?: ResponseInit) { const headers = new Headers(init?.headers); headers.set("content-type", "application/json"); return new Response(JSON.stringify(data), { ...init, headers, }); } export function requestJsonEffect>( request: Request, fallback?: T, ): Effect.Effect { return tryPromise(() => request.json() as Promise).pipe( Effect.catchAll((error) => fallback === undefined ? Effect.fail(error) : Effect.succeed(fallback), ), ); } export function runRouteEffect(effect: Effect.Effect): Promise { return runEffectPromise(effect); } function normalizedHost(value: string) { return value.toLowerCase().replace(/^\[|\]$/g, ""); } function isTestEnvironment() { return process.env.NODE_ENV === "test" || process.env.VITEST === "true"; } function requestCookie(request: Request, name: string) { const cookie = request.headers.get("cookie"); if (!cookie) return { found: false, value: null }; for (const part of cookie.split(";")) { const [key, ...rest] = part.trim().split("="); if (key === name) { try { return { found: true, value: decodeURIComponent(rest.join("=")) }; } catch { return { found: true, value: null }; } } } return { found: false, value: null }; } function configuredWebToken() { const token = process.env.BIRDCLAW_WEB_TOKEN?.trim(); return token || null; } function requestWebTokenStatus(request: Request) { const token = configuredWebToken(); if (!token) return { configured: false, valid: false, fromCookie: false, provided: false, }; const headerToken = request.headers.get("x-birdclaw-token"); const cookieToken = requestCookie(request, "birdclaw_token"); const provided = headerToken !== null || cookieToken.found; const cookieValue = cookieToken.value; const valid = headerToken === token || cookieValue === token; return { configured: true, valid, fromCookie: cookieValue === token && headerToken !== token, provided, }; } function isLocalWebHost(value: string) { const host = normalizedHost(value); return LOCAL_HOSTS.has(host) || host.endsWith(".localhost"); } function allowsUnauthenticatedLocalWeb(request: Request) { const mode = process.env.BIRDCLAW_LOCAL_WEB; if (mode === "socket") { return request.headers.get(LOCAL_WEB_PEER_HEADER) === "1"; } return mode === "1"; } function hasForwardedRequestHeaders(request: Request) { return ( request.headers.has("forwarded") || request.headers.has("x-forwarded-for") || request.headers.has("x-forwarded-proto") || request.headers.has("x-forwarded-host") || request.headers.has("x-real-ip") ); } function firstForwardedValue(value: string | null) { return value?.split(",")[0]?.trim() || null; } function forwardedHeaderPair(request: Request, key: "host" | "proto") { const forwarded = firstForwardedValue(request.headers.get("forwarded")); if (!forwarded) return null; for (const part of forwarded.split(";")) { const [name, ...rest] = part.trim().split("="); if (name?.toLowerCase() !== key) continue; const value = rest.join("=").trim().replace(/^"|"$/g, ""); return value || null; } return null; } function forwardedOrigin(request: Request) { const proto = firstForwardedValue(request.headers.get("x-forwarded-proto")) ?? forwardedHeaderPair(request, "proto"); const host = firstForwardedValue(request.headers.get("x-forwarded-host")) ?? forwardedHeaderPair(request, "host"); if (!proto || !host) return null; const normalizedProto = proto.toLowerCase(); if (normalizedProto !== "http" && normalizedProto !== "https") return null; return `${normalizedProto}://${host}`; } function sameRequestOrigin(request: Request, origin: string, url: URL) { return origin === url.origin || origin === forwardedOrigin(request); } export function sensitiveRequestErrorResponse(request: Request) { const url = new URL(request.url); const token = requestWebTokenStatus(request); const isLocalRequest = allowsUnauthenticatedLocalWeb(request) && isLocalWebHost(url.hostname) && !hasForwardedRequestHeaders(request); const fetchSite = request.headers.get("sec-fetch-site"); const origin = request.headers.get("origin"); const allowRemoteEnv = process.env.BIRDCLAW_ALLOW_REMOTE_WEB === "1"; const allowTrustedRemote = allowRemoteEnv && !token.configured; if (isTestEnvironment() && !token.valid) return null; if (origin && !sameRequestOrigin(request, origin, url)) { return jsonResponse( { ok: false, message: "Cross-origin web API access is disabled" }, { status: 403 }, ); } if (fetchSite === "cross-site") { return jsonResponse( { ok: false, message: "Cross-site web API access is disabled" }, { status: 403 }, ); } if ( !isTestEnvironment() && !token.configured && !isLocalRequest && !allowTrustedRemote ) { return jsonResponse( { ok: false, message: "Remote API access requires BIRDCLAW_ALLOW_REMOTE_WEB=1 for a trusted private proxy, or BIRDCLAW_WEB_TOKEN for tokened access", }, { status: 403 }, ); } if (token.configured && !token.valid && (token.provided || !isLocalRequest)) { return jsonResponse( { ok: false, message: "Invalid web token" }, { status: 403 }, ); } const allowRemote = allowRemoteEnv && (token.valid || allowTrustedRemote); if (!allowRemote && !isLocalRequest) { return jsonResponse( { ok: false, message: "Remote web API access is disabled" }, { status: 403 }, ); } if ( token.fromCookie && fetchSite !== "same-origin" && fetchSite !== "same-site" ) { return jsonResponse( { ok: false, message: "Same-origin browser request required" }, { status: 403 }, ); } return null; } export function parseBoundedInteger( value: number | string | null | undefined, { defaultValue, min = 1, max, }: { defaultValue?: number; min?: number; max: number }, ) { if (value === null || value === undefined || value === "") return defaultValue; if (typeof value === "string" && !/^\d+$/.test(value.trim())) { return defaultValue; } const parsed = typeof value === "number" ? value : Number(value); if (!Number.isSafeInteger(parsed)) return defaultValue; return Math.min(max, Math.max(min, parsed)); }