UNPKG

@adonisjs/shield

Version:

A middleware for AdonisJS to keep web applications secure from common web attacks

267 lines (266 loc) 11.6 kB
import { n as E_BAD_CSRF_TOKEN, t as cspKeywords } from "./keywords-CY7Q7l2u.js"; import string from "@adonisjs/core/helpers/string"; import Tokens from "csrf"; import { debuglog } from "node:util"; function noop(_) {} const DEFAULT_MAX_AGE = 4320 * 60 * 60; function normalizeMaxAge(maxAge) { if (maxAge === null || maxAge === void 0) return DEFAULT_MAX_AGE; const maxAgeInSeconds = string.seconds.parse(maxAge); if (maxAgeInSeconds < 0) throw new Error("Max age for \"shield.hsts\" cannot be a negative value"); return maxAgeInSeconds; } function hstsFactory(options) { if (!options.enabled) return noop; let value = `max-age=${normalizeMaxAge(options.maxAge)}`; if (options.includeSubDomains) value += "; includeSubDomains"; if (options.preload) value += "; preload"; return function hsts({ response }) { response.header("Strict-Transport-Security", value); }; } var debug_default = debuglog("adonisjs:shield"); var CsrfGuard = class { #tokens = new Tokens(); #allowedMethods; #routesToIgnore; #secretSessionKey = "csrf-secret"; #options; #encryption; #edge; constructor(options, encryption, edge) { this.#options = options; this.#encryption = encryption; this.#edge = edge; this.#routesToIgnore = this.#options.exceptRoutes || []; this.#allowedMethods = (this.#options.methods || []).map((method) => method.toLowerCase()); } #shouldValidateRequest(ctx) { if (this.#allowedMethods.length && !this.#allowedMethods.includes(ctx.request.method().toLowerCase())) { debug_default("csrf: ignoring request for \"%s\" method", ctx.request.method()); return false; } if (typeof this.#routesToIgnore === "function") return !this.#routesToIgnore(ctx); if (this.#routesToIgnore.includes(ctx.route.pattern)) { debug_default("csrf: ignoring route \"%s\"", ctx.route.pattern); return false; } return true; } #getCsrfTokenFromRequest({ request }) { if (request.input("_csrf")) { debug_default("retrieved token from \"_csrf\" input"); return request.input("_csrf"); } if (request.header("x-csrf-token")) { debug_default("retrieved token from \"x-csrf-token\" header"); return request.header("x-csrf-token"); } if (!this.#options.enableXsrfCookie) return null; const encryptedToken = request.header("x-xsrf-token"); if (typeof encryptedToken !== "string" || !encryptedToken) return null; debug_default("retrieved token from \"x-xsrf-token\" header"); return this.#encryption.decrypt(decodeURIComponent(encryptedToken).slice(2), "XSRF-TOKEN"); } #shareCsrfViewLocals(ctx) { if (!ctx.view || !this.#edge) return; ctx.view.share({ csrfToken: ctx.request.csrfToken, csrfMeta: () => { return this.#edge.globals.html.safe(`<meta name='csrf-token' content='${ctx.request.csrfToken}'>`); }, csrfField: () => { return this.#edge.globals.html.safe(`<input type='hidden' name='_csrf' value='${ctx.request.csrfToken}'>`); } }); } #generateCsrfToken(csrfSecret) { return this.#tokens.create(csrfSecret); } async #getCsrfSecret(ctx) { let csrfSecret = ctx.session.get(this.#secretSessionKey); if (!csrfSecret) { debug_default("generating new CSRF secret"); csrfSecret = await this.#tokens.secret(); ctx.session.put(this.#secretSessionKey, csrfSecret); } return csrfSecret; } async handle(ctx) { const csrfSecret = await this.#getCsrfSecret(ctx); ctx.request.csrfToken = this.#generateCsrfToken(csrfSecret); if (this.#options.enableXsrfCookie) ctx.response.encryptedCookie("XSRF-TOKEN", ctx.request.csrfToken, { ...this.#options.cookieOptions, httpOnly: false }); this.#shareCsrfViewLocals(ctx); if (this.#shouldValidateRequest(ctx)) { const csrfToken = this.#getCsrfTokenFromRequest(ctx); if (!csrfToken || !this.#tokens.verify(csrfSecret, csrfToken)) throw new E_BAD_CSRF_TOKEN(); } } }; function csrfFactory(options, encryption, edge) { if (!options.enabled) return noop; const csrfGuard = new CsrfGuard(options, encryption, edge); return csrfGuard.handle.bind(csrfGuard); } const dangerouslyDisableDefaultSrc = Symbol("dangerouslyDisableDefaultSrc"); const SHOULD_BE_QUOTED = new Set([ "none", "self", "strict-dynamic", "report-sample", "inline-speculation-rules", "unsafe-inline", "unsafe-eval", "unsafe-hashes", "wasm-unsafe-eval" ]); const getDefaultDirectives = () => ({ "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 dashify = (str) => str.replace(/[A-Z]/g, (capitalLetter) => "-" + capitalLetter.toLowerCase()); const assertDirectiveValueIsValid = (directiveName, directiveValue) => { if (/;|,/.test(directiveValue)) throw new Error(`Content-Security-Policy received an invalid directive value for ${JSON.stringify(directiveName)}`); }; const assertDirectiveValueEntryIsValid = (directiveName, directiveValueEntry) => { if (SHOULD_BE_QUOTED.has(directiveValueEntry) || directiveValueEntry.startsWith("nonce-") || directiveValueEntry.startsWith("sha256-") || directiveValueEntry.startsWith("sha384-") || directiveValueEntry.startsWith("sha512-")) throw new Error(`Content-Security-Policy received an invalid directive value for ${JSON.stringify(directiveName)}. ${JSON.stringify(directiveValueEntry)} should be quoted`); }; function normalizeDirectives(options) { const defaultDirectives = getDefaultDirectives(); const { useDefaults = true, directives: rawDirectives = defaultDirectives } = options; const result = /* @__PURE__ */ new Map(); const directiveNamesSeen = /* @__PURE__ */ new Set(); const directivesExplicitlyDisabled = /* @__PURE__ */ new Set(); for (const rawDirectiveName in rawDirectives) { if (!Object.hasOwn(rawDirectives, rawDirectiveName)) continue; if (rawDirectiveName.length === 0 || /[^a-zA-Z0-9-]/.test(rawDirectiveName)) throw new Error(`Content-Security-Policy received an invalid directive name ${JSON.stringify(rawDirectiveName)}`); const directiveName = dashify(rawDirectiveName); if (directiveNamesSeen.has(directiveName)) throw new Error(`Content-Security-Policy received a duplicate directive ${JSON.stringify(directiveName)}`); directiveNamesSeen.add(directiveName); const rawDirectiveValue = rawDirectives[rawDirectiveName]; let directiveValue; if (rawDirectiveValue === null) { if (directiveName === "default-src") throw new Error("Content-Security-Policy needs a default-src but it was set to `null`. If you really want to disable it, set it to `contentSecurityPolicy.dangerouslyDisableDefaultSrc`."); directivesExplicitlyDisabled.add(directiveName); continue; } else if (typeof rawDirectiveValue === "string") directiveValue = [rawDirectiveValue]; else if (!rawDirectiveValue) throw new Error(`Content-Security-Policy received an invalid directive value for ${JSON.stringify(directiveName)}`); else if (rawDirectiveValue === dangerouslyDisableDefaultSrc) if (directiveName === "default-src") { directivesExplicitlyDisabled.add("default-src"); continue; } else throw new Error(`Content-Security-Policy: tried to disable ${JSON.stringify(directiveName)} as if it were default-src; simply omit the key`); else directiveValue = rawDirectiveValue; for (const element of directiveValue) { if (typeof element !== "string") continue; assertDirectiveValueIsValid(directiveName, element); assertDirectiveValueEntryIsValid(directiveName, element); } result.set(directiveName, directiveValue); } if (useDefaults) Object.entries(defaultDirectives).forEach(([defaultDirectiveName, defaultDirectiveValue]) => { if (!result.has(defaultDirectiveName) && !directivesExplicitlyDisabled.has(defaultDirectiveName)) result.set(defaultDirectiveName, defaultDirectiveValue); }); if (!result.size) throw new Error("Content-Security-Policy has no directives. Either set some or disable the header"); if (!result.has("default-src") && !directivesExplicitlyDisabled.has("default-src")) throw new Error("Content-Security-Policy needs a default-src but none was provided. If you really want to disable it, set it to `contentSecurityPolicy.dangerouslyDisableDefaultSrc`."); return result; } function getHeaderValue(req, res, normalizedDirectives) { const result = []; for (const [directiveName, rawDirectiveValue] of normalizedDirectives) { let directiveValue = ""; for (const element of rawDirectiveValue) if (typeof element === "function") { const newElement = element(req, res); assertDirectiveValueEntryIsValid(directiveName, newElement); directiveValue += " " + newElement; } else directiveValue += " " + element; if (directiveValue) { assertDirectiveValueIsValid(directiveName, directiveValue); result.push(`${directiveName}${directiveValue}`); } else result.push(directiveName); } return result.join(";"); } const contentSecurityPolicy = function contentSecurityPolicy(options = {}) { const headerName = options.reportOnly ? "Content-Security-Policy-Report-Only" : "Content-Security-Policy"; const normalizedDirectives = normalizeDirectives(options); return function contentSecurityPolicyMiddleware(req, res, next) { const result = getHeaderValue(req, res, normalizedDirectives); if (result instanceof Error) next(result); else { res.setHeader(headerName, result); next(); } }; }; contentSecurityPolicy.getDefaultDirectives = getDefaultDirectives; contentSecurityPolicy.dangerouslyDisableDefaultSrc = dangerouslyDisableDefaultSrc; cspKeywords.register("@nonce", function(_, response) { return `'nonce-${response.nonce}'`; }); function cspFactory(options) { if (!options.enabled) return noop; if (options.directives) Object.keys(options.directives).forEach((directive) => { options.directives[directive] = cspKeywords.resolve(options.directives[directive]); }); const helmetCspMiddleware = contentSecurityPolicy({ ...options, useDefaults: false }); return function csp(ctx) { return new Promise((resolve, reject) => { ctx.response.nonce = string.generateRandom(16); ctx.response.response.nonce = ctx.response.nonce; if ("view" in ctx) ctx.view.share({ cspNonce: ctx.response.nonce }); helmetCspMiddleware(ctx.response.request, ctx.response.response, (error) => { if (error) reject(error); else resolve(); }); }); }; } function noSniffFactory(options) { if (!options.enabled) return noop; return function noSniff({ response }) { response.header("X-Content-Type-Options", "nosniff"); }; } const ALLOWED_ACTIONS = [ "DENY", "ALLOW-FROM", "SAMEORIGIN" ]; function frameGuardFactory(options) { if (!options.enabled) return noop; const action = options.action || "SAMEORIGIN"; const resolvedOptions = { ...options, action }; if (!ALLOWED_ACTIONS.includes(resolvedOptions.action)) throw new Error("frameGuard: Action must be one of \"DENY\", \"ALLOW-FROM\" or \"SAMEORGIGIN\""); if (resolvedOptions.action === "ALLOW-FROM" && !resolvedOptions["domain"]) throw new Error("frameGuard: Domain value is required when using action as \"ALLOW-FROM\""); const result = resolvedOptions.action === "ALLOW-FROM" ? `${action} ${resolvedOptions["domain"]}` : action; return function frameGuard({ response }) { response.header("X-Frame-Options", result); }; } export { hstsFactory as a, csrfFactory as i, noSniffFactory as n, cspFactory as r, frameGuardFactory as t };