nosecone
Version:
Protect your Response with secure headers
549 lines (547 loc) • 22.6 kB
JavaScript
// Types based on
// https://github.com/josh-hemphill/csp-typed-directives/blob/6e2cbc6d3cc18bbdc9b13d42c4556e786e28b243/src/csp.types.ts
//
// MIT License
//
// Copyright (c) 2021-present, Joshua Hemphill
// Copyright (c) 2021, Tecnico Corporation
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.
// Map of configuration options to the kebab-case names for
// `Content-Security-Policy` directives
const CONTENT_SECURITY_POLICY_DIRECTIVES = new Map([
["baseUri", "base-uri"],
["childSrc", "child-src"],
["defaultSrc", "default-src"],
["frameSrc", "frame-src"],
["workerSrc", "worker-src"],
["connectSrc", "connect-src"],
["fontSrc", "font-src"],
["imgSrc", "img-src"],
["manifestSrc", "manifest-src"],
["mediaSrc", "media-src"],
["objectSrc", "object-src"],
["prefetchSrc", "prefetch-src"],
["scriptSrc", "script-src"],
["scriptSrcElem", "script-src-elem"],
["scriptSrcAttr", "script-src-attr"],
["styleSrc", "style-src"],
["styleSrcElem", "style-src-elem"],
["styleSrcAttr", "style-src-attr"],
["sandbox", "sandbox"],
["formAction", "form-action"],
["frameAncestors", "frame-ancestors"],
["navigateTo", "navigate-to"],
["reportUri", "report-uri"],
["reportTo", "report-to"],
["requireTrustedTypesFor", "require-trusted-types-for"],
["trustedTypes", "trusted-types"],
["upgradeInsecureRequests", "upgrade-insecure-requests"],
]);
// Set of valid `Cross-Origin-Embedder-Policy` values
const CROSS_ORIGIN_EMBEDDER_POLICIES = new Set([
"require-corp",
"credentialless",
"unsafe-none",
]);
// Set of valid `Cross-Origin-Opener-Policy` values
const CROSS_ORIGIN_OPENER_POLICIES = new Set([
"same-origin",
"same-origin-allow-popups",
"unsafe-none",
]);
// Set of valid `Cross-Origin-Resource-Policy` values
const CROSS_ORIGIN_RESOURCE_POLICIES = new Set([
"same-origin",
"same-site",
"cross-origin",
]);
// Set of valid `Resource-Policy` tokens
const REFERRER_POLICIES = new Set([
"no-referrer",
"no-referrer-when-downgrade",
"same-origin",
"origin",
"strict-origin",
"origin-when-cross-origin",
"strict-origin-when-cross-origin",
"unsafe-url",
"",
]);
// Set of valid `X-Permitted-Cross-Domain-Policies` values
const PERMITTED_CROSS_DOMAIN_POLICIES = new Set([
"none",
"master-only",
"by-content-type",
"all",
]);
// Set of valid values for the `sandbox` directive of `Content-Security-Policy`
const SANDBOX_DIRECTIVES = new Set([
"allow-downloads-without-user-activation",
"allow-forms",
"allow-modals",
"allow-orientation-lock",
"allow-pointer-lock",
"allow-popups",
"allow-popups-to-escape-sandbox",
"allow-presentation",
"allow-same-origin",
"allow-scripts",
"allow-storage-access-by-user-activation",
"allow-top-navigation",
"allow-top-navigation-by-user-activation",
]);
// Mapping of values that need to be quoted in `Content-Security-Policy`;
// however, it does not include `nonce-*` or `sha*-*` because those are dynamic
const QUOTED = new Map([
["self", "'self'"],
["unsafe-eval", "'unsafe-eval'"],
["unsafe-hashes", "'unsafe-hashes'"],
["unsafe-inline", "'unsafe-inline'"],
["none", "'none'"],
["strict-dynamic", "'strict-dynamic'"],
["report-sample", "'report-sample'"],
["wasm-unsafe-eval", "'wasm-unsafe-eval'"],
["script", "'script'"],
]);
const directives = {
baseUri: ["'none'"],
childSrc: ["'none'"],
connectSrc: ["'self'"],
defaultSrc: ["'self'"],
fontSrc: ["'self'"],
formAction: ["'self'"],
frameAncestors: ["'none'"],
frameSrc: ["'none'"],
imgSrc: ["'self'", "blob:", "data:"],
manifestSrc: ["'self'"],
mediaSrc: ["'self'"],
objectSrc: ["'none'"],
scriptSrc: ["'self'"],
styleSrc: ["'self'"],
workerSrc: ["'self'"],
};
const defaults = {
contentSecurityPolicy: {
directives,
},
crossOriginEmbedderPolicy: {
policy: "require-corp",
},
crossOriginOpenerPolicy: {
policy: "same-origin",
},
crossOriginResourcePolicy: {
policy: "same-origin",
},
originAgentCluster: true,
referrerPolicy: {
policy: ["no-referrer"],
},
strictTransportSecurity: {
maxAge: 365 * 24 * 60 * 60,
includeSubDomains: true,
preload: false,
},
xContentTypeOptions: true,
xDnsPrefetchControl: {
allow: false,
},
xDownloadOptions: true,
xFrameOptions: {
action: "sameorigin",
},
xPermittedCrossDomainPolicies: {
permittedPolicies: "none",
},
xXssProtection: true,
};
function resolveValue(v) {
if (typeof v === "function") {
return v();
}
else {
return v;
}
}
class NoseconeValidationError extends Error {
constructor(message) {
super(`validation error: ${message}`);
}
}
// Header defaults and construction inspired by
// https://github.com/helmetjs/helmet/tree/9a8e6d5322aad6090394b0bb2e81448c5f5b3e74
//
// The MIT License
//
// Copyright (c) 2012-2024 Evan Hahn, Adam Baldwin
//
// Permission is hereby granted, free of charge, to any person obtaining
// a copy of this software and associated documentation files (the
// 'Software'), to deal in the Software without restriction, including
// without limitation the rights to use, copy, modify, merge, publish,
// distribute, sublicense, and/or sell copies of the Software, and to
// permit persons to whom the Software is furnished to do so, subject to
// the following conditions:
//
// The above copyright notice and this permission notice shall be
// included in all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED 'AS IS', WITHOUT WARRANTY OF ANY KIND,
// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
// IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
// CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
// TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
// SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
function createContentSecurityPolicy({ directives = defaults.contentSecurityPolicy.directives, } = defaults.contentSecurityPolicy) {
const cspEntries = [];
for (const [optionKey, optionValues] of Object.entries(directives)) {
const key = CONTENT_SECURITY_POLICY_DIRECTIVES.get(
// @ts-expect-error because we're validating this option key
optionKey);
if (!key) {
throw new NoseconeValidationError(`${optionKey} is not a Content-Security-Policy directive`);
}
// Skip anything falsey
if (!optionValues) {
continue;
}
// TODO: What do we want to do if array is empty? I think they work differently for some directives
const resolvedValues = Array.isArray(optionValues)
? new Set(optionValues.map(resolveValue))
: new Set();
// TODO: Add more validation
for (const value of resolvedValues) {
if (QUOTED.has(
// @ts-expect-error because we are validation this value
value)) {
throw new NoseconeValidationError(`"${value}" must be quoted using single-quotes, e.g. "'${value}'"`);
}
if (key === "sandbox") {
if (!SANDBOX_DIRECTIVES.has(
// @ts-expect-error because we are validation this value
value)) {
throw new NoseconeValidationError("invalid sandbox value in Content-Security-Policy");
}
}
}
const values = Array.from(resolvedValues);
const entry = `${key} ${values.join(" ")}`.trim();
const entryWithSep = `${entry};`;
cspEntries.push(entryWithSep);
}
return ["content-security-policy", cspEntries.join(" ")];
}
function createCrossOriginEmbedderPolicy({ policy = defaults.crossOriginEmbedderPolicy.policy, } = defaults.crossOriginEmbedderPolicy) {
if (CROSS_ORIGIN_EMBEDDER_POLICIES.has(policy)) {
return ["cross-origin-embedder-policy", policy];
}
else {
throw new NoseconeValidationError(`invalid value for Cross-Origin-Embedder-Policy`);
}
}
function createCrossOriginOpenerPolicy({ policy = defaults.crossOriginOpenerPolicy.policy, } = defaults.crossOriginOpenerPolicy) {
if (CROSS_ORIGIN_OPENER_POLICIES.has(policy)) {
return ["cross-origin-opener-policy", policy];
}
else {
throw new NoseconeValidationError(`invalid value for Cross-Origin-Opener-Policy`);
}
}
function createCrossOriginResourcePolicy({ policy = defaults.crossOriginResourcePolicy.policy, } = defaults.crossOriginResourcePolicy) {
if (CROSS_ORIGIN_RESOURCE_POLICIES.has(policy)) {
return ["cross-origin-resource-policy", policy];
}
else {
throw new NoseconeValidationError(`invalid value for Cross-Origin-Resource-Policy`);
}
}
function createOriginAgentCluster() {
return ["origin-agent-cluster", "?1"];
}
function createReferrerPolicy({ policy = defaults.referrerPolicy.policy, } = defaults.referrerPolicy) {
if (Array.isArray(policy)) {
if (policy.length > 0) {
const tokens = new Set();
for (const token of policy) {
if (REFERRER_POLICIES.has(token)) {
tokens.add(token);
}
else {
throw new NoseconeValidationError(`invalid value for Referrer-Policy`);
}
}
return ["referrer-policy", Array.from(tokens).join(",")];
}
else {
throw new NoseconeValidationError("must provide at least one policy for Referrer-Policy");
}
}
throw new NoseconeValidationError("must provide array for Referrer-Policy");
}
function createStrictTransportSecurity({ maxAge = defaults.strictTransportSecurity.maxAge, includeSubDomains = defaults.strictTransportSecurity.includeSubDomains, preload = defaults.strictTransportSecurity.preload, } = defaults.strictTransportSecurity) {
if (maxAge >= 0 && Number.isFinite(maxAge)) {
maxAge = Math.floor(maxAge);
}
else {
throw new NoseconeValidationError("must provide a finite, positive integer for the maxAge of Strict-Transport-Security");
}
const directives = [`max-age=${maxAge}`];
if (includeSubDomains) {
directives.push("includeSubDomains");
}
if (preload) {
directives.push("preload");
}
return ["strict-transport-security", directives.join("; ")];
}
function createContentTypeOptions() {
return ["x-content-type-options", "nosniff"];
}
function createDnsPrefetchControl({ allow = defaults.xDnsPrefetchControl.allow, } = defaults.xDnsPrefetchControl) {
const headerValue = allow ? "on" : "off";
return ["x-dns-prefetch-control", headerValue];
}
function createDownloadOptions() {
return ["x-download-options", "noopen"];
}
function createFrameOptions({ action = defaults.xFrameOptions.action, } = defaults.xFrameOptions) {
if (typeof action === "string") {
const headerValue = action.toUpperCase();
if (headerValue === "SAMEORIGIN" || headerValue === "DENY") {
return ["x-frame-options", headerValue];
}
}
throw new NoseconeValidationError("invalid value for X-Frame-Options");
}
function createPermittedCrossDomainPolicies({ permittedPolicies = defaults.xPermittedCrossDomainPolicies
.permittedPolicies, } = defaults.xPermittedCrossDomainPolicies) {
if (PERMITTED_CROSS_DOMAIN_POLICIES.has(permittedPolicies)) {
return ["x-permitted-cross-domain-policies", permittedPolicies];
}
else {
throw new NoseconeValidationError(`invalid value for X-Permitted-Cross-Domain-Policies`);
}
}
function createXssProtection() {
return ["x-xss-protection", "0"];
}
function nosecone({ contentSecurityPolicy = defaults.contentSecurityPolicy, crossOriginEmbedderPolicy = defaults.crossOriginEmbedderPolicy, crossOriginOpenerPolicy = defaults.crossOriginOpenerPolicy, crossOriginResourcePolicy = defaults.crossOriginResourcePolicy, originAgentCluster = defaults.originAgentCluster, referrerPolicy = defaults.referrerPolicy, strictTransportSecurity = defaults.strictTransportSecurity, xContentTypeOptions = defaults.xContentTypeOptions, xDnsPrefetchControl = defaults.xDnsPrefetchControl, xDownloadOptions = defaults.xDownloadOptions, xFrameOptions = defaults.xFrameOptions, xPermittedCrossDomainPolicies = defaults.xPermittedCrossDomainPolicies, xXssProtection = defaults.xXssProtection, } = defaults) {
if (contentSecurityPolicy === true) {
contentSecurityPolicy = defaults.contentSecurityPolicy;
}
if (crossOriginEmbedderPolicy === true) {
crossOriginEmbedderPolicy = defaults.crossOriginEmbedderPolicy;
}
if (crossOriginOpenerPolicy === true) {
crossOriginOpenerPolicy = defaults.crossOriginOpenerPolicy;
}
if (crossOriginResourcePolicy === true) {
crossOriginResourcePolicy = defaults.crossOriginResourcePolicy;
}
if (referrerPolicy === true) {
referrerPolicy = defaults.referrerPolicy;
}
if (strictTransportSecurity === true) {
strictTransportSecurity = defaults.strictTransportSecurity;
}
if (xDnsPrefetchControl === true) {
xDnsPrefetchControl = defaults.xDnsPrefetchControl;
}
if (xFrameOptions === true) {
xFrameOptions = defaults.xFrameOptions;
}
if (xPermittedCrossDomainPolicies === true) {
xPermittedCrossDomainPolicies = defaults.xPermittedCrossDomainPolicies;
}
const headers = new Headers();
if (contentSecurityPolicy) {
const [headerName, headerValue] = createContentSecurityPolicy(contentSecurityPolicy);
headers.set(headerName, headerValue);
}
if (crossOriginEmbedderPolicy) {
const [headerName, headerValue] = createCrossOriginEmbedderPolicy(crossOriginEmbedderPolicy);
headers.set(headerName, headerValue);
}
if (crossOriginOpenerPolicy) {
const [headerName, headerValue] = createCrossOriginOpenerPolicy(crossOriginOpenerPolicy);
headers.set(headerName, headerValue);
}
if (crossOriginResourcePolicy) {
const [headerName, headerValue] = createCrossOriginResourcePolicy(crossOriginResourcePolicy);
headers.set(headerName, headerValue);
}
if (originAgentCluster) {
const [headerName, headerValue] = createOriginAgentCluster();
headers.set(headerName, headerValue);
}
if (referrerPolicy) {
const [headerName, headerValue] = createReferrerPolicy(referrerPolicy);
headers.set(headerName, headerValue);
}
if (strictTransportSecurity) {
const [headerName, headerValue] = createStrictTransportSecurity(strictTransportSecurity);
headers.set(headerName, headerValue);
}
if (xContentTypeOptions) {
const [headerName, headerValue] = createContentTypeOptions();
headers.set(headerName, headerValue);
}
if (xDnsPrefetchControl) {
const [headerName, headerValue] = createDnsPrefetchControl(xDnsPrefetchControl);
headers.set(headerName, headerValue);
}
if (xDownloadOptions) {
const [headerName, headerValue] = createDownloadOptions();
headers.set(headerName, headerValue);
}
if (xFrameOptions) {
const [headerName, headerValue] = createFrameOptions(xFrameOptions);
headers.set(headerName, headerValue);
}
if (xPermittedCrossDomainPolicies) {
const [headerName, headerValue] = createPermittedCrossDomainPolicies(xPermittedCrossDomainPolicies);
headers.set(headerName, headerValue);
}
if (xXssProtection) {
const [headerName, headerValue] = createXssProtection();
headers.set(headerName, headerValue);
}
return headers;
}
/**
* Augment some Nosecone configuration with the values necessary for using the
* Vercel Toolbar.
*
* Follows the guidance at
* https://vercel.com/docs/workflow-collaboration/vercel-toolbar/managing-toolbar#using-a-content-security-policy
*
* @param config Base configuration for you application
* @returns Augmented configuration to allow Vercel Toolbar
*/
function withVercelToolbar(config) {
let contentSecurityPolicy = config.contentSecurityPolicy;
if (contentSecurityPolicy === true) {
contentSecurityPolicy = defaults.contentSecurityPolicy;
}
let augmentedContentSecurityPolicy = contentSecurityPolicy;
if (contentSecurityPolicy) {
let scriptSrc = contentSecurityPolicy.directives?.scriptSrc;
if (scriptSrc === true) {
scriptSrc = defaults.contentSecurityPolicy.directives.scriptSrc;
}
let connectSrc = contentSecurityPolicy.directives?.connectSrc;
if (connectSrc === true) {
connectSrc = defaults.contentSecurityPolicy.directives.connectSrc;
}
let imgSrc = contentSecurityPolicy.directives?.imgSrc;
if (imgSrc === true) {
imgSrc = defaults.contentSecurityPolicy.directives.imgSrc;
}
let frameSrc = contentSecurityPolicy.directives?.frameSrc;
if (frameSrc === true) {
frameSrc = defaults.contentSecurityPolicy.directives.frameSrc;
}
let styleSrc = contentSecurityPolicy.directives?.styleSrc;
if (styleSrc === true) {
styleSrc = defaults.contentSecurityPolicy.directives.styleSrc;
}
let fontSrc = contentSecurityPolicy.directives?.fontSrc;
if (fontSrc === true) {
fontSrc = defaults.contentSecurityPolicy.directives.fontSrc;
}
augmentedContentSecurityPolicy = {
...contentSecurityPolicy,
directives: {
...contentSecurityPolicy.directives,
scriptSrc: scriptSrc
? [
...scriptSrc.filter((v) => v !== "'none'" && v !== "https://vercel.live"),
"https://vercel.live",
]
: scriptSrc,
connectSrc: connectSrc
? [
...connectSrc.filter((v) => v !== "'none'" &&
v !== "https://vercel.live" &&
v !== "wss://ws-us3.pusher.com"),
"https://vercel.live",
"wss://ws-us3.pusher.com",
]
: connectSrc,
imgSrc: imgSrc
? [
...imgSrc.filter((v) => v !== "'none'" &&
v !== "https://vercel.live" &&
v !== "https://vercel.com" &&
v !== "data:" &&
v !== "blob:"),
"https://vercel.live",
"https://vercel.com",
"data:",
"blob:",
]
: imgSrc,
frameSrc: frameSrc
? [
...frameSrc.filter((v) => v !== "'none'" && v !== "https://vercel.live"),
"https://vercel.live",
]
: frameSrc,
styleSrc: styleSrc
? [
...styleSrc.filter((v) => v !== "'none'" &&
v !== "https://vercel.live" &&
v !== "'unsafe-inline'"),
"https://vercel.live",
"'unsafe-inline'",
]
: styleSrc,
fontSrc: fontSrc
? [
...fontSrc.filter((v) => v !== "'none'" &&
v !== "https://vercel.live" &&
v !== "https://assets.vercel.com"),
"https://vercel.live",
"https://assets.vercel.com",
]
: fontSrc,
},
};
}
let crossOriginEmbedderPolicy = config.crossOriginEmbedderPolicy;
if (crossOriginEmbedderPolicy === true) {
crossOriginEmbedderPolicy = defaults.crossOriginEmbedderPolicy;
}
let augmentedCrossOriginEmbedderPolicy = crossOriginEmbedderPolicy;
if (crossOriginEmbedderPolicy) {
augmentedCrossOriginEmbedderPolicy = {
policy: crossOriginEmbedderPolicy.policy
? "unsafe-none"
: crossOriginEmbedderPolicy.policy,
};
}
return {
...config,
contentSecurityPolicy: augmentedContentSecurityPolicy,
crossOriginEmbedderPolicy: augmentedCrossOriginEmbedderPolicy,
};
}
export { CONTENT_SECURITY_POLICY_DIRECTIVES, CROSS_ORIGIN_EMBEDDER_POLICIES, CROSS_ORIGIN_OPENER_POLICIES, CROSS_ORIGIN_RESOURCE_POLICIES, NoseconeValidationError, PERMITTED_CROSS_DOMAIN_POLICIES, QUOTED, REFERRER_POLICIES, SANDBOX_DIRECTIVES, createContentSecurityPolicy, createContentTypeOptions, createCrossOriginEmbedderPolicy, createCrossOriginOpenerPolicy, createCrossOriginResourcePolicy, createDnsPrefetchControl, createDownloadOptions, createFrameOptions, createOriginAgentCluster, createPermittedCrossDomainPolicies, createReferrerPolicy, createStrictTransportSecurity, createXssProtection, nosecone as default, defaults, withVercelToolbar };