229 lines · 6.4 KB · TypeScript
RawDownload
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<T = Record<string, unknown>>(	request: Request,	fallback?: T,): Effect.Effect<T, unknown> {	return tryPromise(() => request.json() as Promise<T>).pipe(		Effect.catchAll((error) =>			fallback === undefined ? Effect.fail(error) : Effect.succeed(fallback),		),	);} export function runRouteEffect<A, E>(effect: Effect.Effect<A, E>): Promise<A> {	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));}