UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

252 lines (228 loc) 6.63 kB
import type { HttpRequestLike } from "./http.js"; import { parseHttpRequestUrl, requireTrustedProxyHeaderName, } from "./trusted-proxy-internal.js"; /** * Header source used to resolve a client IP after an app has explicitly opted * into trusting its deployment proxy or edge. */ export type TrustedProxyClientIpSource = | "x-forwarded-for-last" | "x-forwarded-for-first" | "x-real-ip" | "cf-connecting-ip" | { header: string } | ((req: HttpRequestLike) => string | undefined); /** * Trusted proxy configuration for request metadata. * * Beignet trusts no forwarding headers by default. Configure this only when * the app is always behind a platform or reverse proxy that strips or * normalizes these headers before they reach application code. */ export interface TrustedProxyOptions { /** * Header source for the end-user client IP. Omit or set to `false` when no * client-IP header should be trusted. */ clientIp?: TrustedProxyClientIpSource | false; /** * Header that carries the external request protocol. * * Defaults to `x-forwarded-proto` when trusted proxy handling is enabled. */ protocolHeader?: string | false; /** * Header that carries the external request host. * * Defaults to `x-forwarded-host` when trusted proxy handling is enabled. */ hostHeader?: string | false; } /** * Set to `false` or omit the config to trust no forwarding headers. */ export type TrustedProxyConfig = false | TrustedProxyOptions; /** * Request metadata after applying an explicit trusted-proxy policy. */ export interface TrustedRequestInfo { /** * URL as seen by the app or reconstructed from trusted proxy headers. */ readonly url: Readonly<URL>; /** * External request origin. */ readonly origin: string; /** * External request protocol without a trailing colon. */ readonly protocol: "http" | "https"; /** * External request host, including port when present. */ readonly host: string; /** * Resolved client IP when a trusted client-IP source is configured. */ readonly clientIp?: string; /** * Whether the server policy configured a trusted client-IP source. This can * be true while `clientIp` is absent when the expected header is missing. */ readonly clientIpTrusted: boolean; /** * Whether forwarding headers were eligible to affect this result. */ readonly trustedProxy: boolean; } const DEFAULT_PROTOCOL_HEADER = "x-forwarded-proto"; const DEFAULT_HOST_HEADER = "x-forwarded-host"; function splitForwardedHeader(value: string | null): string[] { if (!value) return []; return value .split(",") .map((entry) => entry.trim()) .filter(Boolean); } function firstHeaderValue( req: HttpRequestLike, header: string, ): string | undefined { return splitForwardedHeader(req.headers.get(header))[0]; } function normalizeProtocol( value: string | undefined, ): "http" | "https" | undefined { if (!value) return undefined; const protocol = value.toLowerCase().replace(/:$/, ""); if (protocol === "http" || protocol === "https") return protocol; return undefined; } function hasInvalidHostCharacter(value: string): boolean { for (const char of value) { const code = char.charCodeAt(0); if ( code <= 32 || code === 127 || char === "/" || char === "\\" || char === "@" || char === "?" || char === "#" ) { return true; } } return false; } function normalizeHost( value: string | undefined, protocol: "http" | "https", ): string | undefined { if (!value || hasInvalidHostCharacter(value)) { return undefined; } try { return new URL(`${protocol}://${value}`).host; } catch { return undefined; } } function baseRequestUrl(req: HttpRequestLike): URL { return parseHttpRequestUrl(req.url); } function normalizedBaseProtocol(url: URL): "http" | "https" { const protocol = normalizeProtocol(url.protocol); if (protocol) return protocol; throw new Error("Resolved request URL must use HTTP or HTTPS."); } /** * Resolve a client IP from a configured trusted proxy source. */ export function resolveTrustedClientIp( req: HttpRequestLike, source: TrustedProxyClientIpSource | false | undefined, ): string | undefined { if (!source) return undefined; if (typeof source === "function") { return source(req)?.trim() || undefined; } if (typeof source === "object") { return firstHeaderValue( req, requireTrustedProxyHeaderName( source.header, "trustedProxy.clientIp.header", ), ); } if (source === "x-forwarded-for-first" || source === "x-forwarded-for-last") { const entries = splitForwardedHeader(req.headers.get("x-forwarded-for")); if (entries.length === 0) return undefined; return source === "x-forwarded-for-first" ? entries[0] : entries[entries.length - 1]; } return firstHeaderValue(req, source); } /** * Resolve request metadata using only app-visible URL data unless a trusted * proxy policy is explicitly configured. */ export function resolveTrustedRequest( req: HttpRequestLike, config: TrustedProxyConfig | undefined = false, ): TrustedRequestInfo { const baseUrl = baseRequestUrl(req); const baseProtocol = normalizedBaseProtocol(baseUrl); const baseHost = baseUrl.host; if (!config) { return { url: baseUrl, origin: baseUrl.origin, protocol: baseProtocol, host: baseHost, clientIpTrusted: false, trustedProxy: false, }; } const protocolHeader = config.protocolHeader === false ? undefined : requireTrustedProxyHeaderName( config.protocolHeader ?? DEFAULT_PROTOCOL_HEADER, "trustedProxy.protocolHeader", ); const hostHeader = config.hostHeader === false ? undefined : requireTrustedProxyHeaderName( config.hostHeader ?? DEFAULT_HOST_HEADER, "trustedProxy.hostHeader", ); const protocol = normalizeProtocol( protocolHeader ? firstHeaderValue(req, protocolHeader) : undefined, ) ?? baseProtocol; const host = normalizeHost( hostHeader ? firstHeaderValue(req, hostHeader) : undefined, protocol, ) ?? baseHost; const url = new URL(baseUrl); url.protocol = `${protocol}:`; url.host = host; const clientIp = resolveTrustedClientIp(req, config.clientIp); return { url, origin: url.origin, protocol, host, ...(clientIp !== undefined ? { clientIp } : {}), clientIpTrusted: Boolean(config.clientIp), trustedProxy: true, }; }