@adonisjs/shield
Version:
A middleware for AdonisJS to keep web applications secure from common web attacks
267 lines (266 loc) • 11.6 kB
JavaScript
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 };