UNPKG

@middy/http-security-headers

Version:

Applies best practice security headers to responses. It's a simplified port of HelmetJS

423 lines (390 loc) 12.7 kB
// Copyright 2017 - 2026 will Farrell, Luciano Mammino, and Middy contributors. // SPDX-License-Identifier: MIT import { normalizeHttpResponse, validateOptions } from "@middy/util"; const name = "http-security-headers"; const pkg = `@middy/${name}`; // Each policy option accepts either a config object or a boolean (true = use // defaults, false = disable). See middleware body for enable/disable logic. const booleanOr = (objectSchema) => ({ oneOf: [{ type: "boolean" }, objectSchema], }); const policyObject = (policyEnum) => ({ type: "object", properties: { policy: policyEnum ? { type: "string", enum: policyEnum } : { type: "string" }, }, additionalProperties: false, }); const actionObject = { type: "object", properties: { action: { type: "string" }, }, additionalProperties: false, }; const referrerPolicyValues = [ "no-referrer", "no-referrer-when-downgrade", "origin", "origin-when-cross-origin", "same-origin", "strict-origin", "strict-origin-when-cross-origin", "unsafe-url", ]; const permittedCrossDomainPoliciesValues = [ "none", "master-only", "by-content-type", "by-ftp-filename", "all", ]; const optionSchema = { type: "object", properties: { // CSP directives are an open-ended string map; keep permissive. contentSecurityPolicy: booleanOr({ type: "object" }), contentSecurityPolicyReportOnly: { type: "boolean" }, contentTypeOptions: booleanOr(actionObject), crossOriginEmbedderPolicy: booleanOr(policyObject()), crossOriginOpenerPolicy: booleanOr(policyObject()), crossOriginResourcePolicy: booleanOr(policyObject()), dnsPrefetchControl: booleanOr({ type: "object", properties: { allow: { type: "boolean" }, }, additionalProperties: false, }), downloadOptions: booleanOr(actionObject), frameOptions: booleanOr(actionObject), originAgentCluster: booleanOr({ type: "object", additionalProperties: false, }), // Permissions-Policy features are an open-ended string map. permissionsPolicy: booleanOr({ type: "object" }), permittedCrossDomainPolicies: booleanOr( policyObject(permittedCrossDomainPoliciesValues), ), poweredBy: { type: "boolean" }, referrerPolicy: booleanOr(policyObject(referrerPolicyValues)), // Reporting-Endpoints is an open-ended group->url map. reportingEndpoints: booleanOr({ type: "object" }), // Report-To groups are open-ended; maxAge/includeSubDomains are known. reportTo: booleanOr({ type: "object" }), strictTransportSecurity: booleanOr({ type: "object", properties: { maxAge: { type: "number", minimum: 0 }, includeSubDomains: { type: "boolean" }, preload: { type: "boolean" }, }, additionalProperties: false, }), xssProtection: booleanOr({ type: "object", additionalProperties: false, }), }, additionalProperties: false, }; export const httpSecurityHeadersValidateOptions = (options) => validateOptions(pkg, optionSchema, options); // Code and Defaults heavily based off https://helmetjs.github.io/ const defaults = { contentSecurityPolicy: { // Fetch directives // 'child-src': '', // fallback default-src // 'connect-src': '', // fallback default-src "default-src": "'report-sample' 'report-sha256'", // 'font-src':'', // fallback default-src // 'frame-src':'', // fallback child-src > default-src // 'img-src':'', // fallback default-src // 'manifest-src':'', // fallback default-src // 'media-src':'', // fallback default-src // 'object-src':'', // fallback default-src // 'prefetch-src':'', // fallback default-src // 'script-src':'', // fallback default-src // 'script-src-elem':'', // fallback script-src > default-src // 'script-src-attr':'', // fallback script-src > default-src // 'style-src':'', // fallback default-src // 'style-src-elem':'', // fallback style-src > default-src // 'style-src-attr':'', // fallback style-src > default-src // 'worker-src':'', // fallback child-src > script-src > default-src // Document directives "base-uri": "'none'", sandbox: "", // Navigation directives "form-action": "'none'", "frame-ancestors": "'none'", // Reporting directives "report-to": "default", // Other directives "require-trusted-types-for": "'script'", "upgrade-insecure-requests": "", }, contentSecurityPolicyReportOnly: false, contentTypeOptions: { action: "nosniff", }, crossOriginEmbedderPolicy: { policy: "require-corp", }, crossOriginOpenerPolicy: { policy: "same-origin", }, crossOriginResourcePolicy: { policy: "same-origin", }, // Stryker disable next-line ObjectLiteral: {allow:false} and {} both yield config.allow falsy -> "off"; observationally equivalent dnsPrefetchControl: { allow: false, }, downloadOptions: { action: "noopen", }, frameOptions: { action: "deny", }, originAgentCluster: {}, permissionsPolicy: { // Standard accelerometer: "", "all-screens-capture": "", "ambient-light-sensor": "", autoplay: "", battery: "", camera: "", "cross-origin-isolated": "", "display-capture": "", "document-domain": "", "encrypted-media": "", "execution-while-not-rendered": "", "execution-while-out-of-viewport": "", fullscreen: "", geolocation: "", gyroscope: "", "keyboard-map": "", magnetometer: "", microphone: "", midi: "", monetization: "", "navigation-override": "", payment: "", "picture-in-picture": "", "publickey-credentials-get": "", "screen-wake-lock": "", "sync-xhr": "", usb: "", "web-share": "", "xr-spatial-tracking": "", // Proposed "clipboard-read": "", "clipboard-write": "", gamepad: "", "speaker-selection": "", // Experimental "conversion-measurement": "", "focus-without-user-activation": "", hid: "", "idle-detection": "", "interest-cohort": "", serial: "", "sync-script": "", "trust-token-redemption": "", "window-placement": "", "vertical-scroll": "", }, permittedCrossDomainPolicies: { policy: "none", // none, master-only, by-content-type, by-ftp-filename, all }, poweredBy: true, referrerPolicy: { policy: "no-referrer", }, reportingEndpoints: {}, reportTo: { maxAge: 365 * 24 * 60 * 60, includeSubDomains: true, }, strictTransportSecurity: { maxAge: 180 * 24 * 60 * 60, includeSubDomains: true, preload: true, }, xssProtection: false, }; const helmet = {}; // *** https://github.com/helmetjs/helmet/tree/main/middlewares *** // // https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy helmet.contentSecurityPolicy = (reportOnly) => (headers, config) => { let header = Object.keys(config) .map((policy) => (config[policy] ? `${policy} ${config[policy]}` : "")) .filter((str) => str) .join("; "); if (config.sandbox === "") { header += "; sandbox"; } if (config["upgrade-insecure-requests"] === "") { header += "; upgrade-insecure-requests"; } const cspHeaderName = reportOnly ? "Content-Security-Policy-Report-Only" : "Content-Security-Policy"; headers[cspHeaderName] = header; }; // crossdomain - N/A - for Adobe products helmet.crossOriginEmbedderPolicy = (headers, config) => { headers["Cross-Origin-Embedder-Policy"] = config.policy; }; helmet.crossOriginOpenerPolicy = (headers, config) => { headers["Cross-Origin-Opener-Policy"] = config.policy; }; helmet.crossOriginResourcePolicy = (headers, config) => { headers["Cross-Origin-Resource-Policy"] = config.policy; }; // DEPRECATED: expectCt // DEPRECATED: hpkp // https://www.permissionspolicy.com/ helmet.permissionsPolicy = (headers, config) => { headers["Permissions-Policy"] = Object.keys(config) .map( (policy) => `${policy}=${config[policy] === "*" ? "*" : `(${config[policy]})`}`, ) .join(", "); }; helmet.originAgentCluster = (headers) => { headers["Origin-Agent-Cluster"] = "?1"; }; // https://github.com/helmetjs/referrer-policy helmet.referrerPolicy = (headers, config) => { headers["Referrer-Policy"] = config.policy; }; // DEPRECATED by reportingEndpoints helmet.reportTo = (headers, config) => { // `maxAge` and `includeSubDomains` are settings, not endpoint groups. Accept // both `includeSubDomains` (aligned with HSTS) and the legacy `includeSubdomains` // casing so a user override is honored either way. const settingKeys = new Set([ "maxAge", "includeSubDomains", "includeSubdomains", ]); // Defaults only set `includeSubDomains`; a legacy `includeSubdomains` key can // only come from a user override, so honor it when present. const includeSubDomains = Object.hasOwn(config, "includeSubdomains") ? config.includeSubdomains : config.includeSubDomains; const header = Object.keys(config) .map((group) => { if (settingKeys.has(group)) return ""; const subdomains = group === "default" ? `, "include_subdomains": ${includeSubDomains}` : ""; return config[group] ? `{ "group": "default", "max_age": ${config.maxAge}, "endpoints": [ { "url": "${config[group]}" } ]${subdomains} }` : ""; }) .filter((str) => str) .join(", "); // Don't emit an empty header when no endpoints are configured. if (header) { headers["Report-To"] = header; } }; helmet.reportingEndpoints = (headers, config) => { const header = Object.keys(config) .map((key) => `${key}="${config[key]}"`) .join(", "); // Don't emit an empty header when no endpoints are configured. if (header) { headers["Reporting-Endpoints"] = header; } }; // https://github.com/helmetjs/hsts helmet.strictTransportSecurity = (headers, config) => { const maxAge = Math.round(config.maxAge); let header = `max-age=${maxAge}`; if (config.includeSubDomains) { header += "; includeSubDomains"; } // The HSTS preload list only accepts entries with max-age >= 31536000 (1 year). // Emitting `preload` below that threshold produces an invalid token, so omit it. if (config.preload && maxAge >= 31536000) { header += "; preload"; } headers["Strict-Transport-Security"] = header; }; // noCache - N/A - separate middleware // X-* // // https://github.com/helmetjs/dont-sniff-mimetype helmet.contentTypeOptions = (headers, config) => { headers["X-Content-Type-Options"] = config.action; }; // https://github.com/helmetjs/dns-Prefetch-control helmet.dnsPrefetchControl = (headers, config) => { headers["X-DNS-Prefetch-Control"] = config.allow ? "on" : "off"; }; // https://github.com/helmetjs/ienoopen helmet.downloadOptions = (headers, config) => { headers["X-Download-Options"] = config.action; }; // https://github.com/helmetjs/frameOptions helmet.frameOptions = (headers, config) => { headers["X-Frame-Options"] = config.action.toUpperCase(); }; // https://github.com/helmetjs/crossdomain helmet.permittedCrossDomainPolicies = (headers, config) => { headers["X-Permitted-Cross-Domain-Policies"] = config.policy; }; // https://github.com/helmetjs/hide-powered-by // Removal handled in after hook via delete to avoid undefined cleanup loop helmet.poweredBy = () => {}; // https://github.com/helmetjs/x-xss-protection helmet.xssProtection = (headers, config) => { headers["X-XSS-Protection"] = "0"; }; const httpSecurityHeadersMiddleware = (opts = {}) => { const options = { ...defaults, ...opts }; // Pre-compute all static header values once at initialization const precomputedHeaders = {}; for (const key of Object.keys(helmet)) { if (!options[key]) continue; const config = { ...defaults[key], ...options[key] }; if (key === "contentSecurityPolicy") { helmet[key](options.contentSecurityPolicyReportOnly)( precomputedHeaders, config, ); } else { helmet[key](precomputedHeaders, config); } } const httpSecurityHeadersMiddlewareAfter = (request) => { normalizeHttpResponse(request); const headers = request.response.headers; Object.assign(headers, precomputedHeaders); if (options.poweredBy) { // Guard `delete` to avoid V8 hidden-class transitions when the key // was never set (the typical Lambda handler case). // Stryker disable next-line ConditionalExpression: `in` guard is a perf-only hidden-class optimization; forcing true still deletes a possibly-absent key with identical observable output if ("Server" in headers) delete headers.Server; // Stryker disable next-line ConditionalExpression: `in` guard is a perf-only hidden-class optimization; forcing true still deletes a possibly-absent key with identical observable output if ("X-Powered-By" in headers) delete headers["X-Powered-By"]; } }; const httpSecurityHeadersMiddlewareOnError = (request) => { if (typeof request.response === "undefined") return; httpSecurityHeadersMiddlewareAfter(request); }; return { after: httpSecurityHeadersMiddlewareAfter, onError: httpSecurityHeadersMiddlewareOnError, }; }; export default httpSecurityHeadersMiddleware;