UNPKG

@beignet/core

Version:

Core framework primitives for Beignet

227 lines 8.38 kB
/** * Security hooks for @beignet/core/server */ import { AppError, httpErrors } from "../../errors/index.js"; import { resolveTrustedRequest, } from "../trusted-proxy.js"; const DEFAULT_PERMISSIONS_POLICY = "camera=(), microphone=(), geolocation=()"; const DEFAULT_PROTECTED_METHODS = ["POST", "PUT", "PATCH", "DELETE"]; const DEFAULT_CSRF_HEADER = "x-csrf-token"; const DEFAULT_CSRF_COOKIE = "beignet.csrf"; function headerKey(headers, name) { const lowerName = name.toLowerCase(); return Object.keys(headers).find((key) => key.toLowerCase() === lowerName); } function setHeaderIfMissing(headers, name, value) { if (value === false || value === undefined) return; if (headerKey(headers, name)) return; headers[name] = value; } function formatStrictTransportSecurity(config) { 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, options = {}) { 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(options = {}) { return { name: "security-headers", beforeSend: ({ response }) => { const headers = { ...(response.headers ?? {}) }; applySecurityHeaders(headers, options); return { ...response, headers, }; }, }; } function normalizeOrigin(value, source) { try { return new URL(value).origin; } catch { throw new Error(`${source} must be an absolute URL origin.`); } } function requestOrigin(req, trustedProxy) { return resolveTrustedRequest(req, trustedProxyOriginConfig(trustedProxy)) .origin; } function trustedProxyOriginConfig(trustedProxy) { if (!trustedProxy) return trustedProxy; const originConfig = {}; if (trustedProxy.hostHeader !== undefined) { originConfig.hostHeader = trustedProxy.hostHeader; } if (trustedProxy.protocolHeader !== undefined) { originConfig.protocolHeader = trustedProxy.protocolHeader; } return originConfig; } function headerOrigin(value) { if (!value) return undefined; try { return new URL(value).origin; } catch { return undefined; } } function originFromRequestHeaders(req) { const origin = req.headers.get("origin"); if (origin) return headerOrigin(origin) ?? origin; return headerOrigin(req.headers.get("referer")); } function normalizeTrustedOrigins(trustedOrigins) { if (!Array.isArray(trustedOrigins)) return trustedOrigins; return trustedOrigins.map((origin) => normalizeOrigin(origin, "trustedOrigins entries")); } function isTrustedOrigin(args) { 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) { try { return decodeURIComponent(value); } catch { return value; } } function parseCookieHeader(cookieHeader) { 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, right) { 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, message) { return new AppError(httpErrors.Forbidden, { reason }, message); } function enforceOrigin(args) { 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, token) { 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(options = {}) { 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; }, }; } //# sourceMappingURL=security.js.map