nosecone
Version:
Protect your Response with secure headers
504 lines (503 loc) • 18.9 kB
JavaScript
//#region src/index.ts
/**
* Map of configuration options to the kebab-case names for
* `Content-Security-Policy` directives.
*/
const CONTENT_SECURITY_POLICY_DIRECTIVES = /* @__PURE__ */ 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 = /* @__PURE__ */ new Set([
"require-corp",
"credentialless",
"unsafe-none"
]);
/**
* Set of valid `Cross-Origin-Opener-Policy` values.
*/
const CROSS_ORIGIN_OPENER_POLICIES = /* @__PURE__ */ new Set([
"same-origin",
"same-origin-allow-popups",
"unsafe-none"
]);
/**
* Set of valid `Cross-Origin-Resource-Policy` values.
*/
const CROSS_ORIGIN_RESOURCE_POLICIES = /* @__PURE__ */ new Set([
"same-origin",
"same-site",
"cross-origin"
]);
/**
* Set of valid `Resource-Policy` tokens.
*/
const REFERRER_POLICIES = /* @__PURE__ */ 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 = /* @__PURE__ */ new Set([
"none",
"master-only",
"by-content-type",
"all"
]);
/**
* Set of valid values for the `sandbox` directive of `Content-Security-Policy`.
*/
const SANDBOX_DIRECTIVES = /* @__PURE__ */ 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 = /* @__PURE__ */ 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'"]
]);
/**
* Default configuration for headers.
*/
const defaults = {
contentSecurityPolicy: { 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'"]
} },
crossOriginEmbedderPolicy: { policy: "require-corp" },
crossOriginOpenerPolicy: { policy: "same-origin" },
crossOriginResourcePolicy: { policy: "same-origin" },
originAgentCluster: true,
referrerPolicy: { policy: ["no-referrer"] },
strictTransportSecurity: {
maxAge: 31536e3,
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;
}
/**
* Kind of error thrown when configuration is invalid.
*/
var NoseconeValidationError = class extends Error {
/**
* Create a new `NoseconeValidationError`.
*
* @param message
* Error message.
*/
constructor(message) {
super(`validation error: ${message}`);
}
};
/**
* Create a `Content-Security-Policy` header.
*
* @param options
* Configuration.
* @returns
* `Content-Security-Policy` header.
*/
function createContentSecurityPolicy(options) {
const directives = options?.directives ?? defaults.contentSecurityPolicy.directives;
const cspEntries = [];
for (const [optionKey, optionValues] of Object.entries(directives)) {
const key = CONTENT_SECURITY_POLICY_DIRECTIVES.get(optionKey);
if (!key) throw new NoseconeValidationError(`${optionKey} is not a Content-Security-Policy directive`);
if (!optionValues) continue;
const resolvedValues = Array.isArray(optionValues) ? new Set(optionValues.map(resolveValue)) : /* @__PURE__ */ new Set();
for (const value of resolvedValues) {
if (QUOTED.has(value)) throw new NoseconeValidationError(`"${value}" must be quoted using single-quotes, e.g. "'${value}'"`);
if (key === "sandbox") {
if (!SANDBOX_DIRECTIVES.has(value)) throw new NoseconeValidationError("invalid sandbox value in Content-Security-Policy");
}
}
const entryWithSep = `${`${key} ${Array.from(resolvedValues).join(" ")}`.trim()};`;
cspEntries.push(entryWithSep);
}
return ["content-security-policy", cspEntries.join(" ")];
}
/**
* Create a `Cross-Origin-Embedder-Policy` header.
*
* @param options
* Configuration.
* @returns
* `Cross-Origin-Embedder-Policy` header.
*/
function createCrossOriginEmbedderPolicy(options) {
const policy = options?.policy ?? defaults.crossOriginEmbedderPolicy.policy;
if (CROSS_ORIGIN_EMBEDDER_POLICIES.has(policy)) return ["cross-origin-embedder-policy", policy];
else throw new NoseconeValidationError(`invalid value for Cross-Origin-Embedder-Policy`);
}
/**
* Create a `Cross-Origin-Opener-Policy` header.
*
* @param options
* Configuration.
* @returns
* `Cross-Origin-Opener-Policy` header.
*/
function createCrossOriginOpenerPolicy(options) {
const policy = options?.policy ?? defaults.crossOriginOpenerPolicy.policy;
if (CROSS_ORIGIN_OPENER_POLICIES.has(policy)) return ["cross-origin-opener-policy", policy];
else throw new NoseconeValidationError(`invalid value for Cross-Origin-Opener-Policy`);
}
/**
* Create a `Cross-Origin-Resource-Policy` header.
*
* @param options
* Configuration.
* @returns
* `Cross-Origin-Resource-Policy` header.
*/
function createCrossOriginResourcePolicy(options) {
const policy = options?.policy ?? defaults.crossOriginResourcePolicy.policy;
if (CROSS_ORIGIN_RESOURCE_POLICIES.has(policy)) return ["cross-origin-resource-policy", policy];
else throw new NoseconeValidationError(`invalid value for Cross-Origin-Resource-Policy`);
}
/**
* Create a `Origin-Agent-Cluster` header.
*
* @returns
* `Origin-Agent-Cluster` header.
*/
function createOriginAgentCluster() {
return ["origin-agent-cluster", "?1"];
}
/**
* Create a `Referrer-Policy` header.
*
* @param options
* Configuration.
* @returns
* `Referrer-Policy` header.
*/
function createReferrerPolicy(options) {
const policy = options?.policy ?? defaults.referrerPolicy.policy;
if (Array.isArray(policy)) if (policy.length > 0) {
const tokens = /* @__PURE__ */ 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");
}
/**
* Create a `Strict-Transport-Security` header.
*
* @param options
* Configuration.
* @returns
* `Strict-Transport-Security` header.
*/
function createStrictTransportSecurity(options) {
let maxAge = options?.maxAge ?? defaults.strictTransportSecurity.maxAge;
const includeSubDomains = options?.includeSubDomains ?? defaults.strictTransportSecurity.includeSubDomains;
const preload = options?.preload ?? defaults.strictTransportSecurity.preload;
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("; ")];
}
/**
* Create an `X-Content-Type-Options` header.
*
* @returns
* `X-Content-Type-Options` header.
*/
function createContentTypeOptions() {
return ["x-content-type-options", "nosniff"];
}
/**
* Create an `X-DNS-Prefetch-Control` header.
*
* @param options
* Configuration.
* @returns
* `X-DNS-Prefetch-Control` header.
*/
function createDnsPrefetchControl(options) {
return ["x-dns-prefetch-control", options?.allow ?? defaults.xDnsPrefetchControl.allow ? "on" : "off"];
}
function createDownloadOptions() {
return ["x-download-options", "noopen"];
}
/**
* Create an `X-Frame-Options` header.
*
* @param options
* Configuration.
* @returns
* `X-Frame-Options` header.
*/
function createFrameOptions(options) {
const action = options?.action ?? defaults.xFrameOptions.action;
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");
}
/**
* Create an `X-Permitted-Cross-Domain-Policies` header.
*
* @param options
* Configuration.
* @returns
* `X-Permitted-Cross-Domain-Policies` header.
*/
function createPermittedCrossDomainPolicies(options) {
const permittedPolicies = options?.permittedPolicies ?? defaults.xPermittedCrossDomainPolicies.permittedPolicies;
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`);
}
/**
* Create an `X-XSS-Protection` header.
*
* @returns
* `X-XSS-Protection` header.
*/
function createXssProtection() {
return ["x-xss-protection", "0"];
}
/**
* Create security headers.
*
* @param options
* Configuration.
* @returns
* `Headers` with the configured security headers.
*/
function nosecone(options) {
let contentSecurityPolicy = options?.contentSecurityPolicy ?? defaults.contentSecurityPolicy;
let crossOriginEmbedderPolicy = options?.crossOriginEmbedderPolicy ?? defaults.crossOriginEmbedderPolicy;
let crossOriginOpenerPolicy = options?.crossOriginOpenerPolicy ?? defaults.crossOriginOpenerPolicy;
let crossOriginResourcePolicy = options?.crossOriginResourcePolicy ?? defaults.crossOriginResourcePolicy;
const originAgentCluster = options?.originAgentCluster ?? defaults.originAgentCluster;
let referrerPolicy = options?.referrerPolicy ?? defaults.referrerPolicy;
let strictTransportSecurity = options?.strictTransportSecurity ?? defaults.strictTransportSecurity;
const xContentTypeOptions = options?.xContentTypeOptions ?? defaults.xContentTypeOptions;
let xDnsPrefetchControl = options?.xDnsPrefetchControl ?? defaults.xDnsPrefetchControl;
const xDownloadOptions = options?.xDownloadOptions ?? defaults.xDownloadOptions;
let xFrameOptions = options?.xFrameOptions ?? defaults.xFrameOptions;
let xPermittedCrossDomainPolicies = options?.xPermittedCrossDomainPolicies ?? defaults.xPermittedCrossDomainPolicies;
const xXssProtection = options?.xXssProtection ?? defaults.xXssProtection;
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 [*Using a Content Security Policy* on
* `vercel.com`](https://vercel.com/docs/vercel-toolbar/managing-toolbar#using-a-content-security-policy).
*
* @param config
* Base configuration for your 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;
return {
...config,
contentSecurityPolicy: augmentedContentSecurityPolicy,
crossOriginEmbedderPolicy: crossOriginEmbedderPolicy && crossOriginEmbedderPolicy.policy ? { policy: "unsafe-none" } : crossOriginEmbedderPolicy
};
}
//#endregion
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, nosecone, defaults, withVercelToolbar };