From f9ec3eeb43708462c2d683a80beb1816beeddc92 Mon Sep 17 00:00:00 2001 From: Domi Date: Fri, 6 Jul 2018 16:26:10 +0200 Subject: [PATCH] [FEATURE] Add CSP middleware (#3) Adds a Content Security Policy (CSP) middleware which can be enabled via URL parameter (sap-ui-xx-csp-policy). One can set the value to a policy string directly, or use one of the already defined policy IDs (sap-target-level-1, sap-target-level-2). The OpenUI5 QUnit test "[sap/ui/core/qunit/csp/ContentSecurityPolicy.qunit.html](https://github.com/SAP/openui5/blob/master/src/sap.ui.core/test/sap/ui/core/qunit/csp/ContentSecurityPolicy.qunit.html)" can be used for a starting point validating for CSP compliance. --- lib/middleware/csp.js | 69 +++++++++++++++++++++++++++++++++++++++++++ lib/server.js | 29 ++++++++++++++++++ test/lib/server.js | 47 +++++++++++++++++++++++++++++ 3 files changed, 145 insertions(+) create mode 100644 lib/middleware/csp.js diff --git a/lib/middleware/csp.js b/lib/middleware/csp.js new file mode 100644 index 00000000..b0a1b474 --- /dev/null +++ b/lib/middleware/csp.js @@ -0,0 +1,69 @@ +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; + +function createMiddleware(sCspUrlParameterName, oConfig) { + const { + allowDynamicPolicySelection=false, + allowDynamicPolicyDefinition=false, + defaultPolicyIsReportOnly=false + } = oConfig; + + return function csp(req, res, next) { + let oPolicy; + let bReportOnly = defaultPolicyIsReportOnly; + + // 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"] + }; + } + + // Use random protocol, host and port to establish a valid URL for parsing query parameters + let oParsedUrl = url.parse(req.url); + let oQuery = querystring.parse(oParsedUrl.query); + let sCspUrlParameterValue = oQuery[sCspUrlParameterName]; + + if (sCspUrlParameterValue) { + let 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; + } 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); + } + oPolicy = { + name: "dynamic-custom-policy", + policy: sCspUrlParameterValue + }; + } + } + + if (oPolicy) { + let sHeader = bReportOnly ? HEADER_CONTENT_SECURITY_POLICY_REPORT_ONLY : HEADER_CONTENT_SECURITY_POLICY; + let sHeaderValue = oPolicy.policy; + + // Send response with CSP header + res.removeHeader(HEADER_CONTENT_SECURITY_POLICY); + res.removeHeader(HEADER_CONTENT_SECURITY_POLICY_REPORT_ONLY); + res.setHeader(sHeader, sHeaderValue); + } + + next(); + }; +} + +module.exports = createMiddleware; diff --git a/lib/server.js b/lib/server.js index 8b569abc..1e666a6c 100644 --- a/lib/server.js +++ b/lib/server.js @@ -8,6 +8,7 @@ const serveResources = require("./middleware/serveResources"); const discovery = require("./middleware/discovery"); const versionInfo = require("./middleware/versionInfo"); const serveThemes = require("./middleware/serveThemes"); +const csp = require("./middleware/csp"); const ui5connect = require("connect-openui5"); const nonReadRequests = require("./middleware/nonReadRequests"); const ui5Fs = require("@ui5/fs"); @@ -51,6 +52,34 @@ function serve(tree, {port, changePortIfInUse = false, h2 = false, key, cert, ac }; const app = express(); + + const oCspConfig = { + allowDynamicPolicySelection: true, + allowDynamicPolicyDefinition: true, + defaultPolicyIsReportOnly: true, + definedPolicies: { + "sap-target-level-1": + "default-src 'self'; " + + "script-src 'self' 'unsafe-eval'; " + + "style-src 'self' 'unsafe-inline'; " + + "font-src 'self' data:; " + + "img-src 'self' * data: blob:; " + + "frame-src 'self' https: data: blob:; " + + "child-src 'self' https: data: blob:; " + + "connect-src 'self' https: wss:;", + "sap-target-level-2": + "default-src 'self'; " + + "script-src 'self'; " + + "style-src 'self' 'unsafe-inline'; " + + "font-src 'self' data:; " + + "img-src 'self' * data: blob:; " + + "frame-src 'self' https: data: blob:; " + + "child-src 'self' https: data: blob:; " + + "connect-src 'self' https: wss:;" + } + }; + app.use(csp("sap-ui-xx-csp-policy", oCspConfig)); + app.use(compression()); app.use(cors()); diff --git a/test/lib/server.js b/test/lib/server.js index 32b0fd4e..9c17050c 100644 --- a/test/lib/server.js +++ b/test/lib/server.js @@ -379,3 +379,50 @@ test("Start server twice - Port is already taken and the next one is used", (t) t.fail(error); }); }); + +test("CSP", (t) => { + return Promise.all([ + 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.regex(res.headers["content-security-policy"], /script-src\s+'self'\s+'unsafe-eval'\s*;/, + "policy should should have the expected content"); + t.is(res.headers["content-security-policy-report-only"], undefined, + "response must not have csp report-only 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.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"); + }), + 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.regex(res.headers["content-security-policy"], /script-src\s+'self'\s*;/, + "policy should should have the expected content"); + t.is(res.headers["content-security-policy-report-only"], undefined, + "response must not have csp report-only 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.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"); + }), + 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"); + t.is(res.headers["content-security-policy-report-only"], undefined, + "response must not have csp report-only 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"); + 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"); + }) + ]); +});