diff --git a/lib/middleware/MiddlewareManager.js b/lib/middleware/MiddlewareManager.js index 537d70fd..259a240c 100644 --- a/lib/middleware/MiddlewareManager.js +++ b/lib/middleware/MiddlewareManager.js @@ -8,7 +8,8 @@ const MiddlewareUtil = require("./MiddlewareUtil"); */ class MiddlewareManager { constructor({tree, resources, options = { - sendSAPTargetCSP: false + sendSAPTargetCSP: false, + serveCSPReports: false }}) { if (!tree || !resources || !resources.all || !resources.rootProject || !resources.dependencies) { throw new Error("[MiddlewareManager]: One or more mandatory parameters not provided"); @@ -118,6 +119,11 @@ class MiddlewareManager { defaultPolicy2IsReportOnly: true, }); } + if (this.options.serveCSPReports) { + Object.assign(oCspConfig, { + serveCSPReports: true, + }); + } return () => { return cspModule("sap-ui-xx-csp-policy", oCspConfig); }; diff --git a/lib/middleware/csp.js b/lib/middleware/csp.js index 541a3094..0ab10817 100644 --- a/lib/middleware/csp.js +++ b/lib/middleware/csp.js @@ -1,6 +1,9 @@ const parseurl = require("parseurl"); +const Router = require("router"); const querystring = require("querystring"); +const log = require("@ui5/logger").getLogger("server:middleware:csp"); + 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|:ro)?$/i; @@ -16,6 +19,27 @@ function addHeader(res, header, value) { } } + +/** + * @typedef {object} CspConfig + * @property {boolean} allowDynamicPolicySelection + * @property {boolean} allowDynamicPolicyDefinition + * @property {string} defaultPolicy + * @property {boolean} defaultPolicyIsReportOnly + * @property {string} defaultPolicy2 + * @property {boolean} defaultPolicy2IsReportOnly + * @property {object} definedPolicies + * @property {boolean} serveCSPReports whether to serve the csp resources + */ + +/** + * @module @ui5/server/middleware/csp + * Middleware which enables CSP (content security policy) support + * @see https://www.w3.org/TR/CSP/ + * @param {string} sCspUrlParameterName + * @param {CspConfig} oConfig + * @returns {Function} Returns a server middleware closure. + */ function createMiddleware(sCspUrlParameterName, oConfig) { const { allowDynamicPolicySelection = false, @@ -24,22 +48,65 @@ function createMiddleware(sCspUrlParameterName, oConfig) { defaultPolicyIsReportOnly = false, defaultPolicy2 = null, defaultPolicy2IsReportOnly = false, - definedPolicies = {} + definedPolicies = {}, + serveCSPReports = false } = oConfig; - return function csp(req, res, next) { - const oParsedURL = parseurl(req); - if (req.method === "POST" ) { - if (req.headers["content-type"] === "application/csp-report" && - oParsedURL.pathname.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. + /** + * List of CSP Report entries + */ + const cspReportEntries = []; + const router = new Router(); + // .csplog + // body parser is required to parse csp-report in body (json) + if (serveCSPReports) { + const bodyParser = require("body-parser"); + router.post("/.ui5/csp/report.csplog", bodyParser.json({type: "application/csp-report"})); + } + router.post("/.ui5/csp/report.csplog", function(req, res, next) { + if (req.headers["content-type"] === "application/csp-report") { + if (!serveCSPReports) { + res.end(); + return; + } + // Write the violation into an array + // They can be retrieved via a request to '/.ui5/csp/csp-reports.json' + if (typeof req.body !== "object") { + const error = new Error(`No body content available: ${req.url}`); + log.error(error); + next(error); return; } + const cspReportObject = req.body["csp-report"]; + if (cspReportObject) { + // extract the csp-report and add it to the cspReportEntries list + cspReportEntries.push(cspReportObject); + } + res.end(); + } else { next(); - return; } + }); + + // csp-reports.json + if (serveCSPReports) { + router.get("/.ui5/csp/csp-reports.json", (req, res, next) => { + // serve csp reports + const body = JSON.stringify({ + "csp-reports": cspReportEntries + }, null, "\t"); + res.writeHead(200, { + "Content-Type": "application/json" + }); + res.end(body); + }); + } + + // html get requests + // add csp headers + router.use((req, res, next) => { + const oParsedURL = parseurl(req); // add CSP headers only to get requests for *.html pages if (req.method !== "GET" || !oParsedURL.pathname.endsWith(".html")) { @@ -81,23 +148,25 @@ function createMiddleware(sCspUrlParameterName, oConfig) { // collect header values based on configuration if (policy) { if (reportOnly) { - // Add dummy report-uri. This is mandatory for the report-only mode. - addHeader(res, HEADER_CONTENT_SECURITY_POLICY_REPORT_ONLY, policy + " report-uri dummy.csplog;"); + // Add report-uri. This is mandatory for the report-only mode. + addHeader(res, HEADER_CONTENT_SECURITY_POLICY_REPORT_ONLY, policy + " report-uri /.ui5/csp/report.csplog;"); } else { 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;"); + // Add report-uri. This is mandatory for the report-only mode. + addHeader(res, HEADER_CONTENT_SECURITY_POLICY_REPORT_ONLY, policy2 + " report-uri /.ui5/csp/report.csplog;"); } else { addHeader(res, HEADER_CONTENT_SECURITY_POLICY, policy2); } } next(); - }; + }); + + return router; } module.exports = createMiddleware; diff --git a/lib/server.js b/lib/server.js index 8cf1b0f4..56ee9a6d 100644 --- a/lib/server.js +++ b/lib/server.js @@ -111,6 +111,7 @@ module.exports = { * aim for (AKA 'target policies'), are send for any requested * *.html file * @param {boolean} [options.simpleIndex=false] Use a simplified view for the server directory listing + * @param {boolean} [options.serveCSPReports=false] Enable csp reports serving for request url '/.ui5/csp/csp-reports.json' * @returns {Promise} Promise resolving once the server is listening. * It resolves with an object containing the port, * h2-flag and a close function, @@ -118,7 +119,7 @@ module.exports = { */ async serve(tree, { port: requestedPort, changePortIfInUse = false, h2 = false, key, cert, - acceptRemoteConnections = false, sendSAPTargetCSP = false, simpleIndex = false + acceptRemoteConnections = false, sendSAPTargetCSP = false, simpleIndex = false, serveCSPReports = false }) { const projectResourceCollections = resourceFactory.createCollectionsForTree(tree); @@ -140,6 +141,7 @@ module.exports = { resources, options: { sendSAPTargetCSP, + serveCSPReports, simpleIndex } }); diff --git a/package-lock.json b/package-lock.json index b3f6ea2e..b461e818 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6388,6 +6388,32 @@ "glob": "^7.1.3" } }, + "router": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/router/-/router-1.3.5.tgz", + "integrity": "sha512-kozCJZUhuSJ5VcLhSb3F8fsmGXy+8HaDbKCAerR1G6tq3mnMZFMuSohbFvGv1c5oMFipijDjRZuuN/Sq5nMf3g==", + "requires": { + "array-flatten": "3.0.0", + "debug": "2.6.9", + "methods": "~1.1.2", + "parseurl": "~1.3.3", + "path-to-regexp": "0.1.7", + "setprototypeof": "1.2.0", + "utils-merge": "1.0.1" + }, + "dependencies": { + "array-flatten": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-3.0.0.tgz", + "integrity": "sha512-zPMVc3ZYlGLNk4mpK1NzP2wg0ml9t7fUgDsayR5Y5rSzxQilzR9FGu/EH2jQOcKSAeAfWeylyW8juy3OkWRvNA==" + }, + "setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==" + } + } + }, "run-async": { "version": "2.4.1", "resolved": "https://registry.npmjs.org/run-async/-/run-async-2.4.1.tgz", diff --git a/package.json b/package.json index ebb52507..dabb8844 100644 --- a/package.json +++ b/package.json @@ -100,6 +100,7 @@ "@ui5/builder": "^2.0.3", "@ui5/fs": "^2.0.1", "@ui5/logger": "^2.0.0", + "body-parser": "^1.19.0", "compression": "^1.7.4", "connect-openui5": "^0.9.0", "cors": "^2.8.5", @@ -114,6 +115,7 @@ "parseurl": "^1.3.3", "portscanner": "^2.1.1", "replacestream": "^4.0.3", + "router": "^1.3.5", "spdy": "^4.0.2", "treeify": "^1.0.1", "yesno": "^0.3.1" diff --git a/test/lib/server/main.js b/test/lib/server/main.js index 6a49bec4..1900cd43 100644 --- a/test/lib/server/main.js +++ b/test/lib/server/main.js @@ -564,6 +564,61 @@ test("CSP (sap policies)", (t) => { }); }); +test("CSP serveCSPReports", (t) => { + const port = 3450; + const request = supertest(`http://localhost:${port}`); + let localServeResult; + return normalizer.generateProjectTree({ + cwd: "./test/fixtures/application.a" + }).then((tree) => { + return server.serve(tree, { + port, + serveCSPReports: true, + simpleIndex: false + }); + }).then((serveResult) => { + localServeResult = serveResult; + const cspReport = { + "csp-report": { + "document-uri": "https://otherserver:8080/index.html", + "referrer": "", + "violated-directive": "script-src-elem", + "effective-directive": "script-src-elem", + "original-policy": "default-src 'self' myserver:443; report-uri /report-csp-violation", + "disposition": "report", + "blocked-uri": "inline", + "line-number": 17, + "source-file": "https://otherserver:8080/index.html", + "status-code": 0, + "script-sample": "" + } + }; + return request.post("/.ui5/csp/report.csplog") + .set("Content-Type", "application/csp-report") + // to allow setting the content type the argument for sending must be a string + .send(JSON.stringify(cspReport)) + .expect(200); + }).then(() => { + return request.get("/.ui5/csp/csp-reports.json") + .then((res) => { + t.true(typeof res.body === "object", "the body is an object"); + t.true(Array.isArray(res.body["csp-reports"]), "csp-reports is an array"); + t.is(res.body["csp-reports"].length, 1, "one csp report in result"); + }); + }).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 index 9c1786ef..23c97179 100644 --- a/test/lib/server/middleware/csp.js +++ b/test/lib/server/middleware/csp.js @@ -1,44 +1,296 @@ 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! + +test("OPTIONS request", async (t) => { + t.plan(2); + const middleware = cspMiddleware("sap-ui-xx-csp-policy", {}); + + await new Promise((resolve) => { + const res = { + getHeader: function() { + return undefined; + }, + end: function() { + t.true(true, "end is called"); + resolve(); + }, + setHeader: function(header, value) { + if (header.startsWith("Content-Security-Policy")) { + t.fail(`should not be called with header ${header} and value ${value}`); + } + if (header === "Allow") { + t.is(value, "POST", "POST should be allowed"); + } + } + }; + const next = function() { + t.fail("should not be called."); + resolve(); + }; + middleware({method: "OPTIONS", url: "/.ui5/csp/report.csplog", headers: {}}, res, next); + }); +}); + +test("Default Settings", async (t) => { const middleware = cspMiddleware("sap-ui-xx-csp-policy", {}); const res = { getHeader: function() { return undefined; }, + end: function() { + t.fail(`end should not be called`); + }, setHeader: 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", url: "/test.html", headers: {}}, res, next); - middleware({ - method: "GET", - url: "/test.html?sap-ui-xx-csp-policy=sap-target-level-2", - headers: {} - }, res, next); - middleware({method: "POST", url: "somePath", headers: {}}, res, next); - middleware({ - method: "POST", - url: "/dummy.csplog", - headers: {"content-type": "application/csp-report"} - }, res, noNext); + await new Promise((resolve) => { + middleware({method: "GET", url: "/test.html", headers: {}}, res, resolve); + }); + await new Promise((resolve) => { + middleware({ + method: "GET", + url: "/test.html?sap-ui-xx-csp-policy=sap-target-level-2", + headers: {} + }, res, resolve); + }); + await new Promise((resolve) => { + middleware({method: "POST", url: "somePath", headers: {}}, res, resolve); + }); + await new Promise((resolve) => { + middleware({ + method: "POST", + url: "/.ui5/csp/report.csplog", + headers: {"content-type": "application/csp-report"} + }, { + end: resolve + }, noNext); + }); // check that unsupported methods result in a call to next() - ["CONNECT", "DELETE", "HEAD", "OPTIONS", "PATCH", "PUT", "TRACE"].forEach( - (method) => middleware({method, url: "/dummy.csplog", headers: {}}, res, next) + const otherMethods = ["CONNECT", "DELETE", "HEAD", "PATCH", "PUT", "TRACE"].map( + (method) => { + return new Promise((resolve) => { + middleware({method, url: "/.ui5/csp/report.csplog", headers: {}}, res, resolve); + }); + } ); + + await Promise.all(otherMethods); + t.true(true, "no failure"); +}); + +test("Default Settings CSP violation", async (t) => { + t.plan(1); + const middleware = cspMiddleware("sap-ui-xx-csp-policy", { + serveCSPReports: true + }); + + const cspReport = { + "document-uri": "https://otherserver:8080/index.html", + "referrer": "", + "violated-directive": "script-src-elem", + "effective-directive": "script-src-elem", + "original-policy": "default-src 'self' myserver:443; report-uri /report-csp-violation", + "disposition": "report", + "blocked-uri": "inline", + "line-number": 17, + "source-file": "https://otherserver:8080/index.html", + "status-code": 0, + "script-sample": "" + }; + + await new Promise((resolve) => { + const noNext = function() { + t.fail("Next should not be called"); + resolve(); + }; + middleware({ + method: "POST", + url: "/.ui5/csp/report.csplog", + headers: {"content-type": "application/csp-report"}, + body: { + "csp-report": cspReport + } + }, { + end: resolve + }, noNext); + }); + + await new Promise((resolve) => { + const noNext = function() { + t.fail("Next should not be called"); + resolve(); + }; + const res = { + writeHead: function(status, contentType) { + }, + end: function(content) { + t.is(content, JSON.stringify({"csp-reports": [cspReport]}, null, "\t"), "content matches"); + resolve(); + }, + }; + middleware({ + method: "GET", + url: "/.ui5/csp/csp-reports.json", + headers: {"content-type": "application/json"} + }, res, noNext); + }); +}); + + +test("Default Settings CSP violation without body parser, invalid body content", async (t) => { + t.plan(2); + const middleware = cspMiddleware("sap-ui-xx-csp-policy", { + serveCSPReports: true + }); + + await new Promise((resolve) => { + const nextFunction = function(error) { + t.true(error instanceof Error); + t.is(error.message, "No body content available: /.ui5/csp/report.csplog", "error message matches"); + resolve(); + }; + middleware({ + method: "POST", + url: "/.ui5/csp/report.csplog", + headers: {"content-type": "application/csp-report"}, + body: "test" + }, { + end: function() { + t.fail("res.end should not be called"); + resolve(); + }}, nextFunction); + }); +}); + + +test("Default Settings two CSP violations", async (t) => { + t.plan(3); + const middleware = cspMiddleware("sap-ui-xx-csp-policy", { + serveCSPReports: true + }); + + const cspReport1 = { + "document-uri": "https://otherserver:8080/index.html", + "referrer": "", + "violated-directive": "script-src-elem", + "effective-directive": "script-src-elem", + "original-policy": "default-src 'self' myserver:443; report-uri /report-csp-violation", + "disposition": "report", + "blocked-uri": "inline", + "line-number": 17, + "source-file": "https://otherserver:8080/index.html", + "status-code": 0, + "script-sample": "" + }; + + const cspReport2 = { + "document-uri": "https://otherserver:8080/imprint.html", + "referrer": "", + "violated-directive": "script-src-elem", + "effective-directive": "script-src-elem", + "original-policy": "default-src 'self' myserver:443; report-uri /report-csp-violation", + "disposition": "report", + "blocked-uri": "inline", + "line-number": 15, + "source-file": "https://otherserver:8080/imprint.html", + "status-code": 0, + "script-sample": "" + }; + + await new Promise((resolve) => { + middleware({ + method: "POST", + url: "/.ui5/csp/report.csplog", + headers: {"content-type": "application/csp-report"}, + body: { + "csp-report": cspReport1 + } + }, { + end: function() { + t.true(true, "end is called"); + resolve(); + } + }, () => { + t.fail("Next should not be called"); + resolve(); + }); + }); + + await new Promise((resolve) => { + middleware({ + method: "POST", + url: "/.ui5/csp/report.csplog", + headers: {"content-type": "application/csp-report"}, + body: { + "csp-report": cspReport2 + } + }, { + end: function() { + t.true(true, "end is called"); + resolve(); + } + }, () => { + t.fail("Next should not be called"); + resolve(); + }); + }); + + await new Promise((resolve) => { + const res = { + writeHead: function(status, contentType) { + }, + end: function(content) { + t.is(content, JSON.stringify({"csp-reports": [cspReport1, cspReport2]}, null, "\t"), "content matches"); + resolve(); + }, + }; + middleware({ + method: "GET", + url: "/.ui5/csp/csp-reports.json", + headers: {"content-type": "application/json"} + }, res, () => { + t.fail("Next should not be called"); + resolve(); + }); + }); +}); + +test("Default Settings no CSP violations", async (t) => { + t.plan(1); + const middleware = cspMiddleware("sap-ui-xx-csp-policy", { + serveCSPReports: true + }); + + await new Promise((resolve) => { + const res = { + writeHead: function(status, contentType) { + }, + end: function(content) { + t.is(content, JSON.stringify({"csp-reports": []}, null, "\t"), "content matches"); + resolve(); + }, + }; + + const noNext = function() { + t.fail("Next should not be called"); + resolve(); + }; + middleware({ + method: "GET", + url: "/.ui5/csp/csp-reports.json", + headers: {"content-type": "application/json"} + }, res, noNext); + }); }); -test("Custom Settings", (t) => { +test("Custom Settings", async (t) => { const middleware = cspMiddleware("csp", { definedPolicies: { policy1: "default-src 'self';", @@ -65,21 +317,33 @@ test("Custom Settings", (t) => { } } }; - const next = function() { - t.pass("Next was called."); - }; expected = ["default-src 'self';", "default-src http:;"]; - middleware({method: "GET", url: "/test.html", headers: {}}, res, next); + await new Promise((resolve) => { + middleware({method: "GET", url: "/test.html", headers: {}}, res, () => { + t.pass("Next was called."); + resolve(); + }); + }); expected = ["default-src https:;", "default-src http:;"]; - middleware({method: "GET", url: "/test.html?csp=policy3", headers: {}}, res, next); + await new Promise((resolve) => { + middleware({method: "GET", url: "/test.html?csp=policy3", headers: {}}, res, () => { + t.pass("Next was called."); + resolve(); + }); + }); expected = ["default-src ftp:;", "default-src http:;"]; - middleware({method: "GET", url: "/test.html?csp=default-src%20ftp:;", headers: {}}, res, next); + await new Promise((resolve) => { + middleware({method: "GET", url: "/test.html?csp=default-src%20ftp:;", headers: {}}, res, () => { + t.pass("Next was called."); + resolve(); + }); + }); }); -test("No Dynamic Policy Definition", (t) => { +test("No Dynamic Policy Definition", async (t) => { const middleware = cspMiddleware("csp", { definedPolicies: { policy1: "default-src 'self';", @@ -103,15 +367,18 @@ test("No Dynamic Policy Definition", (t) => { } } }; - const next = function() { - t.pass("Next was called."); - }; const expected = ["default-src 'self';", "default-src http:;"]; - middleware({method: "GET", url: "/test.html?csp=default-src%20ftp:;", headers: {}}, res, next); + await new Promise((resolve) => { + middleware({method: "GET", url: "/test.html?csp=default-src%20ftp:;", headers: {}}, res, () => { + t.pass("Next was called."); + resolve(); + }); + }); }); -test("Header Manipulation", (t) => { +test("Header Manipulation, add headers to existing header", async (t) => { + t.plan(3); const middleware = cspMiddleware("csp", { definedPolicies: { policy1: "default-src 'self';", @@ -130,13 +397,17 @@ test("Header Manipulation", (t) => { setHeader: function(header, value) { if ( header.toLowerCase() === "content-security-policy" ) { cspHeader = value; + t.true(true, "header is manipulated"); } else { t.fail(`should not be called with header ${header} and value ${value}`); } } }; - const next = function() {}; - middleware({method: "GET", url: "/test.html", headers: {}}, res, next); - t.deepEqual(cspHeader, ["default-src: spdy:", "default-src 'self';", "default-src http:;"]); + await new Promise((resolve) => { + middleware({method: "GET", url: "/test.html", headers: {}}, res, () => { + t.deepEqual(cspHeader, ["default-src: spdy:", "default-src 'self';", "default-src http:;"]); + resolve(); + }); + }); });