@beignet/core
Version:
Core framework primitives for Beignet
508 lines (466 loc) • 14.1 kB
text/typescript
/**
* Security hooks for @beignet/core/server
*/
import type { HttpContractConfig } from "../../contracts/index.js";
import { AppError, httpErrors } from "../../errors/index.js";
import {
resolveTrustedRequest,
type TrustedProxyConfig,
type TrustedProxyOptions,
} from "../trusted-proxy.js";
import type { HttpRequestLike, ServerHook } from "../types.js";
/**
* Strict-Transport-Security configuration.
*
* HSTS is disabled by default because it should only be sent by HTTPS
* deployments that intentionally commit browsers to the configured host policy.
*/
export interface StrictTransportSecurityOptions {
/**
* HSTS max-age value in seconds.
*
* Defaults to one year when `strictTransportSecurity` is configured as an
* object.
*/
maxAgeSec?: number;
/**
* Include subdomains in the HSTS policy.
*/
includeSubDomains?: boolean;
/**
* Mark the policy as eligible for browser preload lists.
*/
preload?: boolean;
}
/**
* Response security headers applied by `createSecurityHeadersHooks(...)`.
*
* Headers are only added when the response does not already define the same
* header name. Route handlers can therefore opt into route-specific CSP or
* download headers without fighting the global hook.
*/
export interface SecurityHeadersOptions {
/**
* Content Security Policy value. Disabled by default because browser apps need
* an app-owned asset, image, frame, and script policy.
*/
contentSecurityPolicy?: string | false;
/**
* Cross-Origin-Opener-Policy value.
*
* Defaults to `"same-origin"`.
*/
crossOriginOpenerPolicy?: string | false;
/**
* Cross-Origin-Resource-Policy value.
*
* Defaults to `"same-origin"`.
*/
crossOriginResourcePolicy?: string | false;
/**
* Permissions-Policy value.
*
* Defaults to disabling camera, microphone, and geolocation.
*/
permissionsPolicy?: string | false;
/**
* Referrer-Policy value.
*
* Defaults to `"strict-origin-when-cross-origin"`.
*/
referrerPolicy?: string | false;
/**
* Strict-Transport-Security value. Pass a string for full control or an object
* for Beignet to format the header. Disabled by default.
*/
strictTransportSecurity?: StrictTransportSecurityOptions | string | false;
/**
* X-Content-Type-Options value.
*
* Defaults to `"nosniff"`.
*/
xContentTypeOptions?: "nosniff" | false;
/**
* X-Frame-Options value.
*
* Defaults to `"DENY"`. Use `contentSecurityPolicy` with `frame-ancestors`
* for more precise frame control.
*/
xFrameOptions?: "DENY" | "SAMEORIGIN" | false;
}
export type CsrfFailureReason =
| "missing_origin"
| "untrusted_origin"
| "missing_token"
| "invalid_token";
/**
* Double-submit cookie token configuration for `createCsrfHooks(...)`.
*/
export interface CsrfTokenOptions {
/**
* Header that must carry the CSRF token.
*
* Defaults to `"x-csrf-token"`.
*/
headerName?: string;
/**
* Cookie that stores the expected CSRF token.
*
* Defaults to `"beignet.csrf"`.
*/
cookieName?: string;
}
/**
* Options for `createCsrfHooks(...)`.
*/
export interface CsrfHooksOptions {
/**
* Unsafe HTTP methods protected by the hook.
*
* Defaults to `POST`, `PUT`, `PATCH`, and `DELETE`.
*/
protectedMethods?: readonly string[];
/**
* Additional trusted origins allowed to send protected requests.
*
* The request URL's own origin is always trusted. Use this for sibling
* frontends such as `https://app.example.com` calling `https://api.example.com`.
*/
trustedOrigins?:
| readonly string[]
| ((args: {
origin: string;
req: HttpRequestLike;
contract: HttpContractConfig;
}) => boolean);
/**
* Whether unsafe requests without `Origin` or `Referer` are allowed.
*
* Defaults to `true` so server-to-server calls, tests, and older same-origin
* clients keep working. Set to `false` for cookie-backed browser-only APIs.
*/
allowMissingOrigin?: boolean;
/**
* Optional double-submit cookie token check.
*
* When configured, protected requests must send the same token in the
* configured header and cookie.
*/
token?: false | CsrfTokenOptions;
/**
* Hook-local trusted-proxy policy used when comparing the request's external
* origin against `Origin` or `Referer`. This overrides the server-level
* policy.
*
* Configure this only when the app is always behind a platform or reverse
* proxy that strips or normalizes forwarding headers. Without this option,
* CSRF uses the `requestInfo` resolved by `createServer(...)`.
*/
trustedProxy?: TrustedProxyConfig;
/**
* App-owned escape hatch for routes that have another verifier, such as
* provider webhooks or auth callbacks.
*/
skip?: (args: {
req: HttpRequestLike;
contract: HttpContractConfig;
params: Record<string, string>;
}) => boolean | Promise<boolean>;
}
const DEFAULT_PERMISSIONS_POLICY = "camera=(), microphone=(), geolocation=()";
const DEFAULT_PROTECTED_METHODS = ["POST", "PUT", "PATCH", "DELETE"] as const;
const DEFAULT_CSRF_HEADER = "x-csrf-token";
const DEFAULT_CSRF_COOKIE = "beignet.csrf";
function headerKey(
headers: Record<string, string>,
name: string,
): string | undefined {
const lowerName = name.toLowerCase();
return Object.keys(headers).find((key) => key.toLowerCase() === lowerName);
}
function setHeaderIfMissing(
headers: Record<string, string>,
name: string,
value: string | false | undefined,
): void {
if (value === false || value === undefined) return;
if (headerKey(headers, name)) return;
headers[name] = value;
}
function formatStrictTransportSecurity(
config: SecurityHeadersOptions["strictTransportSecurity"],
): string | false | undefined {
if (typeof config === "string" || config === false || config === undefined) {
return config;
}
const directives = [`max-age=${config.maxAgeSec ?? 31_536_000}`];
if (config.includeSubDomains) directives.push("includeSubDomains");
if (config.preload) directives.push("preload");
return directives.join("; ");
}
/**
* Apply Beignet's default security response headers to a mutable header record.
*
* Existing headers are preserved case-insensitively so route-owned responses can
* provide more specific policies.
*/
export function applySecurityHeaders(
headers: Record<string, string>,
options: SecurityHeadersOptions = {},
): void {
setHeaderIfMissing(
headers,
"Content-Security-Policy",
options.contentSecurityPolicy,
);
setHeaderIfMissing(
headers,
"Cross-Origin-Opener-Policy",
options.crossOriginOpenerPolicy ?? "same-origin",
);
setHeaderIfMissing(
headers,
"Cross-Origin-Resource-Policy",
options.crossOriginResourcePolicy ?? "same-origin",
);
setHeaderIfMissing(
headers,
"Permissions-Policy",
options.permissionsPolicy ?? DEFAULT_PERMISSIONS_POLICY,
);
setHeaderIfMissing(
headers,
"Referrer-Policy",
options.referrerPolicy ?? "strict-origin-when-cross-origin",
);
setHeaderIfMissing(
headers,
"Strict-Transport-Security",
formatStrictTransportSecurity(options.strictTransportSecurity),
);
setHeaderIfMissing(
headers,
"X-Content-Type-Options",
options.xContentTypeOptions ?? "nosniff",
);
setHeaderIfMissing(
headers,
"X-Frame-Options",
options.xFrameOptions ?? "DENY",
);
}
/**
* Create a server hook that adds common browser security headers to every
* response, including native streamed responses.
*/
export function createSecurityHeadersHooks<Ctx>(
options: SecurityHeadersOptions = {},
): ServerHook<Ctx> {
return {
name: "security-headers",
beforeSend: ({ response }) => {
const headers = { ...(response.headers ?? {}) };
applySecurityHeaders(headers, options);
return {
...response,
headers,
};
},
};
}
function normalizeOrigin(value: string, source: string): string {
try {
return new URL(value).origin;
} catch {
throw new Error(`${source} must be an absolute URL origin.`);
}
}
function requestOrigin(
req: HttpRequestLike,
trustedProxy: TrustedProxyConfig | undefined,
): string {
return resolveTrustedRequest(req, trustedProxyOriginConfig(trustedProxy))
.origin;
}
function trustedProxyOriginConfig(
trustedProxy: TrustedProxyConfig | undefined,
): TrustedProxyConfig | undefined {
if (!trustedProxy) return trustedProxy;
const originConfig: TrustedProxyOptions = {};
if (trustedProxy.hostHeader !== undefined) {
originConfig.hostHeader = trustedProxy.hostHeader;
}
if (trustedProxy.protocolHeader !== undefined) {
originConfig.protocolHeader = trustedProxy.protocolHeader;
}
return originConfig;
}
function headerOrigin(value: string | null): string | undefined {
if (!value) return undefined;
try {
return new URL(value).origin;
} catch {
return undefined;
}
}
function originFromRequestHeaders(req: HttpRequestLike): string | undefined {
const origin = req.headers.get("origin");
if (origin) return headerOrigin(origin) ?? origin;
return headerOrigin(req.headers.get("referer"));
}
function normalizeTrustedOrigins(
trustedOrigins: CsrfHooksOptions["trustedOrigins"],
): CsrfHooksOptions["trustedOrigins"] {
if (!Array.isArray(trustedOrigins)) return trustedOrigins;
return trustedOrigins.map((origin) =>
normalizeOrigin(origin, "trustedOrigins entries"),
);
}
function isTrustedOrigin(args: {
origin: string;
req: HttpRequestLike;
contract: HttpContractConfig;
requestOrigin: string;
trustedOrigins: CsrfHooksOptions["trustedOrigins"];
}): boolean {
if (args.origin === args.requestOrigin) return true;
if (Array.isArray(args.trustedOrigins)) {
return args.trustedOrigins.includes(args.origin);
}
if (typeof args.trustedOrigins === "function") {
return args.trustedOrigins({
origin: args.origin,
req: args.req,
contract: args.contract,
});
}
return false;
}
function decodeCookiePart(value: string): string {
try {
return decodeURIComponent(value);
} catch {
return value;
}
}
function parseCookieHeader(
cookieHeader: string | null,
): Record<string, string> {
if (!cookieHeader) return {};
return Object.fromEntries(
cookieHeader
.split(";")
.map((entry) => entry.trim())
.filter(Boolean)
.map((entry) => {
const separator = entry.indexOf("=");
if (separator === -1) return [entry, ""];
return [
decodeCookiePart(entry.slice(0, separator).trim()),
decodeCookiePart(entry.slice(separator + 1).trim()),
];
}),
);
}
function tokensMatch(left: string, right: string): boolean {
if (left.length !== right.length) return false;
let result = 0;
for (let index = 0; index < left.length; index += 1) {
result |= left.charCodeAt(index) ^ right.charCodeAt(index);
}
return result === 0;
}
function csrfError(reason: CsrfFailureReason, message: string): AppError {
return new AppError(httpErrors.Forbidden, { reason }, message);
}
function enforceOrigin(args: {
req: HttpRequestLike;
contract: HttpContractConfig;
allowMissingOrigin: boolean;
trustedOrigins: CsrfHooksOptions["trustedOrigins"];
requestOrigin: string;
}): void {
const origin = originFromRequestHeaders(args.req);
if (!origin) {
if (args.allowMissingOrigin) return;
throw csrfError(
"missing_origin",
"CSRF check failed because the request did not include an Origin or Referer header.",
);
}
if (
!isTrustedOrigin({
origin,
req: args.req,
contract: args.contract,
requestOrigin: args.requestOrigin,
trustedOrigins: args.trustedOrigins,
})
) {
throw csrfError(
"untrusted_origin",
"CSRF check failed because the request origin is not trusted.",
);
}
}
function enforceToken(
req: HttpRequestLike,
token: CsrfTokenOptions | false,
): void {
if (!token) return;
const headerName = token.headerName ?? DEFAULT_CSRF_HEADER;
const cookieName = token.cookieName ?? DEFAULT_CSRF_COOKIE;
const headerToken = req.headers.get(headerName);
const cookieToken = parseCookieHeader(req.headers.get("cookie"))[cookieName];
if (!headerToken || !cookieToken) {
throw csrfError(
"missing_token",
`CSRF check failed because ${headerName} and ${cookieName} must both be present.`,
);
}
if (!tokensMatch(headerToken, cookieToken)) {
throw csrfError(
"invalid_token",
"CSRF check failed because the submitted token does not match the cookie token.",
);
}
}
/**
* Create CSRF protection for unsafe HTTP methods.
*
* The default protects cookie-backed browser routes from cross-origin unsafe
* requests while still allowing server-to-server calls and tests that do not
* send browser origin headers. Set `allowMissingOrigin: false` and configure
* `token` for stricter browser-only APIs.
*/
export function createCsrfHooks<Ctx>(
options: CsrfHooksOptions = {},
): ServerHook<Ctx> {
const protectedMethods = new Set(
(options.protectedMethods ?? DEFAULT_PROTECTED_METHODS).map((method) =>
method.toUpperCase(),
),
);
const allowMissingOrigin = options.allowMissingOrigin ?? true;
const trustedOrigins = normalizeTrustedOrigins(options.trustedOrigins);
const token = options.token ?? false;
return {
name: "csrf",
onRequest: async ({ req, requestInfo, contract, params }) => {
if (!protectedMethods.has(req.method.toUpperCase())) return undefined;
if (await options.skip?.({ req, contract, params })) return undefined;
enforceOrigin({
req,
contract,
allowMissingOrigin,
trustedOrigins,
requestOrigin:
options.trustedProxy !== undefined
? requestOrigin(req, options.trustedProxy)
: (requestInfo?.origin ?? requestOrigin(req, false)),
});
enforceToken(req, token);
return undefined;
},
};
}