Skip to content

Commit

Permalink
Make header CSP more robust
Browse files Browse the repository at this point in the history
  • Loading branch information
cramforce committed Nov 7, 2021
1 parent ef5b2a4 commit 00e31b3
Show file tree
Hide file tree
Showing 3 changed files with 93 additions and 68 deletions.
91 changes: 37 additions & 54 deletions .eleventy.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 {
Expand All @@ -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);
Expand Down Expand Up @@ -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"));
Expand Down Expand Up @@ -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");
Expand All @@ -233,15 +213,18 @@ 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 {
const headers = fs.readFileSync("./_headers", { encoding: "utf-8" });
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
);
}
});

Expand Down
46 changes: 45 additions & 1 deletion _11ty/apply-csp.js
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
24 changes: 11 additions & 13 deletions test/test-generic-post.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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", () => {});
Expand Down Expand Up @@ -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", () => {
Expand Down

0 comments on commit 00e31b3

Please sign in to comment.