@mangadex-pub/nuxt-security
Version:
🛡️ Security Module for Nuxt based on HTTP Headers and Middleware
339 lines (332 loc) • 10.6 kB
JavaScript
import { fileURLToPath } from 'node:url';
import { normalize, resolve } from 'pathe';
import { defineNuxtModule, addVitePlugin, addServerHandler, installModule } from '@nuxt/kit';
import { createDefu, defu } from 'defu';
import viteRemove from 'unplugin-remove/vite';
const defuReplaceArray = createDefu((obj, key, value) => {
if (Array.isArray(obj[key]) || Array.isArray(value)) {
obj[key] = value;
return true;
}
});
const defaultThrowErrorValue = { throwError: true };
const defaultSecurityConfig = (serverlUrl) => ({
headers: {
crossOriginResourcePolicy: "same-origin",
crossOriginOpenerPolicy: "same-origin",
crossOriginEmbedderPolicy: "require-corp",
contentSecurityPolicy: {
"base-uri": ["'self'"],
"font-src": ["'self'", "https:", "data:"],
"form-action": ["'self'"],
"frame-ancestors": ["'self'"],
"img-src": ["'self'", "data:"],
"object-src": ["'none'"],
"script-src-attr": ["'none'"],
"style-src": ["'self'", "https:", "'unsafe-inline'"],
"upgrade-insecure-requests": true
},
originAgentCluster: "?1",
referrerPolicy: "no-referrer",
strictTransportSecurity: {
maxAge: 15552e3,
includeSubdomains: true
},
xContentTypeOptions: "nosniff",
xDNSPrefetchControl: "off",
xDownloadOptions: "noopen",
xFrameOptions: "SAMEORIGIN",
xPermittedCrossDomainPolicies: "none",
xXSSProtection: "0",
permissionsPolicy: {
camera: [],
"display-capture": [],
fullscreen: [],
geolocation: [],
microphone: []
}
},
requestSizeLimiter: {
maxRequestSizeInBytes: 2e6,
maxUploadFileRequestInBytes: 8e6,
...defaultThrowErrorValue
},
rateLimiter: {
// Twitter search rate limiting
tokensPerInterval: 150,
interval: 3e5,
headers: false,
driver: {
name: "lruCache"
},
...defaultThrowErrorValue
},
xssValidator: {
...defaultThrowErrorValue
},
corsHandler: {
// Options by CORS middleware for Express https://github.com/expressjs/cors#configuration-options
origin: serverlUrl,
methods: ["GET", "HEAD", "PUT", "PATCH", "POST", "DELETE"],
preflight: {
statusCode: 204
}
},
allowedMethodsRestricter: {
methods: "*",
...defaultThrowErrorValue
},
hidePoweredBy: true,
basicAuth: false,
enabled: true,
csrf: false,
nonce: false,
// https://github.com/Talljack/unplugin-remove/blob/main/src/types.ts
removeLoggers: {
external: [],
consoleType: ["log", "debug"],
include: [/\.[jt]sx?$/, /\.vue\??/],
exclude: [/node_modules/, /\.git/]
},
ssg: {
hashScripts: true
}
});
const SECURITY_MIDDLEWARE_NAMES = {
requestSizeLimiter: "requestSizeLimiter",
rateLimiter: "rateLimiter",
xssValidator: "xssValidator",
corsHandler: "corsHandler",
allowedMethodsRestricter: "allowedMethodsRestricter",
basicAuth: "basicAuth",
csrf: "csrf",
nonce: "nonce"
};
const SECURITY_HEADER_NAMES = {
contentSecurityPolicy: "Content-Security-Policy",
crossOriginEmbedderPolicy: "Cross-Origin-Embedder-Policy",
crossOriginOpenerPolicy: "Cross-Origin-Opener-Policy",
crossOriginResourcePolicy: "Cross-Origin-Resource-Policy",
originAgentCluster: "Origin-Agent-Cluster",
referrerPolicy: "Referrer-Policy",
strictTransportSecurity: "Strict-Transport-Security",
xContentTypeOptions: "X-Content-Type-Options",
xDNSPrefetchControl: "X-DNS-Prefetch-Control",
xDownloadOptions: "X-Download-Options",
xFrameOptions: "X-Frame-Options",
xPermittedCrossDomainPolicies: "X-Permitted-Cross-Domain-Policies",
xXSSProtection: "X-XSS-Protection",
permissionsPolicy: "Permissions-Policy"
};
const headerValueMappers = {
strictTransportSecurity: (value) => [
`max-age=${value.maxAge}`,
value.includeSubdomains && "includeSubDomains",
value.preload && "preload"
].filter(Boolean).join("; "),
contentSecurityPolicy: (value) => {
return Object.entries(value).map(([directive, sources]) => {
if (directive === "upgrade-insecure-requests") {
return sources ? "upgrade-insecure-requests" : "";
}
return sources?.length && `${directive} ${sources.join(" ")}`;
}).filter(Boolean).join("; ");
},
permissionsPolicy: (value) => Object.entries(value).map(([directive, sources]) => `${directive}=(${sources.join(" ")})`).filter(Boolean).join(", ")
};
const getHeaderValueFromOptions = (headerType, headerOptions) => {
if (typeof headerOptions === "string") {
return headerOptions;
}
return headerValueMappers[headerType]?.(headerOptions) ?? headerOptions;
};
const module = defineNuxtModule({
meta: {
name: "nuxt-security",
configKey: "security"
},
async setup(options, nuxt) {
const runtimeDir = fileURLToPath(new URL("./runtime", import.meta.url));
nuxt.options.build.transpile.push(runtimeDir);
nuxt.options.security = defuReplaceArray(
{ ...options, ...nuxt.options.security },
{
...defaultSecurityConfig(nuxt.options.devServer.url)
}
);
const securityOptions = nuxt.options.security;
if (!securityOptions.enabled) {
return;
}
if (securityOptions.removeLoggers) {
addVitePlugin(viteRemove(securityOptions.removeLoggers));
}
registerSecurityNitroPlugins(nuxt, securityOptions);
nuxt.options.runtimeConfig.private = defu(
nuxt.options.runtimeConfig.private,
{
basicAuth: securityOptions.basicAuth
}
);
delete securityOptions.basicAuth;
nuxt.options.runtimeConfig.security = defu(
nuxt.options.runtimeConfig.security,
{
...securityOptions
}
);
if (securityOptions.headers) {
setSecurityResponseHeaders(nuxt, securityOptions.headers);
}
setSecurityRouteRules(nuxt, securityOptions);
if (nuxt.options.security.requestSizeLimiter) {
addServerHandler({
handler: normalize(
resolve(runtimeDir, "server/middleware/requestSizeLimiter")
)
});
}
if (nuxt.options.security.rateLimiter) {
addServerHandler({
handler: normalize(
resolve(runtimeDir, "server/middleware/rateLimiter")
)
});
}
if (nuxt.options.security.xssValidator) {
addServerHandler({
handler: normalize(
resolve(runtimeDir, "server/middleware/xssValidator")
)
});
}
if (nuxt.options.security.corsHandler) {
addServerHandler({
handler: normalize(
resolve(runtimeDir, "server/middleware/corsHandler")
)
});
}
if (nuxt.options.security.nonce) {
addServerHandler({
handler: normalize(
resolve(runtimeDir, "server/middleware/cspNonceHandler")
)
});
}
const allowedMethodsRestricterConfig = nuxt.options.security.allowedMethodsRestricter;
if (allowedMethodsRestricterConfig && !Object.values(allowedMethodsRestricterConfig).includes("*")) {
addServerHandler({
handler: normalize(
resolve(runtimeDir, "server/middleware/allowedMethodsRestricter")
)
});
}
const basicAuthConfig = nuxt.options.runtimeConfig.private.basicAuth;
if (basicAuthConfig && (basicAuthConfig?.enabled || basicAuthConfig?.value?.enabled)) {
addServerHandler({
route: basicAuthConfig.route || "",
handler: normalize(resolve(runtimeDir, "server/middleware/basicAuth"))
});
}
nuxt.hook("imports:dirs", (dirs) => {
dirs.push(normalize(resolve(runtimeDir, "composables")));
});
const csrfConfig = nuxt.options.security.csrf;
if (csrfConfig) {
if (Object.keys(csrfConfig).length) {
await installModule("nuxt-csurf", csrfConfig);
}
await installModule("nuxt-csurf");
}
}
});
const setSecurityResponseHeaders = (nuxt, headers) => {
for (const header in headers) {
if (headers[header]) {
const nitroRouteRules = nuxt.options.nitro.routeRules;
const headerOptions = headers[header];
const headerRoute = headerOptions.route || "/**";
nitroRouteRules[headerRoute] = {
...nitroRouteRules[headerRoute],
headers: {
...nitroRouteRules[headerRoute]?.headers,
[SECURITY_HEADER_NAMES[header]]: getHeaderValueFromOptions(header, headerOptions)
}
};
}
}
};
const setSecurityRouteRules = (nuxt, securityOptions) => {
const nitroRouteRules = nuxt.options.nitro.routeRules;
const { headers, enabled, hidePoweredBy, ...rest } = securityOptions;
for (const middleware in rest) {
const middlewareConfig = securityOptions[middleware];
if (typeof middlewareConfig !== "boolean") {
const middlewareRoute = "/**";
nitroRouteRules[middlewareRoute] = {
...nitroRouteRules[middlewareRoute],
security: {
...nitroRouteRules[middlewareRoute]?.security,
[SECURITY_MIDDLEWARE_NAMES[middleware]]: {
...middlewareConfig,
throwError: middlewareConfig.throwError
}
}
};
}
}
};
const registerSecurityNitroPlugins = (nuxt, securityOptions) => {
nuxt.hook("nitro:config", (config) => {
config.plugins = config.plugins || [];
if (securityOptions.rateLimiter) {
const driver = securityOptions.rateLimiter.driver;
if (driver) {
const { name, options } = driver;
config.storage = defu(
config.storage,
{
"#storage-driver": {
driver: name,
options
}
}
);
}
}
if (securityOptions.hidePoweredBy) {
config.externals = config.externals || {};
config.externals.inline = config.externals.inline || [];
config.externals.inline.push(
normalize(fileURLToPath(new URL("./runtime", import.meta.url)))
);
config.plugins.push(
normalize(
fileURLToPath(
new URL("./runtime/nitro/plugins/01-hidePoweredBy", import.meta.url)
)
)
);
}
if (typeof securityOptions.headers === "object" && securityOptions.headers.contentSecurityPolicy) {
config.plugins.push(
normalize(
fileURLToPath(
new URL("./runtime/nitro/plugins/02-cspSsg", import.meta.url)
)
)
);
}
if (nuxt.options.security.nonce) {
config.plugins.push(
normalize(
fileURLToPath(
new URL("./runtime/nitro/plugins/99-cspNonce", import.meta.url)
)
)
);
}
});
};
export { module as default };