From 383684d3397395c7110539fe94a7faad4c4784ad Mon Sep 17 00:00:00 2001 From: ansuz Date: Thu, 10 Feb 2022 16:53:14 +0530 Subject: [PATCH] add new, very specific tests for CSP to the checkup page --- lib/defaults.js | 9 +- server.js | 6 - www/checkup/app-checkup.less | 2 +- www/checkup/checkup-tools.js | 24 ++- www/checkup/main.js | 326 +++++++++++++++++++++++++---------- www/checkup/sandbox/main.js | 18 +- 6 files changed, 273 insertions(+), 112 deletions(-) diff --git a/lib/defaults.js b/lib/defaults.js index f43253ccb..83ed582af 100644 --- a/lib/defaults.js +++ b/lib/defaults.js @@ -16,15 +16,15 @@ Default.commonCSP = function (domain, sandbox) { * it is recommended that you configure these fields to match the * domain which will serve your CryptPad instance. */ - "child-src 'self' blob: " + domain + sandbox, + "child-src " + domain, // IE/Edge - "frame-src 'self' blob: " + domain + sandbox, + "frame-src 'self' blob: " + sandbox, /* this allows connections over secure or insecure websockets if you are deploying to production, you'll probably want to remove - the ws://* directive, and change '*' to your domain + the ws://* directive */ - "connect-src 'self' ws: wss: blob: " + domain + sandbox, + "connect-src 'self' ws: blob: " + domain + sandbox, // data: is used by codemirror "img-src 'self' data: blob:" + domain, @@ -32,6 +32,7 @@ Default.commonCSP = function (domain, sandbox) { // for accounts.cryptpad.fr authentication and cross-domain iframe sandbox "frame-ancestors *", + "worker-src 'self'", "" ]; }; diff --git a/server.js b/server.js index ddd2fcb88..73f63a3e1 100644 --- a/server.js +++ b/server.js @@ -84,12 +84,6 @@ var setHeaders = (function () { } if (Object.keys(headers).length) { return function (req, res) { - // apply a bunch of cross-origin headers for XLSX export in FF and printing elsewhere - /* - applyHeaderMap(res, { - "Cross-Origin-Opener-Policy": /^\/(sheet|presentation|doc|convert)\//.test(req.url)? 'same-origin': '', - });*/ - if (Env.NO_SANDBOX) { // handles correct configuration for local development // https://stackoverflow.com/questions/11531121/add-duplicate-http-response-headers-in-nodejs applyHeaderMap(res, { diff --git a/www/checkup/app-checkup.less b/www/checkup/app-checkup.less index d5cab9c63..064958272 100644 --- a/www/checkup/app-checkup.less +++ b/www/checkup/app-checkup.less @@ -14,7 +14,7 @@ html, body { .report { font-size: 30px; - max-width: 26em; + max-width: 30em; margin: auto; padding-top: 15px; } diff --git a/www/checkup/checkup-tools.js b/www/checkup/checkup-tools.js index f9259ff28..5e0eef42d 100644 --- a/www/checkup/checkup-tools.js +++ b/www/checkup/checkup-tools.js @@ -1,5 +1,7 @@ define([ -], function () { + 'jquery', + '/common/common-util.js', +], function ($, Util) { var Tools = {}; Tools.supportsSharedArrayBuffers = function () { try { @@ -51,5 +53,25 @@ define([ return navigator.userAgent + "\n" + navigator.vendor; }; + Tools.cacheBuster = function (url) { + if (/\?/.test(url)) { return url; } + return url + '?test=' + (+new Date()); + }; + + var common_map = {}; + Tools.common_xhr = function (url, _cb) { + var cb = Util.once(Util.once(Util.mkAsync(_cb))); + var ready = common_map[url]; + if (ready) { return void ready.reg(cb); } + ready = common_map[url] = Util.mkEvent(true); + ready.reg(cb); + return void $.ajax(Tools.cacheBuster(url), { + dataType: 'text', + complete: function (xhr) { + ready.fire(xhr); + }, + }); + }; + return Tools; }); diff --git a/www/checkup/main.js b/www/checkup/main.js index 8d8452726..e54b0b98c 100644 --- a/www/checkup/main.js +++ b/www/checkup/main.js @@ -67,9 +67,7 @@ define([ $(msg).removeClass('cp-danger').addClass('cp-warning'); }; - var cacheBuster = function (url) { - return url + '?test=' + (+new Date()); - }; + var cacheBuster = Tools.cacheBuster; var trimmedSafe = trimSlashes(ApiConfig.httpSafeOrigin); var trimmedUnsafe = trimSlashes(ApiConfig.httpUnsafeOrigin); @@ -371,7 +369,6 @@ define([ var expect = { 'cross-origin-resource-policy': 'cross-origin', 'cross-origin-embedder-policy': 'require-corp', - //'cross-origin-opener-policy': 'same-origin', // FIXME this is in our nginx config but not server.js }; $.ajax(url, { @@ -656,7 +653,7 @@ define([ ]); }; - assert(function (_cb, msg) { + assert(function (_cb, msg) { // FIXME possibly superseded by more advanced CSP tests? var url = '/sheet/inner.html'; var cb = Util.once(Util.mkAsync(_cb)); msg.appendChild(CSP_WARNING(url)); @@ -672,7 +669,7 @@ define([ }); }); - assert(function (cb, msg) { + assert(function (cb, msg) { // FIXME possibly superseded by more advanced CSP tests? var url = '/common/onlyoffice/v5/web-apps/apps/spreadsheeteditor/main/index.html'; msg.appendChild(CSP_WARNING(url)); deferredPostMessage({ @@ -805,36 +802,7 @@ define([ cb(isHTTPS(trimmedUnsafe) && isHTTPS(trimmedSafe)); }); - [ - //'sheet', - //'presentation', - //'doc', - //'convert', - ].forEach(function (url) { - assert(function (cb, msg) { - var header = 'cross-origin-opener-policy'; - var expected = 'same-origin'; - deferredPostMessage({ - command: 'GET_HEADER', - content: { - url: '/' + url + '/', - header: header, - } - }, function (content) { - msg.appendChild(h('span', [ - code(url), - ' was served without the correct ', - code(header), - ' HTTP header value (', - code(expected), - '). This will interfere with your ability to convert between office file formats.' - ])); - cb(content === expected); - }); - }); - }); - - assert(function (cb, msg) { + assert(function (cb, msg) { // FIXME this test has been superceded, but the descriptive text is still useful // check that the sandbox domain is included in connect-src msg.appendChild(h('span', [ "This instance's ", @@ -848,65 +816,254 @@ define([ " See the provided NGINX configuration file for an example of how to set this header correctly.", ])); - $.ajax(cacheBuster('/'), { - dataType: 'text', - complete: function (xhr) { - var CSP = parseCSP(xhr.getResponseHeader('content-security-policy')); - var connect = (CSP && CSP['connect-src']) || ""; - if (connect.includes(trimmedSafe)) { - return void cb(true); - } - cb(CSP); - }, + Tools.common_xhr('/', function (xhr) { + var CSP = parseCSP(xhr.getResponseHeader('content-security-policy')); + var connect = (CSP && CSP['connect-src']) || ""; + if (connect.includes(trimmedSafe)) { + return void cb(true); + } + cb(CSP); }); }); assert(function (cb, msg) { - var directives = [ - 'img-src', - 'media-src', - 'child-src', - 'frame-src' - ]; - msg.appendChild(h('span', [ - "This instance's ", - code("Content-Security-Policy"), - " headers are unnecessarily permissive.", - h('br'), - h('br'), - " Review the recommended settings for ", - code('img-src'), ', ', - code('media-src'), ', ', - code('child-src'), ', and ', - code('frame-src'), - " in the provided NGINX configuration file for an example of how to set these headers correctly.", + code('/api/config'), + " returned an HTTP status code other than ", + code('200'), + ' when accessed from the sandbox domain.', ])); - $.ajax(cacheBuster('/'), { - dataType: 'text', - complete: function (xhr) { - var CSP = parseCSP(xhr.getResponseHeader('content-security-policy')); - // check that the relevant CSP directives are defined - // and that none of them permit general remote content via '*' - if (directives.every(function (k) { - return typeof(CSP[k]) === 'string' && !/ \* /.test(CSP[k]); - })) { - return void cb(true); - } - cb(CSP); + deferredPostMessage({ + command: 'CHECK_HTTP_STATUS', + content: { + url: cacheBuster('/api/config'), }, + }, function (content) { + cb(content === 200 || content); }); }); +/* assert(function (cb, msg) { - msg.appendChild(h('span', 'pewpew')); + msg.appendChild(h('span', [ + 'all headers', + ])); + + Tools.common_xhr('/', function (xhr) { + var all_headers = xhr.getAllResponseHeaders().split(/\r|\n/).filter(Boolean); + cb(all_headers); + }); + }); +*/ + + var validateCSP = function (raw, msg, expected) { + var CSP = parseCSP(raw); + var checkRule = function (attr, rules) { + var v = CSP[attr]; + // return `true` if you fail this test... + if (typeof(v) !== 'string' || !v) { return true; } + var l = rules.length; + for (var i = 0;i < l;i++) { + if (typeof(rules[i]) !== 'undefined' && !v.includes(rules[i])) { return true; } + v = v.replace(rules[i], ''); + } + return v.trim(); + }; + if (Object.keys(expected).some(function (dir) { + var result = checkRule(dir, expected[dir]); + if (result) { + msg.appendChild(h('p', [ + 'A value of ', + code('"' + expected[dir].filter(Boolean).join(' ') + '"'), + ' was expected for the ', + code(dir), + ' directive.', + ])); + + console.log('BAD_HEADER:', { + rule: dir, + expected: expected[dir], + result: result, + }); + } + + return result; + })) { + return parseCSP(raw); + } + return true; + }; + + assert(function (_cb, msg) { + var url = '/sheet/inner.html'; + var cb = Util.once(Util.mkAsync(_cb)); + msg.appendChild(h('span', [ + code(trimmedUnsafe + url), + ' was served with incorrect ', + code('Content-Security-Policy'), + ' headers.', + ])); + + //msg.appendChild(CSP_WARNING(url)); deferredPostMessage({ - command: 'CHECK_HTTP_STATUS', + command: 'GET_HEADER', content: { - url: cacheBuster('/api/config'), + url: url, + header: 'content-security-policy', }, - }, function (content) { - cb(content === 200 || content); + }, function (raw) { + var $outer = trimmedUnsafe; + var $sandbox = trimmedSafe; + var result = validateCSP(raw, msg, { + 'default-src': ["'none'"], + 'style-src': ["'unsafe-inline'", "'self'", $outer], + 'font-src': ["'self'", 'data:', $outer], + 'child-src': [$outer], //["'self'", 'blob:', $outer, $sandbox], + 'frame-src': ["'self'", 'blob:', /*$outer, */$sandbox], + 'script-src': ["'self'", 'resource:', $outer, + "'unsafe-eval'", + "'unsafe-inline'", + ], + 'connect-src': [ + "'self'", + 'blob:', + $outer, + $sandbox, + /https:\/\//.test($outer)? $outer.replace('https://', 'wss://') : 'ws:', + ], + + 'img-src': ["'self'", 'data:', 'blob:', $outer], + 'media-src': ['blob:'], + //'frame-ancestors': ['*'], // XXX IFF you want to support remote embedding + 'worker-src': ["'self'"], // , $outer, $sandbox], + }); + cb(result); + }); + }); + + assert(function (cb, msg) { + var header = 'content-security-policy'; + msg.appendChild(h('span', [ + code(trimmedUnsafe + '/'), + ' was served with incorrect ', + code('Content-Security-Policy'), + ' headers.', + ])); + Tools.common_xhr('/', function (xhr) { + var raw = xhr.getResponseHeader(header); + var $outer = trimmedUnsafe; + var $sandbox = trimmedSafe; + var result = validateCSP(raw, msg, { + 'default-src': ["'none'"], + 'style-src': ["'unsafe-inline'", "'self'", $outer], + 'font-src': ["'self'", 'data:', $outer], + 'child-src': [$outer], //["'self'", 'blob:', $outer, $sandbox], + 'frame-src': ["'self'", 'blob:', /*$outer,*/ $sandbox], + 'script-src': ["'self'", 'resource:', $outer], + 'connect-src': [ + "'self'", + 'blob:', + $outer, + $sandbox, + /https:\/\//.test($outer)? $outer.replace('https://', 'wss://') : 'ws:', + ], + 'img-src': ["'self'", 'data:', 'blob:', $outer], + 'media-src': ['blob:'], + //'frame-ancestors': ['*'], // XXX IFF you want to support remote embedding + 'worker-src': ["'self'"],//, $outer, $sandbox], + }); + + cb(result); + }); + }); + + assert(function (cb, msg) { + var header = 'Access-Control-Allow-Origin'; + msg.appendChild(h('span', [ + 'Assets must be served with an ', + code(header), + ' header with a value of ', + code("'*'"), + ' if you wish to support embedding of encrypted media on third party websites.', + ])); + Tools.common_xhr('/', function (xhr) { + var raw = xhr.getResponseHeader(header); + cb(raw === "*" || raw); + }); + }); + + assert(function (cb, msg) { + var header = 'Cross-Origin-Embedder-Policy'; + msg.appendChild(h('span', [ + "Assets must be served with a ", + code(header), + ' value of ', + code('require-corp'), + " to enable browser features required for client-side document conversion.", + ])); + Tools.common_xhr('/', function (xhr) { + var raw = xhr.getResponseHeader(header); + cb(raw === 'require-corp' || raw); + }); + }); + + assert(function (cb, msg) { + var header = 'Cross-Origin-Resource-Policy'; + msg.appendChild(h('span', [ + "Assets must be served with a ", + code(header), + ' value of ', + code('cross-origin'), + " to enable browser features required for client-side document conversion.", + ])); + Tools.common_xhr('/', function (xhr) { + var raw = xhr.getResponseHeader(header); + cb(raw === 'cross-origin' || raw); + }); + }); + + assert(function (cb, msg) { + var header = 'X-Content-Type-Options'; + msg.appendChild(h('span', [ + "Assets should be served with an ", + code(header), + ' header with a value of ', + code('nosniff'), + '.', + ])); + Tools.common_xhr('/', function (xhr) { + var raw = xhr.getResponseHeader(header); + cb(raw === 'nosniff' || raw); + }); + }); + + assert(function (cb, msg) { + var header = 'Cache-Control'; + msg.appendChild(h('span', [ + 'Assets requested without a version parameter should be served with a ', + code('no-cache'), + ' value for the ', + code("Cache-Control"), + ' header.', + ])); + // Cache-Control should be 'no-cache' unless the URL includes ver= + Tools.common_xhr('/', function (xhr) { + var raw = xhr.getResponseHeader(header); + cb(raw === 'no-cache' || raw); + }); + }); + + assert(function (cb, msg) { + var header = 'Cache-Control'; + msg.appendChild(h('span', [ + 'Assets requested with a version parameter should be served with a long-lived ', + code('Cache-Control'), + ' header.', + ])); + // Cache-Control should be 'max-age=' if the URL includes 'ver=' + Tools.common_xhr('/customize/messages.js?ver=' +(+new Date()), function (xhr) { + var raw = xhr.getResponseHeader(header); + cb(/max\-age=\d+$/.test(raw) || raw); }); }); @@ -959,13 +1116,6 @@ define([ }); */ - if (false) { - assert(function (cb, msg) { - msg.innerText = 'fake test to simulate failure'; - cb(false); - }); - } - var row = function (cells) { return h('tr', cells.map(function (cell) { return h('td', cell); diff --git a/www/checkup/sandbox/main.js b/www/checkup/sandbox/main.js index 47a7be13b..22130b3cf 100644 --- a/www/checkup/sandbox/main.js +++ b/www/checkup/sandbox/main.js @@ -11,13 +11,10 @@ define([ window.parent.postMessage(JSON.stringify(content), '*'); }; postMessage({ command: "READY", }); - var getHeaders = function (url, cb) { - $.ajax(url + "?test=" + (+new Date()), { - dataType: 'text', - complete: function (xhr) { - var allHeaders = xhr.getAllResponseHeaders(); - return void cb(void 0, allHeaders, xhr); - }, + var getHeaders = function (url, cb) { // XXX reuse XHR objects? + Tools.common_xhr(url, function (xhr) { + var allHeaders = xhr.getAllResponseHeaders(); + return void cb(void 0, allHeaders, xhr); }); }; var COMMANDS = {}; @@ -49,11 +46,8 @@ define([ }; COMMANDS.CHECK_HTTP_STATUS = function (content, cb) { - $.ajax(content.url, { - dataType: 'text', - complete: function (xhr) { - cb(xhr.status); - }, + Tools.common_xhr(content.url, function (xhr) { + cb(xhr.status); }); };