Skip to content

Commit

Permalink
Add Server Option to Send SAP's Target CSPs by default
Browse files Browse the repository at this point in the history
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
  • Loading branch information
codeworrior committed Apr 14, 2019
1 parent aa57198 commit 9c0a37d
Show file tree
Hide file tree
Showing 4 changed files with 357 additions and 71 deletions.
113 changes: 63 additions & 50 deletions lib/middleware/csp.js
Original file line number Diff line number Diff line change
@@ -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();
Expand Down
12 changes: 10 additions & 2 deletions lib/server.js
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,8 @@ module.exports = {
* <code>h2</code>-flag and a <code>close</code> 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);

Expand All @@ -134,7 +135,6 @@ module.exports = {
const oCspConfig = {
allowDynamicPolicySelection: true,
allowDynamicPolicyDefinition: true,
defaultPolicyIsReportOnly: true,
definedPolicies: {
"sap-target-level-1":
"default-src 'self'; " +
Expand All @@ -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());
Expand Down
157 changes: 138 additions & 19 deletions test/lib/server/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down
Loading

0 comments on commit 9c0a37d

Please sign in to comment.