@nestjs/core
Version:
Nest - modern, fast, powerful node.js web framework (@core)
343 lines (342 loc) • 13.6 kB
JavaScript
import { isObject, isPlainObject, isString } from '@nestjs/common/internal';
/**
* Default Content-Security-Policy directives, identical to helmet 8.
*/
export const DEFAULT_CONTENT_SECURITY_POLICY_DIRECTIVES = Object.freeze({
'default-src': ["'self'"],
'base-uri': ["'self'"],
'font-src': ["'self'", 'https:', 'data:'],
'form-action': ["'self'"],
'frame-ancestors': ["'self'"],
'img-src': ["'self'", 'data:'],
'object-src': ["'none'"],
'script-src': ["'self'"],
'script-src-attr': ["'none'"],
'style-src': ["'self'", 'https:', "'unsafe-inline'"],
'upgrade-insecure-requests': [],
});
const DEFAULT_HSTS_MAX_AGE = 365 * 24 * 60 * 60;
const COEP_POLICIES = ['require-corp', 'credentialless', 'unsafe-none'];
const COOP_POLICIES = [
'same-origin',
'same-origin-allow-popups',
'noopener-allow-popups',
'unsafe-none',
];
const CORP_POLICIES = ['same-origin', 'same-site', 'cross-origin'];
const REFERRER_POLICIES = [
'',
'no-referrer',
'no-referrer-when-downgrade',
'same-origin',
'origin',
'strict-origin',
'origin-when-cross-origin',
'strict-origin-when-cross-origin',
'unsafe-url',
];
const X_FRAME_OPTIONS_ACTIONS = ['deny', 'sameorigin'];
const X_PERMITTED_CROSS_DOMAIN_POLICIES = [
'none',
'master-only',
'by-content-type',
'all',
];
/**
* Option names of `SecurityHeadersOptions`, with the object keys each one
* accepts (an empty list for boolean-only options).
*/
const OPTION_KEYS = {
contentSecurityPolicy: ['directives', 'reportOnly', 'useDefaults'],
crossOriginEmbedderPolicy: ['policy'],
crossOriginOpenerPolicy: ['policy'],
crossOriginResourcePolicy: ['policy'],
originAgentCluster: [],
referrerPolicy: ['policy'],
strictTransportSecurity: ['includeSubDomains', 'maxAge', 'preload'],
xContentTypeOptions: [],
xDnsPrefetchControl: ['allow'],
xDownloadOptions: [],
xFrameOptions: ['action'],
xPermittedCrossDomainPolicies: ['permittedPolicies'],
xPoweredBy: [],
xXssProtection: [],
};
/**
* helmet's legacy option names, which are not supported, mapped to the
* current ones for a helpful error message.
*/
const HELMET_LEGACY_OPTION_NAMES = {
dnsPrefetchControl: 'xDnsPrefetchControl',
frameguard: 'xFrameOptions',
hidePoweredBy: 'xPoweredBy',
hsts: 'strictTransportSecurity',
ieNoOpen: 'xDownloadOptions',
noSniff: 'xContentTypeOptions',
permittedCrossDomainPolicies: 'xPermittedCrossDomainPolicies',
xssFilter: 'xXssProtection',
};
/**
* Source expressions that are only valid quoted. Unquoted, the browser reads
* them as host names, which silently weakens or breaks the policy.
*/
const MUST_BE_QUOTED = /^(?:self|none|strict-dynamic|report-sample|inline-speculation-rules|unsafe-inline|unsafe-eval|unsafe-hashes|wasm-unsafe-eval|unsafe-allow-redirects)$|^(?:nonce|sha256|sha384|sha512)-[A-Za-z0-9+/_=-]+$/i;
// Separators of the header grammar (`;` between directives, `,` between
// policies) must never appear in a source expression, nor may control
// characters (header injection) or non-ASCII characters (which Node.js
// rejects when the header is written, i.e. at request time).
const FORBIDDEN_VALUE_CHARACTERS = /[;,]|[^\x20-\x7e]/;
const DIRECTIVE_NAME = /^[a-zA-Z0-9-]+$/;
const NORMALIZED_DIRECTIVE_NAME = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/;
export class SecurityHeadersConfigurationError extends Error {
constructor(message) {
super(`Invalid security headers configuration: ${message}`);
this.name = 'SecurityHeadersConfigurationError';
}
}
const fail = (message) => {
throw new SecurityHeadersConfigurationError(message);
};
/**
* Serializes a Content-Security-Policy, validating directive names and
* values so that user input cannot inject extra directives or policies.
* Directive names may be camelCase (`scriptSrc`) or kebab-case.
*
* @returns the header value (directives separated by `;`, like helmet).
*/
export function serializeContentSecurityPolicy(options = {}) {
const { useDefaults = true, directives = {} } = options;
if (typeof useDefaults !== 'boolean') {
fail('contentSecurityPolicy.useDefaults must be a boolean.');
}
if (!isObject(directives) || Array.isArray(directives)) {
fail('contentSecurityPolicy.directives must be an object.');
}
const result = new Map();
if (useDefaults) {
for (const [name, value] of Object.entries(DEFAULT_CONTENT_SECURITY_POLICY_DIRECTIVES)) {
result.set(name, value);
}
}
const seen = new Set();
const removed = new Set();
for (const [rawName, rawValue] of Object.entries(directives)) {
const name = normalizeDirectiveName(rawName);
if (seen.has(name)) {
fail(`Content-Security-Policy directive "${name}" is set twice.`);
}
seen.add(name);
const values = normalizeDirectiveValue(name, rawValue);
if (values === null) {
removed.add(name);
result.delete(name);
}
else {
result.set(name, values);
}
}
if (!result.has('default-src') && !removed.has('default-src')) {
fail('Content-Security-Policy needs a "default-src" directive. Set ' +
'"defaultSrc: null" to omit it deliberately.');
}
if (result.size === 0) {
fail('Content-Security-Policy has no directives.');
}
return Array.from(result, ([name, values]) => values.length > 0 ? `${name} ${values.join(' ')}` : name).join(';');
}
function normalizeDirectiveName(rawName) {
const name = DIRECTIVE_NAME.test(rawName)
? rawName.replace(/[A-Z]/g, letter => `-${letter}`).toLowerCase()
: '';
if (!NORMALIZED_DIRECTIVE_NAME.test(name)) {
fail(`"${rawName}" is not a valid Content-Security-Policy directive name ` +
'(expected camelCase, e.g. "scriptSrc", or lower-case kebab-case, ' +
'e.g. "script-src").');
}
return name;
}
function normalizeDirectiveValue(name, value) {
if (value === null || value === false || value === undefined) {
return null;
}
if (value === true) {
return [];
}
const values = isString(value) ? [value] : value;
if (!Array.isArray(values)) {
return fail(`Content-Security-Policy directive "${name}" must be a string, a ` +
'list of strings, a boolean or null.');
}
return values.map(item => {
if (!isString(item) || item.trim().length === 0) {
return fail(`Content-Security-Policy directive "${name}" contains an empty or ` +
'non-string value.');
}
if (FORBIDDEN_VALUE_CHARACTERS.test(item)) {
return fail(`Content-Security-Policy directive "${name}" contains an invalid ` +
`value ${JSON.stringify(item)}: ";", "," and control or non-ASCII ` +
'characters are not allowed.');
}
if (item.split(/\s+/).some(token => MUST_BE_QUOTED.test(token))) {
return fail(`Content-Security-Policy directive "${name}" contains ` +
`${JSON.stringify(item)}: keywords, nonces and hashes must be ` +
`single-quoted (e.g. "'self'").`);
}
return item.trim();
});
}
/**
* Resolves `app.useSecurityHeaders()` options into the headers to set and
* remove on every response, validating them once, at startup: unknown
* options, values of the wrong type and invalid policies are rejected.
*
* Without options, the result matches helmet 8's defaults.
*/
export function resolveSecurityHeaders(options = {}) {
if (!isPlainObject(options)) {
fail('expected an options object.');
}
for (const name of Object.keys(options)) {
if (!Object.hasOwn(OPTION_KEYS, name)) {
const current = HELMET_LEGACY_OPTION_NAMES[name];
fail(`unknown option "${name}"` +
(current ? ` (helmet's legacy name, use "${current}").` : '.'));
}
}
const headers = [];
const add = (name, value) => headers.push([name, value]);
const csp = readOption(options, 'contentSecurityPolicy', true);
if (csp) {
add(readBoolean(csp, 'contentSecurityPolicy', 'reportOnly', false)
? 'Content-Security-Policy-Report-Only'
: 'Content-Security-Policy', serializeContentSecurityPolicy(csp));
}
const coep = readOption(options, 'crossOriginEmbedderPolicy', false);
if (coep) {
add('Cross-Origin-Embedder-Policy', pick('crossOriginEmbedderPolicy.policy', coep.policy, COEP_POLICIES, 'require-corp'));
}
const coop = readOption(options, 'crossOriginOpenerPolicy', true);
if (coop) {
add('Cross-Origin-Opener-Policy', pick('crossOriginOpenerPolicy.policy', coop.policy, COOP_POLICIES, 'same-origin'));
}
const corp = readOption(options, 'crossOriginResourcePolicy', true);
if (corp) {
add('Cross-Origin-Resource-Policy', pick('crossOriginResourcePolicy.policy', corp.policy, CORP_POLICIES, 'same-origin'));
}
if (readOption(options, 'originAgentCluster', true)) {
add('Origin-Agent-Cluster', '?1');
}
const referrer = readOption(options, 'referrerPolicy', true);
if (referrer) {
add('Referrer-Policy', resolveReferrerPolicy(referrer.policy));
}
const hsts = readOption(options, 'strictTransportSecurity', true);
if (hsts) {
add('Strict-Transport-Security', resolveHsts(hsts));
}
if (readOption(options, 'xContentTypeOptions', true)) {
add('X-Content-Type-Options', 'nosniff');
}
const dnsPrefetch = readOption(options, 'xDnsPrefetchControl', true);
if (dnsPrefetch) {
const allow = readBoolean(dnsPrefetch, 'xDnsPrefetchControl', 'allow');
add('X-DNS-Prefetch-Control', allow ? 'on' : 'off');
}
if (readOption(options, 'xDownloadOptions', true)) {
add('X-Download-Options', 'noopen');
}
const frameOptions = readOption(options, 'xFrameOptions', true);
if (frameOptions) {
const { action } = frameOptions;
add('X-Frame-Options', pick('xFrameOptions.action', isString(action) ? action.toLowerCase() : action, X_FRAME_OPTIONS_ACTIONS, 'sameorigin').toUpperCase());
}
const crossDomain = readOption(options, 'xPermittedCrossDomainPolicies', true);
if (crossDomain) {
add('X-Permitted-Cross-Domain-Policies', pick('xPermittedCrossDomainPolicies.permittedPolicies', crossDomain.permittedPolicies, X_PERMITTED_CROSS_DOMAIN_POLICIES, 'none'));
}
if (readOption(options, 'xXssProtection', true)) {
add('X-XSS-Protection', '0');
}
const removeHeaders = readOption(options, 'xPoweredBy', true)
? ['X-Powered-By']
: [];
return { headers, removeHeaders };
}
/**
* Reads one header option: `undefined` when the header is left out, and its
* options object (`{}` for `true` and for an enabled default) otherwise.
*/
function readOption(options, name, enabledByDefault) {
const value = options[name];
if (value === undefined) {
return enabledByDefault ? {} : undefined;
}
if (typeof value === 'boolean') {
return value ? {} : undefined;
}
const keys = OPTION_KEYS[name];
if (keys.length === 0 || !isPlainObject(value)) {
return fail(`${name} must be a boolean${keys.length > 0 ? ' or an object' : ''}, ` +
`received ${JSON.stringify(value)}.`);
}
for (const key of Object.keys(value)) {
if (!keys.includes(key)) {
fail(`unknown option "${name}.${key}" (expected ` +
`${keys.map(k => `"${k}"`).join(', ')}).`);
}
}
return value;
}
function readBoolean(object, option, key, fallback = false) {
const value = object[key];
if (value === undefined) {
return fallback;
}
if (typeof value !== 'boolean') {
fail(`${option}.${key} must be a boolean, received ${JSON.stringify(value)}.`);
}
return value;
}
function pick(option, value, allowed, fallback) {
if (value === undefined) {
return fallback;
}
if (!isString(value) || !allowed.includes(value)) {
fail(`${option} must be one of ${allowed.map(v => `"${v}"`).join(', ')}, ` +
`received ${JSON.stringify(value)}.`);
}
return value;
}
function resolveReferrerPolicy(policy) {
if (policy === undefined) {
return 'no-referrer';
}
const tokens = isString(policy) ? [policy] : policy;
if (!Array.isArray(tokens) || tokens.length === 0) {
fail('referrerPolicy.policy must be a token or a non-empty list.');
}
const unique = new Set();
for (const token of tokens) {
pick('referrerPolicy.policy', token, REFERRER_POLICIES, 'no-referrer');
if (unique.has(token)) {
fail(`referrerPolicy.policy contains "${token}" more than once.`);
}
unique.add(token);
}
return tokens.join(',');
}
function resolveHsts(option) {
const maxAge = option.maxAge ?? DEFAULT_HSTS_MAX_AGE;
if (typeof maxAge !== 'number' || !Number.isFinite(maxAge) || maxAge < 0) {
fail('strictTransportSecurity.maxAge must be a non-negative number of ' +
`seconds, received ${JSON.stringify(maxAge)}.`);
}
let value = `max-age=${Math.floor(maxAge)}`;
if (readBoolean(option, 'strictTransportSecurity', 'includeSubDomains', true)) {
value += '; includeSubDomains';
}
if (readBoolean(option, 'strictTransportSecurity', 'preload')) {
value += '; preload';
}
return value;
}