UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

512 lines (470 loc) 14.1 kB
/** * 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, HttpResponseHeaders, 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: HttpResponseHeaders, name: string, ): string | undefined { const lowerName = name.toLowerCase(); return Object.keys(headers).find((key) => key.toLowerCase() === lowerName); } function setHeaderIfMissing( headers: HttpResponseHeaders, 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: HttpResponseHeaders, 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; }, }; }