@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
JavaScript
// 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;