UNPKG

nosecone

Version:

Protect your Response with secure headers

504 lines (503 loc) 18.9 kB
//#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 };