@trifrost/core
Version:
Blazingly fast, runtime-agnostic server framework for modern edge and node environments
344 lines (343 loc) • 15.6 kB
JavaScript
import { isNeArray } from '@valkyriestudios/utils/array';
import { isBoolean } from '@valkyriestudios/utils/boolean';
import { isIntGt } from '@valkyriestudios/utils/number';
import { isObject } from '@valkyriestudios/utils/object';
import { isNeString } from '@valkyriestudios/utils/string';
import { Sym_TriFrostDescription, Sym_TriFrostFingerPrint, Sym_TriFrostName } from '../types/constants';
import { hexId } from '../utils/Generic';
const RGX_NONCE = /'nonce'/g;
/* Specific symbol attached to security mware to identify them by */
export const Sym_TriFrostMiddlewareSecurity = Symbol('TriFrost.Middleware.Security');
export var ContentSecurityPolicy;
(function (ContentSecurityPolicy) {
ContentSecurityPolicy["DefaultSrc"] = "default-src";
ContentSecurityPolicy["ScriptSrc"] = "script-src";
ContentSecurityPolicy["StyleSrc"] = "style-src";
ContentSecurityPolicy["ImgSrc"] = "img-src";
ContentSecurityPolicy["ConnectSrc"] = "connect-src";
ContentSecurityPolicy["FontSrc"] = "font-src";
ContentSecurityPolicy["ObjectSrc"] = "object-src";
ContentSecurityPolicy["MediaSrc"] = "media-src";
ContentSecurityPolicy["FrameSrc"] = "frame-src";
ContentSecurityPolicy["BaseUri"] = "base-uri";
ContentSecurityPolicy["FormAction"] = "form-action";
ContentSecurityPolicy["FrameAncestors"] = "frame-ancestors";
ContentSecurityPolicy["PluginTypes"] = "plugin-types";
ContentSecurityPolicy["ReportUri"] = "report-uri";
})(ContentSecurityPolicy || (ContentSecurityPolicy = {}));
export var CrossOriginEmbedderPolicy;
(function (CrossOriginEmbedderPolicy) {
CrossOriginEmbedderPolicy["UnsafeNone"] = "unsafe-none";
CrossOriginEmbedderPolicy["RequireCorp"] = "require-corp";
CrossOriginEmbedderPolicy["Credentialless"] = "credentialless";
})(CrossOriginEmbedderPolicy || (CrossOriginEmbedderPolicy = {}));
export var CrossOriginOpenerPolicy;
(function (CrossOriginOpenerPolicy) {
CrossOriginOpenerPolicy["UnsafeNone"] = "unsafe-none";
CrossOriginOpenerPolicy["SameOriginAllowPopups"] = "same-origin-allow-popups";
CrossOriginOpenerPolicy["SameOrigin"] = "same-origin";
})(CrossOriginOpenerPolicy || (CrossOriginOpenerPolicy = {}));
export var CrossOriginResourcePolicy;
(function (CrossOriginResourcePolicy) {
CrossOriginResourcePolicy["SameSite"] = "same-site";
CrossOriginResourcePolicy["SameOrigin"] = "same-origin";
CrossOriginResourcePolicy["CrossOrigin"] = "cross-origin";
})(CrossOriginResourcePolicy || (CrossOriginResourcePolicy = {}));
export var ReferrerPolicy;
(function (ReferrerPolicy) {
ReferrerPolicy["NoReferrer"] = "no-referrer";
ReferrerPolicy["NoReferrerWhenDowngrade"] = "no-referrer-when-downgrade";
ReferrerPolicy["Origin"] = "origin";
ReferrerPolicy["OriginWhenCrossOrigin"] = "origin-when-cross-origin";
ReferrerPolicy["SameOrigin"] = "same-origin";
ReferrerPolicy["StrictOrigin"] = "strict-origin";
ReferrerPolicy["StrictOriginWhenCrossOrigin"] = "strict-origin-when-cross-origin";
ReferrerPolicy["UnsafeUrl"] = "unsafe-url";
})(ReferrerPolicy || (ReferrerPolicy = {}));
export var XContentTypes;
(function (XContentTypes) {
XContentTypes["NoSniff"] = "nosniff";
})(XContentTypes || (XContentTypes = {}));
export var XDnsPrefetchControl;
(function (XDnsPrefetchControl) {
XDnsPrefetchControl["On"] = "on";
XDnsPrefetchControl["Off"] = "off";
})(XDnsPrefetchControl || (XDnsPrefetchControl = {}));
export var XDownloadOptions;
(function (XDownloadOptions) {
XDownloadOptions["NoOpen"] = "noopen";
})(XDownloadOptions || (XDownloadOptions = {}));
export var XFrameOptions;
(function (XFrameOptions) {
XFrameOptions["Deny"] = "DENY";
XFrameOptions["SameOrigin"] = "SAMEORIGIN";
})(XFrameOptions || (XFrameOptions = {}));
/**
* Pre-baked CSP key lookup map
*/
const CSP_DIRECTIVES = new Set(Object.values(ContentSecurityPolicy));
/**
* Pre-baked Referrer Policy lookup map
*/
const REFERRERPOLICIES = new Set(Object.values(ReferrerPolicy));
/**
* Configures the provided security header
*
* @param {Record<string, string>} map - Map to store the header on
* @param {string} key - Header to configure
* @param {string|null} val - Header value to set
* @param {string[]} options - Array of possible options the value needs to fall into
* @param {string} name - Name of the option
*/
function header(map, key, val, options, name) {
/* If val is null it means we don't want to set the header */
if (val === undefined || val === null)
return;
/* Validation check */
if (!isNeString(val) || !isNeArray(options) || options.indexOf(val) < 0)
throw new Error(`TriFrostMiddleware@Security: Invalid configuration for ${name}`);
map[key] = val;
}
/**
* Content-Security-Policy header, this header is used to allow website administrators to control resources
* the user agent is allowed to load for a given page.
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy
*
* @param {Record<string, string>} map - Map to store the header on
* @param {TriFrostSecurityOptions['contentSecurityPolicy']} val - Value to set
*/
function contentSecurityPolicy(map, val) {
if (!isObject(val))
return;
/* Loop through each key in the csp object being passed */
const parts = [];
for (const key in val) {
if (!CSP_DIRECTIVES.has(key)) {
throw new Error(`TriFrostMiddleware@Security: Invalid directive "${key}" in contentSecurityPolicy`);
}
const chunk = val[key];
let finalized_chunk;
/**
* Special case for the base-uri directive as only one value is allowed
* Otherwise values can come in form of singular string "'self'" or string array ["'self'", "example.com"]
*/
if (key === ContentSecurityPolicy.BaseUri) {
if (!isNeString(chunk))
throw new Error('TriFrostMiddleware@Security: Invalid value for directive "base-uri"');
finalized_chunk = chunk.trim();
}
else if (isNeString(chunk)) {
finalized_chunk = chunk.trim();
}
else if (isNeArray(chunk)) {
const normalized = [];
const seen = new Set();
for (let i = 0; i < chunk.length; i++) {
let el = chunk[i];
if (!isNeString(el))
throw new Error(`TriFrostMiddleware@Security: Invalid value for directive "${key}" in contentSecurityPolicy`);
el = el.trim();
if (seen.has(el))
continue;
normalized.push(el);
seen.add(el);
}
finalized_chunk = normalized.join(' ');
}
else {
throw new Error(`TriFrostMiddleware@Security: Invalid value for directive "${key}"`);
}
if (isNeString(finalized_chunk))
parts.push(`${key} ${finalized_chunk.trim()}`);
}
if (parts.length)
map['content-security-policy'] = parts.join('; ');
}
/**
* Origin-Agent-Cluster header, this header is used to request that the associated Document should
* be placed in an origin-keyed agent cluster.
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Origin-Agent-Cluster
*
* @param {Record<string, string>} map - Map to store the header on
* @param {TriFrostSecurityOptions['originAgentCluster']} val - Value to set
*/
function originAgentCluster(map, val) {
switch (val) {
case true:
return (map['origin-agent-cluster'] = '?1');
case false:
return (map['origin-agent-cluster'] = '?0');
case null:
case undefined:
return;
default:
throw new Error('TriFrostMiddleware@Security: Invalid configuration for originAgentCluster');
}
}
/**
* Referrer-Policy header, this header controls how much referrer information should be included with requests
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Referrer-Policy
*
* @param {Record<string, string>} map - Map to store the header on
* @param {TriFrostSecurityOptions['referrerPolicy']} val - Value to set
*/
function referrerPolicy(map, val) {
if (val === undefined || val === null)
return;
const input = Array.isArray(val) ? val : typeof val === 'string' ? [val] : [];
if (!input.length)
throw new Error('TriFrostMiddleware@Security: Invalid configuration for referrerPolicy');
const seen = new Set();
const normalized = [];
for (let i = 0; i < input.length; i++) {
let el = input[i];
if (typeof el !== 'string') {
throw new Error('TriFrostMiddleware@Security: Invalid configuration for referrerPolicy');
}
el = el.trim();
if (!REFERRERPOLICIES.has(el)) {
throw new Error('TriFrostMiddleware@Security: Invalid configuration for referrerPolicy');
}
if (seen.has(el))
continue;
normalized.push(el);
seen.add(el);
}
map['referrer-policy'] = normalized.join(', ');
}
/**
* Strict-Transport-Security header, this header informs browsers that the site should only be accessed using HTTPS,
* and that any future attempts to access it using HTTP should automatically be converted to HTTPS.
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Strict-Transport-Security
*
* @param {Record<string, string>} map - Map to store the header on
* @param {TriFrostSecurityOptions['strictTransportSecurity']} val - Value to set
*/
function strictTransportSecurity(map, val) {
if (val === undefined || val === null)
return;
if (isIntGt(val?.maxAge, 0)) {
const parts = [`max-age=${val.maxAge}`];
/* includeSubDomains */
if (val.includeSubDomains === true)
parts.push('includeSubDomains');
/* preload (only allowed if max age is above 31536000 and subdomains is set) */
if (parts.length === 2 && val.preload === true && isIntGt(val.maxAge, 31536000))
parts.push('preload');
/* Only set header if we have parts */
return (map['strict-transport-security'] = parts.join('; '));
}
throw new Error('TriFrostMiddleware@Security: Invalid configuration for strictTransportSecurity');
}
/**
* Configures X-XSS-Protection header, by default set to false
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-XSS-Protection
*
* @param {Record<string, string>} map - Map to store the header on
* @param {TriFrostSecurityOptions['xXssProtection']} val - Value to set
*/
function xXssProtection(map, val) {
if (val === undefined || val === null)
return;
if (val === '0') {
return (map['x-xss-protection'] = '0');
}
else if (val === '1') {
return (map['x-xss-protection'] = '1');
}
else if (val === 'block') {
return (map['x-xss-protection'] = '1; mode=block');
}
else if (typeof val === 'string') {
const n_val = val.trim();
if (n_val.startsWith('/'))
return (map['x-xss-protection'] = '1; report=' + n_val);
}
throw new Error('TriFrostMiddleware@Security: Invalid configuration for xXssProtection');
}
/**
* Security defaults
*/
const SecurityDefaults = {
contentSecurityPolicy: null,
crossOriginEmbedderPolicy: null,
crossOriginOpenerPolicy: CrossOriginOpenerPolicy.SameOrigin,
crossOriginResourcePolicy: CrossOriginResourcePolicy.SameSite,
originAgentCluster: true,
referrerPolicy: ReferrerPolicy.NoReferrer,
strictTransportSecurity: { maxAge: 15552000, includeSubDomains: true },
xContentTypeOptions: XContentTypes.NoSniff,
xDnsPrefetchControl: XDnsPrefetchControl.Off,
xDownloadOptions: XDownloadOptions.NoOpen,
xFrameOptions: XFrameOptions.SameOrigin,
xXssProtection: '0',
};
/**
* Middleware that returns a handler which configures security headers on a context
*
* @param {TriFrostSecurityOptions} options - Options to apply
* @param {TriFrostSecurityConfig} config - Additional behavioral config
*/
export function Security(options = {}, config) {
const use_defaults = !isBoolean(config?.use_defaults) ? true : config.use_defaults;
const cfg = use_defaults === true ? { ...SecurityDefaults, ...(isObject(options) && options) } : isObject(options) ? options : {};
/* Generate configuration */
const map = {};
/* Content-Security-Policy */
contentSecurityPolicy(map, cfg.contentSecurityPolicy);
/**
* Cross-Origin Headers
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cross-Origin-Embedder-Policy
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cross-Origin-Opener-Policy
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cross-Origin-Resource-Policy
*/
header(map, 'cross-origin-embedder-policy', cfg.crossOriginEmbedderPolicy, Object.values(CrossOriginEmbedderPolicy), 'crossOriginEmbedderPolicy');
header(map, 'cross-origin-opener-policy', cfg.crossOriginOpenerPolicy, Object.values(CrossOriginOpenerPolicy), 'crossOriginOpenerPolicy');
header(map, 'cross-origin-resource-policy', cfg.crossOriginResourcePolicy, Object.values(CrossOriginResourcePolicy), 'crossOriginResourcePolicy');
/* Origin-Agent-Cluster */
originAgentCluster(map, cfg.originAgentCluster);
/* Referrer-Policy */
referrerPolicy(map, cfg.referrerPolicy);
/* Strict-Transport-Security */
strictTransportSecurity(map, cfg.strictTransportSecurity);
/**
* X-Content-Type-Options
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Content-Type-Options
*/
header(map, 'x-content-type-options', cfg.xContentTypeOptions, Object.values(XContentTypes), 'xContentTypeOptions');
/**
* X-DNS-Prefetch-Control
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-DNS-Prefetch-Control
*/
header(map, 'x-dns-prefetch-control', cfg.xDnsPrefetchControl, Object.values(XDnsPrefetchControl), 'xDnsPrefetchControl');
/**
* X-Download-Options
* @see https://docs.microsoft.com/en-us/archive/blogs/ie/ie8-security-part-v-comprehensive-protection
*/
header(map, 'x-download-options', cfg.xDownloadOptions, Object.values(XDownloadOptions), 'xDownloadOptions');
/**
* X-Frame-Options
* @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Frame-Options
*/
header(map, 'x-frame-options', cfg.xFrameOptions, Object.values(XFrameOptions), 'xFrameOptions');
/* X-XSS-Protection */
xXssProtection(map, cfg.xXssProtection);
/* Baseline Middleware function */
const mware = function TriFrostSecurityMiddleware(ctx) {
ctx.setHeaders(map);
/* Replace nonce placeholder with a generated nonce */
if ('content-security-policy' in map) {
const val = map['content-security-policy'];
if (RGX_NONCE.test(val)) {
const nonce = btoa(hexId(8));
ctx.setHeader('content-security-policy', val.replace(RGX_NONCE, "'nonce-" + nonce + "'"));
ctx.setState({ nonce });
}
}
};
/* Add symbols for introspection/use further down the line */
Reflect.set(mware, Sym_TriFrostName, 'TriFrostSecurity');
Reflect.set(mware, Sym_TriFrostDescription, 'Middleware for configuring Security headers and CSP on contexts passing through it');
Reflect.set(mware, Sym_TriFrostFingerPrint, Sym_TriFrostMiddlewareSecurity);
return mware;
}