From 00e31b38feb21fd97cca42ec37f25094171492ed Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 6 Nov 2021 19:19:15 -0700 Subject: [PATCH] Make header CSP more robust --- .eleventy.js | 91 ++++++++++++++++----------------------- _11ty/apply-csp.js | 46 +++++++++++++++++++- test/test-generic-post.js | 24 +++++------ 3 files changed, 93 insertions(+), 68 deletions(-) diff --git a/.eleventy.js b/.eleventy.js index e855ec3..02ad24d 100644 --- a/.eleventy.js +++ b/.eleventy.js @@ -55,6 +55,7 @@ const markdownItAnchor = require("markdown-it-anchor"); const localImages = require("./third_party/eleventy-plugin-local-images/.eleventy.js"); const CleanCSS = require("clean-css"); const GA_ID = require("./_data/metadata.json").googleAnalyticsId; +const { cspDevMiddleware } = require("./_11ty/apply-csp.js"); module.exports = function (eleventyConfig) { eleventyConfig.addPlugin(pluginRss); @@ -75,21 +76,21 @@ module.exports = function (eleventyConfig) { eleventyConfig.addPlugin(require("./_11ty/apply-csp.js")); eleventyConfig.setDataDeepMerge(true); eleventyConfig.addLayoutAlias("post", "layouts/post.njk"); - eleventyConfig.addNunjucksAsyncFilter("addHash", function ( - absolutePath, - callback - ) { - readFile(`_site${absolutePath}`, { - encoding: "utf-8", - }) - .then((content) => { - return hasha.async(content); + eleventyConfig.addNunjucksAsyncFilter( + "addHash", + function (absolutePath, callback) { + readFile(`_site${absolutePath}`, { + encoding: "utf-8", }) - .then((hash) => { - callback(null, `${absolutePath}?hash=${hash.substr(0, 10)}`); - }) - .catch((error) => callback(error)); - }); + .then((content) => { + return hasha.async(content); + }) + .then((hash) => { + callback(null, `${absolutePath}?hash=${hash.substr(0, 10)}`); + }) + .catch((error) => callback(error)); + } + ); async function lastModifiedDate(filename) { try { @@ -110,22 +111,22 @@ module.exports = function (eleventyConfig) { // Cache the lastModifiedDate call because shelling out to git is expensive. // This means the lastModifiedDate will never change per single eleventy invocation. const lastModifiedDateCache = new Map(); - eleventyConfig.addNunjucksAsyncFilter("lastModifiedDate", function ( - filename, - callback - ) { - const call = (result) => { - result.then((date) => callback(null, date)); - result.catch((error) => callback(error)); - }; - const cached = lastModifiedDateCache.get(filename); - if (cached) { - return call(cached); + eleventyConfig.addNunjucksAsyncFilter( + "lastModifiedDate", + function (filename, callback) { + const call = (result) => { + result.then((date) => callback(null, date)); + result.catch((error) => callback(error)); + }; + const cached = lastModifiedDateCache.get(filename); + if (cached) { + return call(cached); + } + const promise = lastModifiedDate(filename); + lastModifiedDateCache.set(filename, promise); + call(promise); } - const promise = lastModifiedDate(filename); - lastModifiedDateCache.set(filename, promise); - call(promise); - }); + ); eleventyConfig.addFilter("encodeURIComponent", function (str) { return encodeURIComponent(str); @@ -163,8 +164,7 @@ module.exports = function (eleventyConfig) { return array.slice(0, n); }); - - eleventyConfig.addCollection("posts", function(collectionApi) { + eleventyConfig.addCollection("posts", function (collectionApi) { return collectionApi.getFilteredByTag("posts"); }); eleventyConfig.addCollection("tagList", require("./_11ty/getTagList")); @@ -196,27 +196,7 @@ module.exports = function (eleventyConfig) { // Browsersync Overrides eleventyConfig.setBrowserSyncConfig({ - // read CSP headers from _headers file, add it to response - middleware: function (req, res, next) { - const url = new URL(req.originalUrl, `http://${req.headers.host}/)`); - // add csp headers only for html pages (incluse pretty urls) - if (url.pathname.endsWith("/") || url.pathname.endsWith(".html")) { - const pathNameEscaped = url.pathname.replace(/[.*+?^${}()\/|[\]\\]/g, '\\$&'); - // add CSP Policy - try { - let headers = fs.readFileSync("_site/_headers", { encoding: "utf-8" }); - // don't use literals string to avoid double escape - const pattern = "(" + pathNameEscaped + "\n Content-Security-Policy: )(.*)"; - const match = headers.match(new RegExp(pattern)); - if (match) { - res.setHeader("Content-Security-Policy", match[2]); - } - } catch (error) { - console.log("[setBrowserSyncConfig] Something went wrong with the creation of the csp headers\n", error); - } - } - next(); - }, + middleware: cspDevMiddleware, callbacks: { ready: function (err, browserSync) { const content_404 = fs.readFileSync("_site/404.html"); @@ -233,7 +213,7 @@ module.exports = function (eleventyConfig) { }); // Run me before the build starts - eleventyConfig.on('beforeBuild', () => { + eleventyConfig.on("beforeBuild", () => { // Copy _header to dist // Don't use addPassthroughCopy to prevent apply-csp from running before the _header file has been copied try { @@ -241,7 +221,10 @@ module.exports = function (eleventyConfig) { fs.mkdirSync("./_site", { recursive: true }); fs.writeFileSync("_site/_headers", headers); } catch (error) { - console.log("[beforeBuild] Something went wrong with the _headers file\n", error); + console.log( + "[beforeBuild] Something went wrong with the _headers file\n", + error + ); } }); diff --git a/_11ty/apply-csp.js b/_11ty/apply-csp.js index 940dca3..476dc62 100644 --- a/_11ty/apply-csp.js +++ b/_11ty/apply-csp.js @@ -115,13 +115,57 @@ const addCspHash = async (rawContent, outputPath) => { return content; }; +function parseHeaders(headersFile) { + let currentFilename; + let headers = {}; + for (let line of headersFile.split(/\n+/)) { + if (!line) continue; + if (/^\S/.test(line)) { + currentFilename = line; + headers[currentFilename] = []; + } else { + line = line.trim(); + const h = line.split(/:\s+/); + headers[currentFilename][h[0]] = h[1]; + } + } + return headers; +} + module.exports = { initArguments: {}, configFunction: async (eleventyConfig, pluginOptions = {}) => { eleventyConfig.addTransform("csp", addCspHash); }, + parseHeaders: parseHeaders, + cspDevMiddleware: function (req, res, next) { + const url = new URL(req.originalUrl, `http://${req.headers.host}/)`); + // add csp headers only for html pages (include pretty urls) + if (url.pathname.endsWith("/") || url.pathname.endsWith(".html")) { + let headers; + try { + headers = parseHeaders( + fs.readFileSync("_site/_headers", { + encoding: "utf-8", + }) + )[url.pathname]; + } catch (error) { + console.error( + "[setBrowserSyncConfig] Something went wrong with the creation of the csp headers\n", + error + ); + } + if (headers) { + const csp = headers["Content-Security-Policy"]; + if (csp) { + res.setHeader("Content-Security-Policy", csp); + } + } + } + next(); + }, }; function isDevelopmentMode() { - return /serve/.test(process.argv.join()); + return /serve|dev/.test(process.argv.join()); } diff --git a/test/test-generic-post.js b/test/test-generic-post.js index e7db578..3f51bf1 100644 --- a/test/test-generic-post.js +++ b/test/test-generic-post.js @@ -5,6 +5,7 @@ const readFileSync = require("fs").readFileSync; const existsSync = require("fs").existsSync; const metadata = require("../_data/metadata.json"); const GA_ID = require("../_data/googleanalytics.js")(); +const { parseHeaders } = require("../_11ty/apply-csp"); /** * These tests kind of suck and they are kind of useful. @@ -15,10 +16,10 @@ const GA_ID = require("../_data/googleanalytics.js")(); describe("check build output for a generic post", () => { describe("sample post", () => { - const POST_FILENAME = "_site/posts/firstpost/index.html"; - const POST_RELATIVEFILENAME = "/posts/firstpost/index.html"; + const POST_PATH = "/posts/firstpost/"; + const POST_FILENAME = `_site${POST_PATH}index.html`; const URL = metadata.url; - const POST_URL = URL + "/posts/firstpost/"; + const POST_URL = URL + POST_PATH; if (!existsSync(POST_FILENAME)) { it("WARNING skipping tests because POST_FILENAME does not exist", () => {}); @@ -92,16 +93,13 @@ describe("check build output for a generic post", () => { }); it("should have a good CSP", () => { - const pathNameEscaped = POST_RELATIVEFILENAME.replace(/[.*+?^${}()\/|[\]\\]/g, '\\$&'); - assert(existsSync("./_site/_headers"),"_header exists"); - const headers = readFileSync("./_site/_headers", { encoding: "utf-8" }); - const pattern = "(" + pathNameEscaped + "\n Content-Security-Policy: )(.*)"; - const match = headers.match(new RegExp(pattern)); - const csp = match ? match[2] : false; - assert(match,"There is a CSP header"); - expect(csp).to.contain(";object-src 'none';"); - expect(csp).to.match(/^default-src 'self';/); - + assert(existsSync("./_site/_headers"), "_header exists"); + const headers = parseHeaders( + readFileSync("./_site/_headers", { encoding: "utf-8" }) + ); + POST_PATH; + expect(headers).to.have.key(POST_PATH); + expect(headers).to.have.key(`${POST_PATH}index.html`); }); it("should have accessible buttons", () => {