@beignet/core
Version:
Core framework primitives for Beignet
252 lines (228 loc) • 6.63 kB
text/typescript
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,
};
}