UNPKG

rollup-plugin-htaccess

Version:
629 lines 22.5 kB
import {findAll}from'domutils';import {glob}from'glob';import {parseDocument,ElementType}from'htmlparser2';import {join}from'path';import {readFile as readFile$1,writeFile as writeFile$1}from'fs';function buildAddOutputFilterByType(spec) { const output = []; for (const directive of spec) { output.push(`AddOutputFilterByType ${directive.filters.join(";")} ${directive.mediaTypes.join(" ")}`); } return output.join("\n"); }function buildDirectorySlash(spec) { return `DirectorySlash ${spec.value ? "On" : "Off"}\n`; }function buildErrorDocument(spec) { const output = []; for (const errorCode in spec) { if (!Object.prototype.hasOwnProperty.call(spec, errorCode)) { continue; } let doc = spec[errorCode]; if (doc.includes(" ")) { doc = `"${doc}"`; } output.push(`ErrorDocument ${errorCode} ${doc}`); } return output.join("\n"); }function buildFiles(context, spec) { if (spec.fileName.includes("/")) { context.error("The <Files> directive cannot contain files in subfolders. Use a separate .htaccess file or the <If> directive."); } return `<Files "${escapeValue(spec.fileName)}">\n${buildInnerSpec(context, spec.innerSpec)}\n</Files>`; }function buildFilesMatch(context, spec) { return `<FilesMatch "${escapeValue(spec.regex)}">\n${buildInnerSpec(context, spec.innerSpec)}\n</FilesMatch>`; }function buildContentSecurityPolicyValue(spec) { const parts = []; for (const directive in spec) { if (!Object.prototype.hasOwnProperty.call(spec, directive)) { continue; } parts.push(buildPart(directive, spec[directive])); } return parts.join("; "); } function buildPart(directive, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Needed to correctly infer value type valueSpec) { // eslint-disable-next-line @typescript-eslint/switch-exhaustiveness-check -- Default is correct here switch (directive) { case "report-to": return `report-to ${valueSpec}`; case "report-uri": return ["report-uri", ...valueSpec].join(" "); case "require-trusted-types-for": return "require-trusted-types-for 'script'"; case "sandbox": return buildSandboxPart(valueSpec); case "trusted-types": return buildTrustedTypesPart(valueSpec); case "upgrade-insecure-requests": return "upgrade-insecure-requests"; default: return buildSourcePart(directive, valueSpec); } } function buildSandboxPart(valueSpec) { if (valueSpec !== null) { return `sandbox ${valueSpec}`; } return "sandbox"; } function buildSourcePart(directive, sourceSpec) { const sources = []; for (const source of [ "self", "unsafe-eval", "wasm-unsafe-eval", "unsafe-hashes", "unsafe-inline", "strict-dynamic", "report-sample", "inline-speculation-rules", ]) { if (sourceSpec[source] === true) { sources.push(`'${source}'`); } } if (sourceSpec.schemes !== undefined) { for (const scheme of [ "data", "mediastream", "blob", "filesystem", ]) { if (sourceSpec.schemes[scheme] === true) { sources.push(`${scheme}:`); } } } if (sourceSpec.nonces !== undefined) { sources.push(...sourceSpec.nonces.map((nonce) => `'nonce-${nonce}'`)); } if (sourceSpec.hashes !== undefined) { for (const algo of ["sha256", "sha384", "sha512"]) { const hashes = sourceSpec.hashes[algo]; if (hashes !== undefined) { sources.push(...hashes.map((hash) => `'${algo}-${hash}'`)); } } } if (sourceSpec.hosts !== undefined) { sources.push(...sourceSpec.hosts.map(escapeValue)); } if (sources.length === 0) { sources.push("'none'"); } return `${directive} ${sources.join(" ")}`; } function buildTrustedTypesPart(valueSpec) { const parts = ["trusted-types", ...(valueSpec.policies ?? [])]; if (valueSpec["allow-duplicates"] === true) { parts.push("'allow-duplicates'"); } return parts.join(" "); }function buildPermissionsPolicyValue(spec) { const parts = []; for (const key in spec) { if (!Object.prototype.hasOwnProperty.call(spec, key)) { continue; } const allowlistSpec = spec[key]; if (allowlistSpec === undefined) { continue; } parts.push(`${key}=${buildAllowlist(allowlistSpec)}`); } return parts.join(", "); } function buildAllowlist(allowlist) { if (allowlist === "*") { return "*"; } const list = allowlist.origins?.map((origin) => `\\"${escapeValue(origin)}\\"`) ?? []; if (allowlist.src === true) { list.unshift("src"); } if (allowlist.self === true) { list.unshift("self"); } return `(${list.join(" ")})`; }function buildReferrerPolicyValue(spec) { return spec; }function buildStrictTransportSecurityValue(context, spec) { if (spec.preload === true) { if (spec.maxAge < 31536000) { context.error("The strict transport security header with preloading requires max age >= 31536000."); } if (spec.includeSubDomains !== true) { context.error("The strict transport security header with preloading requires subdomains to be included."); } } return `max-age=${spec.maxAge.toString()}${spec.includeSubDomains === true ? "; includeSubDomains" : ""}${spec.preload === true ? "; preload" : ""}`; }function buildXContentTypeOptionsValue() { return "nosniff"; }// eslint-disable-next-line @typescript-eslint/no-deprecated -- Internal deprecation function buildXFrameOptionsValue(spec) { return spec === "sameorigin" ? "SAMEORIGIN" : "DENY"; }// eslint-disable-next-line @typescript-eslint/no-deprecated -- Internal deprecation function buildXXssProtectionValue(spec) { switch (spec.mode) { case "block": return "1; mode=block"; case "sanitize": if (spec.reportUri !== undefined) { return `1; report=${escapeValue(spec.reportUri)}`; } return "1"; case "disabled": default: return "0"; } }/* eslint-enable */ function buildHeader(context, spec) { const parts = ["Header"]; if (spec.always === true) { parts.push("always"); } parts.push(spec.action, spec.header); if (["add", "append", "merge", "set", "setifempty"].includes(spec.action)) { parts.push(`"${buildHeaderValue(context, spec.header, spec.value)}"`); } else if (["edit", "edit*"].includes(spec.action)) { parts.push(`"${escapeValue(spec.value)}"`); parts.push(`"${escapeValue(spec.replacement)}"`); } if (spec.condition !== undefined) { if ("envVar" in spec.condition) { parts.push(`env=${spec.condition.requireUnset === true ? "!" : ""}${spec.condition.envVar}`); } else if ("expression" in spec.condition) { parts.push(`"expr=${escapeValue(spec.condition.expression)}"`); } } return parts.join(" "); } // eslint-disable-next-line consistent-return -- Clashes with typescript unreachable code check function buildHeaderValue(context, header, // eslint-disable-next-line @typescript-eslint/no-explicit-any -- Needed to correctly infer value type value) { switch (header) { case "Content-Security-Policy": return buildContentSecurityPolicyValue(value); case "Permissions-Policy": return buildPermissionsPolicyValue(value); case "Referrer-Policy": return buildReferrerPolicyValue(value); case "Strict-Transport-Security": return buildStrictTransportSecurityValue(context, value); case "X-Content-Type-Options": return buildXContentTypeOptionsValue(); case "X-Frame-Options": return buildXFrameOptionsValue(value); case "X-Xss-Protection": return buildXXssProtectionValue(value); default: context.error(`Unknown header type "${header}".`); } }function buildIf(context, spec) { let output = `<If "${escapeValue(spec.condition)}">\n${buildInnerSpec(context, spec.innerSpec)}\n</If>`; for (const elseIfSpec of spec.ElseIf ?? []) { output += `\n<ElseIf "${escapeValue(elseIfSpec.condition)}">\n${buildInnerSpec(context, elseIfSpec.innerSpec)}\n</ElseIf>`; } if (spec.Else !== undefined) { output += `\n<Else>\n${buildInnerSpec(context, spec.Else.innerSpec)}\n</Else>`; } return output; }function buildIfDefine(context, spec) { return `<IfDefine ${spec.inverted === true ? "!" : ""}${spec.parameterName}>\n${buildInnerSpec(context, spec.innerSpec)}\n</IfDefine>`; }function buildIfDirective(context, spec) { return `<IfDirective ${spec.inverted === true ? "!" : ""}${spec.directiveName}>\n${buildInnerSpec(context, spec.innerSpec)}\n</IfDirective>`; }function buildIfFile(context, spec) { return `<IfFile ${spec.inverted === true ? "!" : ""}${spec.fileName}>\n${buildInnerSpec(context, spec.innerSpec)}\n</IfFile>`; }function buildIfModule(context, spec) { return `<IfModule ${spec.inverted === true ? "!" : ""}${spec.moduleNameOrIdentifier}>\n${buildInnerSpec(context, spec.innerSpec)}\n</IfModule>`; }function buildIfSection(context, spec) { return `<IfSection ${spec.inverted === true ? "!" : ""}${spec.sectionName}>\n${buildInnerSpec(context, spec.innerSpec)}\n</IfSection>`; }function buildOptions(spec) { if (spec === "None") { return "Options None"; } const output = ["Options"]; if ("set" in spec) { output.push(spec.set.join(" ")); } if ("plus" in spec && spec.plus !== undefined) { output.push(spec.plus.map((option) => `+${option}`).join(" ")); } if ("minus" in spec && spec.minus !== undefined) { output.push(spec.minus.map((option) => `-${option}`).join(" ")); } return output.join(" "); }function buildRewrite(spec) { const output = []; if (spec.base !== undefined) { output.push(`RewriteBase "${escapeValue(spec.base)}"`); } if (spec.options !== undefined) { output.push(...buildRewriteOptions(spec.options)); } if (spec.rules !== undefined) { output.push(...buildRewriteRules(spec.rules)); } if (output.length > 0) { output.unshift("RewriteEngine on"); } return output.join("\n"); } function buildRewriteCondition(spec) { const flags = []; if (spec.flags?.nocase === true) { flags.push("NC"); } if (spec.flags?.ornext === true) { flags.push("OR"); } if (spec.flags?.novary === true) { flags.push("NV"); } return `RewriteCond "${spec.testString}" "${spec.conditionPattern}"${flags.length > 0 ? ` [${flags.join(",")}]` : ""}`; } function buildRewriteOptions(spec) { const output = []; for (const option in spec) { if (spec[option] === true) { output.push(`RewriteOptions ${option}`); } } return output; } function buildRewriteRuleCookieFlag(spec) { const output = [spec.name, spec.value, spec.domain]; if (spec.lifetime !== undefined) { output.push(spec.lifetime.toString()); } if ("path" in spec && spec.path !== undefined) { output.push(spec.path); } if ("secure" in spec && spec.secure === true) { output.push("true"); } if ("httponly" in spec && spec.httponly === true) { output.push("true"); } if ("samesite" in spec && spec.samesite !== undefined) { output.push(spec.samesite); } const containsColon = output.some((field) => field.includes(":")); return `CO=${containsColon ? ";" : ""}${output.join(containsColon ? ";" : ":")}`; } function buildRewriteRuleFlags(flags) { const output = []; let requireQuotes = false; if (flags.B === true) { output.push("B"); } if (typeof flags.B === "string") { output.push(`B=${flags.B}`); if (flags.B.includes(" ")) { requireQuotes = true; } } if (flags.backrefnoplus === true) { output.push("BNP"); } if (flags.BCTLS === true) { output.push("BCTLS"); } if (flags.BNE !== undefined) { output.push(`BNE=${flags.BNE}`); if (flags.BNE.includes(" ")) { requireQuotes = true; } } if (flags.chain === true) { output.push("C"); } if (flags.cookie !== undefined) { output.push(buildRewriteRuleCookieFlag(flags.cookie)); } if (flags.discardpath === true) { output.push("DPI"); } if (flags.env !== undefined) { let envValue = flags.env.variable; if (flags.env.value === null) { envValue = `!${envValue}`; } else if (flags.env.value !== "") { envValue += `:${flags.env.value}`; } output.push(`E=${envValue}`); } if (flags.END === true) { output.push("END"); } if (flags.forbidden === true) { output.push("F"); } if (flags.gone === true) { output.push("G"); } if (flags.handler !== undefined) { output.push(`H=${flags.handler}`); } if (flags.last === true) { output.push("L"); } if (flags.next === true) { output.push("N"); } if (flags.nocase === true) { output.push("NC"); } if (flags.noescape === true) { output.push("NE"); } if (flags.nosubreq === true) { output.push("NS"); } if (flags.proxy === true) { output.push("P"); } if (flags.qsappend === true) { output.push("QSA"); } if (flags.qsdiscard === true) { output.push("QSD"); } if (flags.qslast === true) { output.push("QSL"); } if (flags.redirect !== undefined) { output.push(`R=${flags.redirect.toString()}`); } if (flags.skip !== undefined) { output.push(`S=${flags.skip.toString()}`); } if (flags.type !== undefined) { output.push(`T=${flags.type}`); } if (flags.UnsafeAllow3F === true) { output.push("UnsafeAllow3F"); } if (flags.UnsafePrefixStat === true) { output.push("UnsafePrefixStat"); } if (output.length === 0) { return ""; } const quote = requireQuotes ? '"' : ""; return ` ${quote}[${output.join(",")}]${quote}`; } function buildRewriteRules(spec) { const output = []; for (const rule of spec) { for (const condition of rule.conditions ?? []) { output.push(buildRewriteCondition(condition)); } const flags = rule.flags !== undefined ? buildRewriteRuleFlags(rule.flags) : ""; output.push(`RewriteRule "${escapeValue(rule.pattern)}" "${escapeValue(rule.substitution ?? "-")}"${flags}`); } return output; }function buildSpec(context, spec) { let output = ""; if (spec.AddOutputFilterByType !== undefined) { output += `${buildAddOutputFilterByType(spec.AddOutputFilterByType)}\n`; } if (spec.DirectorySlash !== undefined) { output += `${buildDirectorySlash(spec.DirectorySlash)}\n`; } if (spec.ErrorDocument !== undefined) { output += `${buildErrorDocument(spec.ErrorDocument)}\n`; } if (spec.Options !== undefined) { output += `${buildOptions(spec.Options)}\n`; } for (const header of spec.Header ?? []) { output += `${buildHeader(context, header)}\n`; } if (spec.rewrite !== undefined) { output += `${buildRewrite(spec.rewrite)}\n`; } for (const files of spec.Files ?? []) { output += `${buildFiles(context, files)}\n`; } for (const filesMatch of spec.FilesMatch ?? []) { output += `${buildFilesMatch(context, filesMatch)}\n`; } for (const ifSpec of spec.If ?? []) { output += `${buildIf(context, ifSpec)}\n`; } for (const ifDefineSpec of spec.IfDefine ?? []) { output += `${buildIfDefine(context, ifDefineSpec)}\n`; } for (const ifDirectiveSpec of spec.IfDirective ?? []) { output += `${buildIfDirective(context, ifDirectiveSpec)}\n`; } for (const ifFileSpec of spec.IfFile ?? []) { output += `${buildIfFile(context, ifFileSpec)}\n`; } for (const ifModuleSpec of spec.IfModule ?? []) { output += `${buildIfModule(context, ifModuleSpec)}\n`; } for (const ifSectionSpec of spec.IfSection ?? []) { output += `${buildIfSection(context, ifSectionSpec)}\n`; } return output; }function buildInnerSpec(context, innerSpec) { return buildSpec(context, innerSpec).trim().replace(/^/gmu, "\t"); } function escapeRegexString(input) { return input .replace(/([\\^.$|()[*+?{#])/gu, "\\$1") .replace(/\//gu, "\\x{2f}"); } function escapeValue(value) { return value.replace(/"/gu, '\\"'); } async function readFile(path) { return new Promise((resolve, reject) => { readFile$1(path, "utf8", (err, data) => { if (err !== null) { reject(err); } resolve(data); }); }); } async function writeFile(path, contents) { return new Promise((resolve) => { writeFile$1(path, contents, () => { resolve(); }); }); }let outputOptions = undefined; function extractMetaCSP(options) { if (!options.extractMetaCSP.enabled) { return {}; } return { closeBundle: closeBundle(options.extractMetaCSP, options.fileName), renderStart, }; } function closeBundle(options, htaccessFileName) { return { async handler() { const outputDir = options.outputDir ?? outputOptions?.dir ?? process.cwd(); const defaultPolicy = options.defaultPolicyFile !== undefined ? await extractCSPValueFromHTMLFile(this, join(outputDir, options.defaultPolicyFile)) : null; const perFilePolicyFiles = await glob(options.perFilePolicyFiles ?? [], { cwd: outputDir, }); perFilePolicyFiles.sort(); const perFilePolicies = Object.fromEntries((await Promise.all(perFilePolicyFiles.map(async (fileName) => [ fileName, await extractCSPValueFromHTMLFile(this, join(outputDir, fileName)), ]))).filter(([_, policy]) => policy !== null)); await writeCSPValuesToHtaccessFile(defaultPolicy, perFilePolicies, options, htaccessFileName); }, order: "post", sequential: true, }; } async function extractCSPValueFromHTMLFile(context, fileName) { let fileContents = ""; try { fileContents = await readFile(fileName); } catch { return null; } const dom = parseDocument(fileContents, { withEndIndices: true, withStartIndices: true, }); const cspMetaElems = findAll((elem) => elem.type === ElementType.Tag && elem.name === "meta" && "http-equiv" in elem.attribs && elem.attribs["http-equiv"].toLowerCase() === "content-security-policy", dom.children); const cspValues = [ ...new Set(cspMetaElems.map((elem) => elem.attribs["content"])), ]; if (cspValues.length < 1) { return null; } if (cspValues.length > 1) { context.error("Found multiple conflicting CSP directives when extracting from meta tags."); } for (const cspMetaElem of cspMetaElems) { if (cspMetaElem.startIndex !== null && cspMetaElem.endIndex !== null) { fileContents = fileContents.substring(0, cspMetaElem.startIndex) + fileContents.substring(cspMetaElem.endIndex + 1); } } await writeFile(fileName, fileContents); return cspValues[0]; } function renderStart(outputOptionsValue) { outputOptions = outputOptionsValue; } async function writeCSPValuesToHtaccessFile(defaultPolicy, perFilePolicies, options, htaccessFileName) { const path = join(options.outputDir ?? outputOptions?.dir ?? process.cwd(), htaccessFileName); let fileContents = await readFile(path).catch(() => ""); if (defaultPolicy !== null) { fileContents += `Header always set Content-Security-Policy "${escapeValue(defaultPolicy)}"\n`; } function constructPerFileDirective([fileName, policy]) { const innerDirective = `\n\tHeader always set Content-Security-Policy "${escapeValue(policy)}"\n`; if (!fileName.includes("/")) { return `<Files "${escapeValue(fileName)}">${innerDirective}</Files>\n`; } return `<If "%{REQUEST_FILENAME} =~ /${escapeRegexString(fileName)}$/">${innerDirective}</If>\n`; } fileContents += Object.entries(perFilePolicies) .map(constructPerFileDirective) .join(""); await writeFile(path, fileContents); }async function readTemplate(context, root, template) { let fileContents = ""; try { fileContents = await readFile(join(root, template)); } catch (err) { context.error(`Could not read rollup-plugin-htaccess template file, Error: ${err.message}`); } return `${fileContents.replace(/\r/gu, "")}\n`; }/** * @public */ function htaccess(opts) { const options = { extractMetaCSP: { enabled: false }, fileName: ".htaccess", spec: {}, template: undefined, ...opts, }; let root = ""; const rollupPlugin = { configResolved: (config) => { root = config.root; }, async generateBundle() { this.emitFile({ fileName: options.fileName, source: await buildHtaccessFile(this, options, root), type: "asset", }); }, name: "htaccess", ...extractMetaCSP(options), }; return rollupPlugin; } async function buildHtaccessFile(context, options, root) { let output = ""; if (options.template !== undefined) { output += await readTemplate(context, root, options.template); } output += buildSpec(context, options.spec); return output; }export{htaccess as default};