@beignet/core
Version:
Core framework primitives for Beignet
227 lines • 8.38 kB
JavaScript
/**
* 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