From 9c0a37d375313ac1a9cf995f688ce9ed0f3b3446 Mon Sep 17 00:00:00 2001 From: Frank Weigel Date: Sun, 14 Apr 2019 11:31:30 +0200 Subject: [PATCH] Add Server Option to Send SAP's Target CSPs by default When option cspDefaults:true is given, the server now sends two policies for *.html files, both in report-only mode: - sap-target-level-1, which forbids inline scripts and only allows sources from self - sap-target-level-2, which additionally forbids 'eval' Each policy is sent with its own 'Content-Security-Policy-Report-Only' header. This might look uncommon, but simplifies automated validation of the violation reports that are sent by the browser. Browsers don't consistently report blocked-uri or source-file, but the original-policy is reported consistently. middleware/csp.js: - allow to define and send a 2nd default policy - skip execution for file types other than *.html and for HTTP methods other than POST and GET - use native capabilities of the express request object instead of parsing URLs with NodeJS means - when using the URL parameter, the shorter suffix ":ro" can now be used to activate the report-only mode server.js - add boolean server option 'cspDefaults' (default false) - enrich csp middleware configuration accordingly when option is set test/ - enhance for the new features --- lib/middleware/csp.js | 113 +++++++++++---------- lib/server.js | 12 ++- test/lib/server/main.js | 157 ++++++++++++++++++++++++++---- test/lib/server/middleware/csp.js | 146 +++++++++++++++++++++++++++ 4 files changed, 357 insertions(+), 71 deletions(-) create mode 100644 test/lib/server/middleware/csp.js diff --git a/lib/middleware/csp.js b/lib/middleware/csp.js index 944aa1df..45d3abf3 100644 --- a/lib/middleware/csp.js +++ b/lib/middleware/csp.js @@ -1,80 +1,93 @@ -const url = require("url"); -const querystring = require("querystring"); - const HEADER_CONTENT_SECURITY_POLICY = "Content-Security-Policy"; const HEADER_CONTENT_SECURITY_POLICY_REPORT_ONLY = "Content-Security-Policy-Report-Only"; -const rPolicy = /([-_a-zA-Z0-9]+)(:report-only)?/i; +const rPolicy = /^([-_a-zA-Z0-9]+)(:report-only|:ro)?$/i; + +function addHeader(res, header, value) { + const current = res.get(header); + if ( current == null ) { + res.set(header, value); + } else if ( Array.isArray(current) ) { + res.set(header, [...current, value]); + } else { + res.set(header, [current, value]); + } +} function createMiddleware(sCspUrlParameterName, oConfig) { const { allowDynamicPolicySelection = false, allowDynamicPolicyDefinition = false, - defaultPolicyIsReportOnly = false + defaultPolicy = "default", + defaultPolicyIsReportOnly = false, + defaultPolicy2 = null, + defaultPolicy2IsReportOnly = false, + definedPolicies = {} } = oConfig; return function csp(req, res, next) { - let oPolicy; - let bReportOnly = defaultPolicyIsReportOnly; - - if (req.method === "POST" && - req.headers["content-type"] === "application/csp-report" && - req.url.endsWith("/dummy.csplog") - ) { - // In report-only mode there must be a report-uri defined - // For now just ignore the violation. It will be logged in the browser anyway. + if (req.method === "POST" ) { + if (req.headers["content-type"] === "application/csp-report" && + req.path.endsWith("/dummy.csplog") ) { + // In report-only mode there must be a report-uri defined + // For now just ignore the violation. It will be logged in the browser anyway. + return; + } + next(); return; } - // If a policy with name 'default' is defined, it will even be send without a present URL parameter. - if (oConfig.definedPolicies["default"]) { - oPolicy = { - name: "default", - policy: oConfig.definedPolicies["default"] - }; + // add CSP headers only to get requests for *.html pages + if (req.method !== "GET" || !req.path.endsWith(".html")) { + next(); + return; } - const oParsedUrl = url.parse(req.url); - const oQuery = querystring.parse(oParsedUrl.query); - let sCspUrlParameterValue = oQuery[sCspUrlParameterName]; + // If default policies are defined, they will even be send without a present URL parameter. + let policy = defaultPolicy && definedPolicies[defaultPolicy]; + let reportOnly = defaultPolicyIsReportOnly; + const policy2 = defaultPolicy2 && definedPolicies[defaultPolicy2]; + const reportOnly2 = defaultPolicy2IsReportOnly; + const sCspUrlParameterValue = req.query[sCspUrlParameterName]; if (sCspUrlParameterValue) { const mPolicyMatch = rPolicy.exec(sCspUrlParameterValue); - if (mPolicyMatch && mPolicyMatch[1] - && oConfig.definedPolicies[mPolicyMatch[1]] && allowDynamicPolicySelection) { - oPolicy = { - name: mPolicyMatch[1], - policy: oConfig.definedPolicies[mPolicyMatch[1]] - }; - bReportOnly = mPolicyMatch[2] !== undefined; + if (mPolicyMatch) { + if (allowDynamicPolicySelection) { + policy = definedPolicies[mPolicyMatch[1]]; + reportOnly = mPolicyMatch[2] !== undefined; + } // else: ignore parameter } else if (allowDynamicPolicyDefinition) { // Custom CSP policy directives get passed as part of the CSP URL-Parameter value - bReportOnly = sCspUrlParameterValue.endsWith(":report-only"); - if (bReportOnly) { - sCspUrlParameterValue = sCspUrlParameterValue.slice(0, - ":report-only".length); + if ( sCspUrlParameterValue.endsWith(":report-only") ) { + policy = sCspUrlParameterValue.slice(0, - ":report-only".length); + reportOnly = true; + } else if ( sCspUrlParameterValue.endsWith(":ro") ) { + policy = sCspUrlParameterValue.slice(0, - ":ro".length); + reportOnly = true; + } else { + policy = sCspUrlParameterValue; + reportOnly = false; } - oPolicy = { - name: "dynamic-custom-policy", - policy: sCspUrlParameterValue - }; - } + } // else: parameter ignored } - if (oPolicy) { - const sHeader = bReportOnly ? HEADER_CONTENT_SECURITY_POLICY_REPORT_ONLY : HEADER_CONTENT_SECURITY_POLICY; - let sHeaderValue; - - if (bReportOnly) { + // collect header values based on configuration + if (policy) { + if (reportOnly) { // Add dummy report-uri. This is mandatory for the report-only mode. - sHeaderValue = oPolicy.policy + " report-uri dummy.csplog;"; + addHeader(res, HEADER_CONTENT_SECURITY_POLICY_REPORT_ONLY, policy + " report-uri dummy.csplog;"); } else { - sHeaderValue = oPolicy.policy; + addHeader(res, HEADER_CONTENT_SECURITY_POLICY, policy); + } + } + if (policy2) { + if (reportOnly2) { + // Add dummy report-uri. This is mandatory for the report-only mode. + addHeader(res, HEADER_CONTENT_SECURITY_POLICY_REPORT_ONLY, policy2 + " report-uri dummy.csplog;"); + } else { + addHeader(res, HEADER_CONTENT_SECURITY_POLICY, policy2); } - - // Send response with CSP header - res.removeHeader(HEADER_CONTENT_SECURITY_POLICY); - res.removeHeader(HEADER_CONTENT_SECURITY_POLICY_REPORT_ONLY); - res.setHeader(sHeader, sHeaderValue); } next(); diff --git a/lib/server.js b/lib/server.js index c520302c..e14d971e 100644 --- a/lib/server.js +++ b/lib/server.js @@ -109,7 +109,8 @@ module.exports = { * h2-flag and a close function, * which can be used to stop the server. */ - serve(tree, {port, changePortIfInUse = false, h2 = false, key, cert, acceptRemoteConnections = false}) { + serve(tree, {port, changePortIfInUse = false, h2 = false, key, cert, acceptRemoteConnections = false, + cspDefaults = false}) { return Promise.resolve().then(() => { const projectResourceCollections = resourceFactory.createCollectionsForTree(tree); @@ -134,7 +135,6 @@ module.exports = { const oCspConfig = { allowDynamicPolicySelection: true, allowDynamicPolicyDefinition: true, - defaultPolicyIsReportOnly: true, definedPolicies: { "sap-target-level-1": "default-src 'self'; " + @@ -156,6 +156,14 @@ module.exports = { "connect-src 'self' https: wss:;" } }; + if ( cspDefaults ) { + Object.assign(oCspConfig, { + defaultPolicy: "sap-target-level-1", + defaultPolicyIsReportOnly: true, + defaultPolicy2: "sap-target-level-2", + defaultPolicy2IsReportOnly: true, + }); + } app.use(csp("sap-ui-xx-csp-policy", oCspConfig)); app.use(compression()); diff --git a/test/lib/server/main.js b/test/lib/server/main.js index 151e2377..778396e0 100644 --- a/test/lib/server/main.js +++ b/test/lib/server/main.js @@ -293,51 +293,170 @@ test("Stop server", (t) => { test("CSP", (t) => { return Promise.all([ + request.get("/index.html").then((res) => { + t.is(res.headers["content-security-policy"], undefined, + "response must not have enforcing csp header"); + t.is(res.headers["content-security-policy-report-only"], undefined, + "response must not have report-only csp header"); + }), request.get("/index.html?sap-ui-xx-csp-policy=sap-target-level-1").then((res) => { - t.truthy(res.headers["content-security-policy"], "response should have csp header"); + t.truthy(res.headers["content-security-policy"], "response should have enforcing csp header"); t.regex(res.headers["content-security-policy"], /script-src\s+'self'\s+'unsafe-eval'\s*;/, - "policy should should have the expected content"); + "header should should have the expected content"); t.is(res.headers["content-security-policy-report-only"], undefined, - "response must not have csp report-only header"); + "response must not have report-only csp header"); }), request.get("/index.html?sap-ui-xx-csp-policy=sap-target-level-1:report-only").then((res) => { - t.is(res.headers["content-security-policy"], undefined, "response must not have csp header"); + t.is(res.headers["content-security-policy"], undefined, + "response must not have enforcing csp header"); t.truthy(res.headers["content-security-policy-report-only"], "response should have report-only csp header"); t.regex(res.headers["content-security-policy-report-only"], /script-src\s+'self'\s+'unsafe-eval'\s*;/, - "policy should should have the expected content"); + "header should should have the expected content"); }), request.get("/index.html?sap-ui-xx-csp-policy=sap-target-level-2").then((res) => { - t.truthy(res.headers["content-security-policy"], "response should have csp header"); + t.truthy(res.headers["content-security-policy"], "response should have enforcing csp header"); t.regex(res.headers["content-security-policy"], /script-src\s+'self'\s*;/, - "policy should should have the expected content"); + "header should should have the expected content"); t.is(res.headers["content-security-policy-report-only"], undefined, - "response must not have csp report-only header"); + "response must not have report-only csp header"); }), request.get("/index.html?sap-ui-xx-csp-policy=sap-target-level-2:report-only").then((res) => { - t.is(res.headers["content-security-policy"], undefined, "response must not have csp header"); + t.is(res.headers["content-security-policy"], undefined, + "response must not have enforcing csp header"); t.truthy(res.headers["content-security-policy-report-only"], "response should have report-only csp header"); t.regex(res.headers["content-security-policy-report-only"], /script-src\s+'self'\s*;/, - "policy should should have the expected content"); + "header should have the expected content"); }), - request.get("/index.html?sap-ui-xx-csp-policy=default-src%20'self';").then((res) => { - t.truthy(res.headers["content-security-policy"], "response should have csp header"); - t.regex(res.headers["content-security-policy"], /default-src\s+'self'\s*;/, - "policy should should have the expected content"); + request.get("/index.html?sap-ui-xx-csp-policy=default-src%20http%3a;").then((res) => { + t.truthy(res.headers["content-security-policy"], "response should have enforcing csp header"); + t.regex(res.headers["content-security-policy"], /default-src\s+http:\s*;/, + "header should contain the configured policy"); t.is(res.headers["content-security-policy-report-only"], undefined, - "response must not have csp report-only header"); + "response must not have report-only csp header"); }), - request.get("/index.html?sap-ui-xx-csp-policy=default-src%20'self';:report-only").then((res) => { - t.is(res.headers["content-security-policy"], undefined, "response must not have csp header"); + request.get("/index.html?sap-ui-xx-csp-policy=default-src%20http%3a;:report-only").then((res) => { + t.is(res.headers["content-security-policy"], undefined, + "response must not have enforcing csp header"); t.truthy(res.headers["content-security-policy-report-only"], "response should have report-only csp header"); - t.regex(res.headers["content-security-policy-report-only"], /default-src\s+'self'\s*;/, - "policy should should have the expected content"); + t.regex(res.headers["content-security-policy-report-only"], /default-src\s+http:\s*;/, + "header should contain the configured policy"); + }), + request.get("/index.html?sap-ui-xx-csp-policy=default-src%20http%3a;:ro").then((res) => { + t.is(res.headers["content-security-policy"], undefined, + "response must not have enforcing csp header"); + t.truthy(res.headers["content-security-policy-report-only"], + "response should have report-only csp header"); + t.regex(res.headers["content-security-policy-report-only"], /default-src\s+http:\s*;/, + "header should contain the configured policy"); }) ]); }); +/* + * Note: the '--csp-defaults' configuration sends two 'content-security-policy-report-only' headers. + * The response object of supertest joins the values of the two headers in a single string, which makes + * assertions below a bit harder to understand (two checks with different regex on the same header) + */ +test("CSP Defaults", (t) => { + const port = 3400; + const request = supertest(`http://localhost:${port}`); + let localServeResult; + return normalizer.generateProjectTree({ + cwd: "./test/fixtures/application.a" + }).then((tree) => { + return server.serve(tree, { + port, + cspDefaults: true + }); + }).then((serveResult) => { + localServeResult = serveResult; + return Promise.all([ + request.get("/index.html").then((res) => { + t.is(res.headers["content-security-policy"], undefined, "response must not have enforcing csp header"); + t.truthy(res.headers["content-security-policy-report-only"], + "response should have report-only csp header"); + t.regex(res.headers["content-security-policy-report-only"], /script-src\s+'self'\s+'unsafe-eval'\s*;/, + "header should contain the 1st default policy"); + t.regex(res.headers["content-security-policy-report-only"], /script-src\s+'self'\s*;/, + "header should contain the 2nd default policy"); + }), + request.get("/index.html?sap-ui-xx-csp-policy=sap-target-level-1").then((res) => { + t.truthy(res.headers["content-security-policy"], "response should have enforcing csp header"); + t.regex(res.headers["content-security-policy"], /script-src\s+'self'\s+'unsafe-eval'\s*;/, + "header should should have the expected content"); + t.truthy(res.headers["content-security-policy-report-only"], + "response should have report-only csp header"); + t.regex(res.headers["content-security-policy-report-only"], /script-src\s+'self'\s*;/, + "header should contain the 2nd default policy"); + }), + request.get("/index.html?sap-ui-xx-csp-policy=sap-target-level-1:report-only").then((res) => { + t.is(res.headers["content-security-policy"], undefined, "response must not have enforcing csp header"); + t.truthy(res.headers["content-security-policy-report-only"], + "response should have report-only csp header"); + t.regex(res.headers["content-security-policy-report-only"], /script-src\s+'self'\s+'unsafe-eval'\s*;/, + "header should should have the expected content"); + t.regex(res.headers["content-security-policy-report-only"], /script-src\s+'self'\s*;/, + "header should contain the 2nd default policy"); + }), + request.get("/index.html?sap-ui-xx-csp-policy=sap-target-level-2").then((res) => { + t.truthy(res.headers["content-security-policy"], "response should have enforcing csp header"); + t.regex(res.headers["content-security-policy"], /script-src\s+'self'\s*;/, + "header should should have the expected content"); + t.regex(res.headers["content-security-policy-report-only"], /script-src\s+'self'\s*;/, + "header should contain the 2nd default policy"); + }), + request.get("/index.html?sap-ui-xx-csp-policy=sap-target-level-2:report-only").then((res) => { + t.is(res.headers["content-security-policy"], undefined, "response must not have enforcing csp header"); + t.truthy(res.headers["content-security-policy-report-only"], + "response should have report-only csp header"); + t.regex(res.headers["content-security-policy-report-only"], /script-src\s+'self'\s*;/, + "header should have the expected content"); + }), + request.get("/index.html?sap-ui-xx-csp-policy=default-src%20http%3a;").then((res) => { + t.truthy(res.headers["content-security-policy"], "response should have enforcing csp header"); + t.regex(res.headers["content-security-policy"], /default-src\s+http:\s*;/, + "header should contain the configured policy"); + t.regex(res.headers["content-security-policy-report-only"], /script-src\s+'self'\s*;/, + "header should contain the 2nd default policy"); + }), + request.get("/index.html?sap-ui-xx-csp-policy=default-src%20http%3a;:report-only").then((res) => { + t.is(res.headers["content-security-policy"], undefined, + "response must not have enforcing csp header"); + t.truthy(res.headers["content-security-policy-report-only"], + "response should have report-only csp header"); + t.regex(res.headers["content-security-policy-report-only"], /default-src\s+http:\s*;/, + "header should contain the configured policy"); + t.regex(res.headers["content-security-policy-report-only"], /default-src\s+'self'\s*;/, + "header should contain the 2nd default policy"); + }), + request.get("/index.html?sap-ui-xx-csp-policy=default-src%20http%3a;:ro").then((res) => { + t.is(res.headers["content-security-policy"], undefined, + "response must not have enforcing csp header"); + t.truthy(res.headers["content-security-policy-report-only"], + "response should have report-only csp header"); + t.regex(res.headers["content-security-policy-report-only"], /default-src\s+http:\s*;/, + "header should contain the configured policy"); + t.regex(res.headers["content-security-policy-report-only"], /default-src\s+'self'\s*;/, + "header should contain the 2nd default policy"); + }) + ]); + }).then(() => { + return new Promise((resolve, reject) => { + localServeResult.close((error) => { + if (error) { + reject(error); + } else { + t.pass("Server closing"); + resolve(); + } + }); + }); + }); +}); + test("Get index of resources", (t) => { return Promise.all([ request.get("").then((res) => { diff --git a/test/lib/server/middleware/csp.js b/test/lib/server/middleware/csp.js new file mode 100644 index 00000000..8708610c --- /dev/null +++ b/test/lib/server/middleware/csp.js @@ -0,0 +1,146 @@ +const {test} = require("ava"); +const cspMiddleware = require("../../../../lib/middleware/csp"); + +test("Default Settings", (t) => { + t.plan(3 + 7); // fourth request should end in middleware and not call next! + const middleware = cspMiddleware("sap-ui-xx-csp-policy", {}); + const res = { + get: function() { + return undefined; + }, + set: function(header, value) { + t.fail(`should not be called with header ${header} and value ${value}`); + } + }; + const next = function() { + t.pass("Next was called."); + }; + const noNext = function() { + t.fail("Next should not be called"); + }; + + middleware({method: "GET", path: "/test.html", headers: {}, query: {}}, res, next); + middleware({ + method: "GET", + path: "/test.html", + headers: {}, + query: { + "sap-ui-xx-csp-policy": "sap-target-level-2" + } + }, res, next); + middleware({method: "POST", path: "somePath", headers: {}, query: {}}, res, next); + middleware({ + method: "POST", + path: "/dummy.csplog", + headers: {"content-type": "application/csp-report"}, + query: {} + }, res, noNext); + + // check that unsupported methods result in a call to next() + ["CONNECT", "DELETE", "HEAD", "OPTIONS", "PATCH", "PUT", "TRACE"].forEach( + (method) => middleware({method, path: "/dummy.csplog", headers: {}, query: {}}, res, next) + ); +}); + +test("Custom Settings", (t) => { + const middleware = cspMiddleware("csp", { + definedPolicies: { + policy1: "default-src 'self';", + policy2: "default-src http:;", + policy3: "default-src https:;" + }, + defaultPolicy: "policy1", + defaultPolicyIsReportOnly: false, + defaultPolicy2: "policy2", + defaultPolicy2IsReportOnly: false, + allowDynamicPolicySelection: true, + allowDynamicPolicyDefinition: true + }); + let expected; + const res = { + get: function() { + return undefined; + }, + set: function(header, value) { + if ( header.toLowerCase() === "content-security-policy" ) { + t.is(value, expected.shift(), "should have the expected value"); + } else { + t.fail(`should not be called with header ${header} and value ${value}`); + } + } + }; + const next = function() { + t.pass("Next was called."); + }; + + expected = ["default-src 'self';", "default-src http:;"]; + middleware({method: "GET", path: "/test.html", headers: {}, query: {}}, res, next); + + expected = ["default-src https:;", "default-src http:;"]; + middleware({method: "GET", path: "/test.html", headers: {}, query: {"csp": "policy3"}}, res, next); + + expected = ["default-src ftp:;", "default-src http:;"]; + middleware({method: "GET", path: "/test.html", headers: {}, query: {"csp": "default-src ftp:;"}}, res, next); +}); + +test("No Dynamic Policy Definition", (t) => { + const middleware = cspMiddleware("csp", { + definedPolicies: { + policy1: "default-src 'self';", + policy2: "default-src http:;" + }, + defaultPolicy: "policy1", + defaultPolicyIsReportOnly: false, + defaultPolicy2: "policy2", + defaultPolicy2IsReportOnly: false, + allowDynamicPolicyDefinition: false + }); + const res = { + get: function() { + return undefined; + }, + set: function(header, value) { + if ( header.toLowerCase() === "content-security-policy" ) { + t.is(value, expected.shift(), "should have the expected value"); + } else { + t.fail(`should not be called with header ${header} and value ${value}`); + } + } + }; + const next = function() { + t.pass("Next was called."); + }; + + const expected = ["default-src 'self';", "default-src http:;"]; + middleware({method: "GET", path: "/test.html", headers: {}, query: {"csp": "default-src ftp:;"}}, res, next); +}); + +test("Header Manipulation", (t) => { + const middleware = cspMiddleware("csp", { + definedPolicies: { + policy1: "default-src 'self';", + policy2: "default-src http:;" + }, + defaultPolicy: "policy1", + defaultPolicyIsReportOnly: false, + defaultPolicy2: "policy2", + defaultPolicy2IsReportOnly: false + }); + let cspHeader = "default-src: spdy:"; + const res = { + get: function() { + return cspHeader; + }, + set: function(header, value) { + if ( header.toLowerCase() === "content-security-policy" ) { + cspHeader = value; + } else { + t.fail(`should not be called with header ${header} and value ${value}`); + } + } + }; + const next = function() {}; + + middleware({method: "GET", path: "/test.html", headers: {}, query: {}}, res, next); + t.deepEqual(cspHeader, ["default-src: spdy:", "default-src 'self';", "default-src http:;"]); +});