diff --git a/docs/example.nginx.conf b/docs/example.nginx.conf index 13fd7789f..be56e10cd 100644 --- a/docs/example.nginx.conf +++ b/docs/example.nginx.conf @@ -19,6 +19,16 @@ server { set $main_domain "your-main-domain.com"; set $sandbox_domain "your-sandbox-domain.com"; + # By default CryptPad allows remote domains to embed CryptPad documents in iframes. + # This behaviour can be blocked by changing $allowed_origins from "*" to the + # sandbox domain, which must be permitted to load content from the main domain + # in order for CryptPad to work as expected. + # + # An example is given below which can be uncommented if you want to block + # remote sites from including content from your server + set $allowed_origins "*"; + # set $allowed_origins "https://${sandbox_domain}"; + # CryptPad's dynamic content (websocket traffic and encrypted blobs) # can be served over separate domains. Using dedicated domains (or subdomains) # for these purposes allows you to move them to a separate machine at a later date @@ -58,7 +68,7 @@ server { add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; add_header X-XSS-Protection "1; mode=block"; add_header X-Content-Type-Options nosniff; - add_header Access-Control-Allow-Origin "*"; + add_header Access-Control-Allow-Origin "${allowed_origins}"; # add_header X-Frame-Options "SAMEORIGIN"; # Opt out of Google's FLoC Network @@ -115,6 +125,9 @@ server { # script-src specifies valid sources for javascript, including inline handlers set $scriptSrc "'self' resource: https://${main_domain}"; + # XXX frame-ancestors defines where your cryptpad instance can be embedded... + set $frameAncestors "https://${main_domain} $https://${sandbox_domain}"; + set $unsafe 0; # the following assets are loaded via the sandbox domain # they unfortunately still require exceptions to the sandboxing to work correctly. @@ -135,7 +148,7 @@ server { } # Finally, set all the rules you composed above. - add_header Content-Security-Policy "default-src 'none'; child-src $childSrc; worker-src $workerSrc; media-src $mediaSrc; style-src $styleSrc; script-src $scriptSrc; connect-src $connectSrc; font-src $fontSrc; img-src $imgSrc; frame-src $frameSrc;"; + add_header Content-Security-Policy "default-src 'none'; child-src $childSrc; worker-src $workerSrc; media-src $mediaSrc; style-src $styleSrc; script-src $scriptSrc; connect-src $connectSrc; font-src $fontSrc; img-src $imgSrc; frame-src $frameSrc; frame-ancestors $frameAncestors"; # The nodejs process can handle all traffic whether accessed over websocket or as static assets # We prefer to serve static content from nginx directly and to leave the API server to handle @@ -183,7 +196,7 @@ server { # encrypted blobs are immutable and are thus cached for a year location ^~ /blob/ { if ($request_method = 'OPTIONS') { - add_header 'Access-Control-Allow-Origin' '*'; + add_header 'Access-Control-Allow-Origin' "${allowed_origins}"; add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS'; add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Content-Range,Range'; add_header 'Access-Control-Max-Age' 1728000; @@ -192,7 +205,7 @@ server { return 204; } add_header Cache-Control max-age=31536000; - add_header 'Access-Control-Allow-Origin' '*'; + add_header 'Access-Control-Allow-Origin' "${allowed_origins}"; add_header 'Access-Control-Allow-Methods' 'GET, POST, OPTIONS'; add_header 'Access-Control-Allow-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Content-Range,Range,Content-Length'; add_header 'Access-Control-Expose-Headers' 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Content-Range,Range,Content-Length'; diff --git a/lib/commands/admin-rpc.js b/lib/commands/admin-rpc.js index 604189c0e..f38c09d88 100644 --- a/lib/commands/admin-rpc.js +++ b/lib/commands/admin-rpc.js @@ -324,6 +324,7 @@ var setLastEviction = function (Env, Server, cb, data, unsafeKey) { var instanceStatus = function (Env, Server, cb) { cb(void 0, { restrictRegistration: Env.restrictRegistration, + disableEmbedding: Env.disableEmbedding, launchTime: Env.launchTime, currentTime: +new Date(), diff --git a/lib/decrees.js b/lib/decrees.js index f115cc6bc..b777a16d4 100644 --- a/lib/decrees.js +++ b/lib/decrees.js @@ -53,6 +53,8 @@ ADD_INVITE REVOKE_INVITE REDEEM_INVITE +DISABLE_EMBEDDING + // 2.0 Env.DEV_MODE || Env.FRESH_MODE, @@ -92,6 +94,9 @@ var makeBooleanSetter = function (attr) { }; }; +// CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['DISABLE_EMBEDDING', [true]]], console.log) +commands.DISABLE_EMBEDDING = makeBooleanSetter('disableEmbedding'); + // CryptPad_AsyncStore.rpc.send('ADMIN', [ 'ADMIN_DECREE', ['RESTRICT_REGISTRATION', [true]]], console.log) commands.RESTRICT_REGISTRATION = makeBooleanSetter('restrictRegistration'); diff --git a/lib/defaults.js b/lib/defaults.js index 4dd906515..663d4bd02 100644 --- a/lib/defaults.js +++ b/lib/defaults.js @@ -1,7 +1,8 @@ var Default = module.exports; -Default.commonCSP = function (domain, sandbox) { - domain = ' ' + domain; +Default.commonCSP = function (Env) { + var domain = ' ' + Env.httpUnsafeOrigin; + var sandbox = Env.httpSafeOrigin; sandbox = (sandbox && sandbox !== domain? ' ' + sandbox: ''); // Content-Security-Policy @@ -31,25 +32,25 @@ Default.commonCSP = function (domain, sandbox) { "media-src blob:", // for accounts.cryptpad.fr authentication and cross-domain iframe sandbox - "frame-ancestors *", + Env.disableEmbedding? `frame-ancestors ${domain}${sandbox}`: "frame-ancestors *", "worker-src 'self'", "" ]; }; -Default.contentSecurity = function (domain, sandbox) { - return (Default.commonCSP(domain, sandbox).join('; ') + "script-src 'self' resource: " + domain).replace(/\s+/g, ' '); +Default.contentSecurity = function (Env) { + return (Default.commonCSP(Env).join('; ') + "script-src 'self' resource: " + Env.httpUnsafeOrigin).replace(/\s+/g, ' '); }; -Default.padContentSecurity = function (domain, sandbox) { - return (Default.commonCSP(domain, sandbox).join('; ') + "script-src 'self' 'unsafe-eval' 'unsafe-inline' resource: " + domain).replace(/\s+/g, ' '); +Default.padContentSecurity = function (Env) { + return (Default.commonCSP(Env).join('; ') + "script-src 'self' 'unsafe-eval' 'unsafe-inline' resource: " + Env.httpUnsafeOrigin).replace(/\s+/g, ' '); }; -Default.httpHeaders = function () { +Default.httpHeaders = function (Env) { return { "X-XSS-Protection": "1; mode=block", "X-Content-Type-Options": "nosniff", - "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Origin": Env.disableEmbedding? Env.permittedEmbedders: "*", "Permissions-policy":"interest-cohort=()" }; }; diff --git a/lib/env.js b/lib/env.js index 10722b584..64280193b 100644 --- a/lib/env.js +++ b/lib/env.js @@ -21,13 +21,45 @@ var isValidPort = function (p) { return typeof(p) === 'number' && p < 65535; }; +var deriveSandboxOrigin = function (unsafe, port) { + var url = new URL(unsafe); + url.port = port; + return url.origin; +}; + module.exports.create = function (config) { + var httpUnsafeOrigin = canonicalizeOrigin(config.httpUnsafeOrigin); + + var httpSafeOrigin; + var NO_SANDBOX = false; + var httpSafePort; + var httpPort = isValidPort(config.httpPort)? config.httpPort: 3000; + + if (typeof(config.httpSafeOrigin) !== 'string') { + NO_SANDBOX = true; + if (typeof(config.httpSafePort) !== 'number') { httpSafePort = httpPort + 1; } + httpSafeOrigin = deriveSandboxOrigin(httpUnsafeOrigin, httpSafePort); + } else { + httpSafeOrigin = canonicalizeOrigin(config.httpSafeOrigin); + } + + var permittedEmbedders = config.permittedEmbedders; + if (typeof(permittedEmbedders) === 'string') { + permittedEmbedders = permittedEmbedders.trim(); + } + const Env = { + fileHost: config.fileHost, // XXX + NO_SANDBOX: NO_SANDBOX, + httpSafePort: httpSafePort, + version: Package.version, installMethod: config.installMethod || undefined, - httpUnsafeOrigin: canonicalizeOrigin(config.httpUnsafeOrigin), - httpSafeOrigin: canonicalizeOrigin(config.httpSafeOrigin), + httpUnsafeOrigin: httpUnsafeOrigin, + httpSafeOrigin: httpSafeOrigin, + permittedEmbedders: typeof(permittedEmbedders) === 'string' && permittedEmbedders? permittedEmbedders: httpSafeOrigin, + removeDonateButton: config.removeDonateButton, httpPort: isValidPort(config.httpPort)? config.httpPort: 3000, httpAddress: typeof(config.httpAddress) === 'string'? config.httpAddress: '127.0.0.1', @@ -39,9 +71,19 @@ module.exports.create = function (config) { DEV_MODE: false, configCache: {}, broadcastCache: {}, + + officeHeadersCache: undefined, + standardHeadersCache: undefined, + apiHeadersCache: undefined, + flushCache: function () { Env.configCache = {}; Env.broadcastCache = {}; + + Env.officeHeadersCache = undefined; + Env.standardHeadersCache = undefined; + Env.apiHeadersCache = undefined; + Env.FRESH_KEY = +new Date(); if (!(Env.DEV_MODE || Env.FRESH_MODE)) { Env.FRESH_MODE = true; } if (!Env.Log) { return; } @@ -62,7 +104,6 @@ module.exports.create = function (config) { archiveRetentionTime: config.archiveRetentionTime, accountRetentionTime: config.accountRetentionTime, - // TODO implement mutability adminEmail: config.adminEmail, supportMailbox: config.supportMailboxPublicKey, @@ -112,6 +153,9 @@ module.exports.create = function (config) { } }, + // as of 4.14.0 you need to opt-in to remote embedding. + disableEmbedding: true, + /* FIXME restrictRegistration is initialized as false and then overridden by admin decree There is a narrow window in which someone could register before the server updates this value. See also the cached 'restrictRegistration' value in server.js#serveConfig diff --git a/server.js b/server.js index 204cd5f6e..73e4d2b00 100644 --- a/server.js +++ b/server.js @@ -23,34 +23,35 @@ var fancyURL = function (domain, path) { return false; }; -var deriveSandboxOrigin = function (unsafe, port) { - var url = new URL(unsafe); - url.port = port; - return url.origin; -}; - (function () { // you absolutely must provide an 'httpUnsafeOrigin' (a truthy string) - if (!Env.httpUnsafeOrigin || typeof(Env.httpUnsafeOrigin) !== 'string') { + if (typeof(Env.httpUnsafeOrigin) !== 'string' || !Env.httpUnsafeOrigin.trim()) { throw new Error("No 'httpUnsafeOrigin' provided"); } - - if (typeof(Env.httpSafeOrigin) !== 'string') { - Env.NO_SANDBOX = true; - if (typeof(Env.httpSafePort) !== 'number') { - Env.httpSafePort = Env.httpPort + 1; - } - Env.httpSafeOrigin = deriveSandboxOrigin(Env.httpUnsafeOrigin, Env.httpSafePort); - } }()); var applyHeaderMap = function (res, map) { - for (let header in map) { res.setHeader(header, map[header]); } + for (let header in map) { + if (typeof(map[header]) === 'string') { res.setHeader(header, map[header]); } + } }; -var setHeaders = (function () { - // load the default http headers unless the admin has provided their own via the config file - var headers; +var EXEMPT = [ + /^\/common\/onlyoffice\/.*\.html.*/, + /^\/(sheet|presentation|doc)\/inner\.html.*/, + /^\/unsafeiframe\/inner\.html.*$/, +]; + +var cacheHeaders = function (Env, key, headers) { + if (Env.DEV_MODE) { return; } + Env[key] = headers; +}; + +var getHeaders = function (Env, type) { + var key = type + 'HeadersCache'; + if (Env[key]) { return Env[key]; } + + var headers = {}; var custom = config.httpHeaders; // if the admin provided valid http headers then use them @@ -58,62 +59,46 @@ var setHeaders = (function () { headers = Util.clone(custom); } else { // otherwise use the default - headers = Default.httpHeaders(); + headers = Default.httpHeaders(Env); } - // next define the base Content Security Policy (CSP) headers - if (typeof(config.contentSecurity) === 'string') { - headers['Content-Security-Policy'] = config.contentSecurity; - if (!/;$/.test(headers['Content-Security-Policy'])) { headers['Content-Security-Policy'] += ';' } - if (headers['Content-Security-Policy'].indexOf('frame-ancestors') === -1) { - // backward compat for those who do not merge the new version of the config - // when updating. This prevents endless spinner if someone clicks donate. - // It also fixes the cross-domain iframe. - headers['Content-Security-Policy'] += "frame-ancestors *;"; - } - } else { - // use the default CSP headers constructed with your domain - headers['Content-Security-Policy'] = Default.contentSecurity(Env.httpUnsafeOrigin, Env.httpSafeOrigin); + headers['Content-Security-Policy'] = type === 'office'? + Default.padContentSecurity(Env): + Default.contentSecurity(Env); + + if (Env.NO_SANDBOX) { // handles correct configuration for local development + // https://stackoverflow.com/questions/11531121/add-duplicate-http-response-headers-in-nodejs + headers["Cross-Origin-Resource-Policy"] = 'cross-origin'; + headers["Cross-Origin-Embedder-Policy"] = 'require-corp'; } - const padHeaders = Util.clone(headers); - if (typeof(config.padContentSecurity) === 'string') { - padHeaders['Content-Security-Policy'] = config.padContentSecurity; - } else { - padHeaders['Content-Security-Policy'] = Default.padContentSecurity(Env.httpUnsafeOrigin, Env.httpSafeOrigin); + // Don't set CSP headers on /api/ endpoints + // because they aren't necessary and they cause problems + // when duplicated by NGINX in production environments + if (type === 'api') { + cacheHeaders(Env, key, headers); + return headers; } - if (Object.keys(headers).length) { - return function (req, res) { - if (Env.NO_SANDBOX) { // handles correct configuration for local development - // https://stackoverflow.com/questions/11531121/add-duplicate-http-response-headers-in-nodejs - applyHeaderMap(res, { - "Cross-Origin-Resource-Policy": 'cross-origin', - "Cross-Origin-Embedder-Policy": 'require-corp', - }); - } - // Don't set CSP headers on /api/ endpoints - // because they aren't necessary and they cause problems - // when duplicated by NGINX in production environments - if (/^\/api\/(broadcast|config)/.test(req.url)) { return; } - - applyHeaderMap(res, { - "Cross-Origin-Resource-Policy": 'cross-origin', - }); - - // targeted CSP, generic policies, maybe custom headers - const h = [ - /^\/common\/onlyoffice\/.*\.html.*/, - /^\/(sheet|presentation|doc)\/inner\.html.*/, - /^\/unsafeiframe\/inner\.html.*$/, - ].some((regex) => { - return regex.test(req.url); - }) ? padHeaders : headers; - applyHeaderMap(res, h); - }; + headers["Cross-Origin-Resource-Policy"] = 'cross-origin'; + cacheHeaders(Env, key, headers); + return headers; +}; + +var setHeaders = function (req, res) { + var type; + if (EXEMPT.some(regex => regex.test(req.url))) { + type = 'office'; + } else if (/^\/api\/(broadcast|config)/.test(req.url)) { + type = 'api'; + } else { + type = 'standard'; } - return function () {}; -}()); + + var h = getHeaders(Env, type); + //console.log('PEWPEW', type, h); + applyHeaderMap(res, h); +}; (function () { if (!config.logFeedback) { return; } @@ -135,7 +120,7 @@ app.use('/blob', function (req, res, next) { if (req.method === 'HEAD') { Express.static(Path.join(__dirname, Env.paths.blob), { setHeaders: function (res, path, stat) { - res.set('Access-Control-Allow-Origin', '*'); + res.set('Access-Control-Allow-Origin', Env.disableEmbedding? Env.permittedEmbedders: '*'); res.set('Access-Control-Allow-Headers', 'Content-Length'); res.set('Access-Control-Expose-Headers', 'Content-Length'); } @@ -147,9 +132,9 @@ app.use('/blob', function (req, res, next) { app.use(function (req, res, next) { if (req.method === 'OPTIONS' && /\/blob\//.test(req.url)) { - res.setHeader('Access-Control-Allow-Origin', '*'); + res.setHeader('Access-Control-Allow-Origin', Env.disableEmbedding? Env.permittedEmbedders: '*'); res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); - res.setHeader('Access-Control-Allow-Headers', 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Content-Range,Range'); + res.setHeader('Access-Control-Allow-Headers', 'DNT,X-CustomHeader,Keep-Alive,User-Agent,X-Requested-With,If-Modified-Since,Cache-Control,Content-Type,Content-Range,Range,Access-Control-Allow-Origin'); res.setHeader('Access-Control-Max-Age', 1728000); res.setHeader('Content-Type', 'application/octet-stream; charset=utf-8'); res.setHeader('Content-Length', 0); @@ -249,6 +234,8 @@ var serveConfig = makeRouteCache(function (host) { premiumUploadSize: Env.premiumUploadSize, restrictRegistration: Env.restrictRegistration, httpSafeOrigin: Env.httpSafeOrigin, + disableEmbedding: Env.disableEmbedding, + fileHost: Env.fileHost, }, null, '\t'), '});' ].join(';\n') diff --git a/www/admin/inner.js b/www/admin/inner.js index 74169a074..7ed7651c3 100644 --- a/www/admin/inner.js +++ b/www/admin/inner.js @@ -54,6 +54,7 @@ define([ 'cp-admin-flush-cache', 'cp-admin-update-limit', 'cp-admin-registration', + 'cp-admin-disableembeds', 'cp-admin-email', 'cp-admin-instance-info-notice', @@ -293,40 +294,6 @@ define([ return $div; }; - create['registration'] = function () { - var key = 'registration'; - var $div = makeBlock(key); // Msg.admin_registrationHint, .admin_registrationTitle, .admin_registrationButton - - var state = APP.instanceStatus.restrictRegistration; - var $cbox = $(UI.createCheckbox('cp-settings-' + key, - Messages.admin_registrationTitle, - state, { label: { class: 'noTitle' } })); - var spinner = UI.makeSpinner($cbox); - var $checkbox = $cbox.find('input').on('change', function() { - spinner.spin(); - var val = $checkbox.is(':checked') || false; - $checkbox.attr('disabled', 'disabled'); - sFrameChan.query('Q_ADMIN_RPC', { - cmd: 'ADMIN_DECREE', - data: ['RESTRICT_REGISTRATION', [val]] - }, function (e, response) { - if (e || response.error) { - UI.warn(Messages.error); - console.error(e, response); - } - APP.updateStatus(function () { - spinner.done(); - state = APP.instanceStatus.restrictRegistration; - $checkbox[0].checked = state; - $checkbox.removeAttr('disabled'); - }); - }); - }); - $cbox.appendTo($div); - - return $div; - }; - var makeAdminCheckbox = function (data) { return function () { var state = data.getState(); @@ -354,7 +321,16 @@ define([ }; }; - // Msg.admin_registrationHint, .admin_registrationTitle, .admin_registrationButton + Messages.admin_cacheEvictionRequired = "Your server's internal state has been updated, but you may need to use the 'flush cache' button for clients to experience the intended effect."; // XXX + Messages.admin_reviewCheckupNotice = "It is also recommended that you review this instance's checkup page to confirm that it is configured correctly."; // XXX + var flushCacheNotice = function () { + UI.alert(h('span', [ + h('p', Messages.admin_cacheEvictionRequired), + h('p', Messages.admin_reviewCheckupNotice), + ])); + }; + + // Msg.admin_registrationHint, .admin_registrationTitle create['registration'] = makeAdminCheckbox({ key: 'registration', getState: function () { @@ -371,6 +347,32 @@ define([ } APP.updateStatus(function () { setState(APP.instanceStatus.restrictRegistration); + flushCacheNotice(); + }); + }); + }, + }); + + Messages.admin_disableembedsTitle = "Disable remote embedding"; // XXX + Messages.admin_disableembedsHint = "Remove options to embed pads and media-tags hosted on third party websites from sharing menus."; // XXX + // Msg.admin_disableembedsHint, .admin_disableembedsTitle + create['disableembeds'] = makeAdminCheckbox({ + key: 'disableembeds', + getState: function () { + return APP.instanceStatus.disableEmbedding; + }, + query: function (val, setState) { + sFrameChan.query('Q_ADMIN_RPC', { + cmd: 'ADMIN_DECREE', + data: ['DISABLE_EMBEDDING', [val]] + }, function (e, response) { + if (e || response.error) { + UI.warn(Messages.error); + console.error(e, response); + } + APP.updateStatus(function () { + setState(APP.instanceStatus.disableEmbedding); + flushCacheNotice(); }); }); }, @@ -1957,7 +1959,6 @@ define([ Messages.admin_bytesWrittenTitle = "Disk performance measurement window"; // XXX Messages.admin_bytesWrittenHint = "If you have enabled disk performance measurements then the duration of the window can be configured below."; // XXX Messages.admin_bytesWrittenDuration = "Duration of the window in milliseconds: {0}"; // XXX - //Messages.admin_defaultDuration = "admin_defaultDuration"; // XXX Messages.admin_setDuration = "Set duration"; // XXX var isPositiveInteger = function (n) { diff --git a/www/checkup/main.js b/www/checkup/main.js index 5e3d0ccfd..76f6b88d7 100644 --- a/www/checkup/main.js +++ b/www/checkup/main.js @@ -977,8 +977,8 @@ define([ 'img-src': ["'self'", 'data:', 'blob:', $outer], 'media-src': ['blob:'], - //'frame-ancestors': ['*'], // XXX IFF you want to support remote embedding - 'worker-src': ["'self'"], // , $outer, $sandbox], + 'frame-ancestors': ApiConfig.disableEmbedding? [$outer, $sandbox]: ['*'], + 'worker-src': ["'self'"], }); cb(result); }); @@ -1015,7 +1015,7 @@ define([ ], 'img-src': ["'self'", 'data:', 'blob:', $outer], 'media-src': ['blob:'], - //'frame-ancestors': ['*'], // XXX IFF you want to support remote embedding + 'frame-ancestors': ApiConfig.disableEmbedding? [$outer, $sandbox]: ['*'], 'worker-src': ["'self'"],//, $outer, $sandbox], }); @@ -1023,18 +1023,53 @@ define([ }); }); +/* Only two use-cases are currently supported: + 1. remote embedding is enabled, and fully permissive + 2. remote embedding is disabled, so media-tags can only be loaded on your instance + + Support for selectively enabling embedding on remote sites is far more complicated + and will need funding. +*/ + var checkAllowedOrigins = function (raw, url, msg, cb) { + var header = 'Access-Control-Allow-Origin'; + var expected; + if (ApiConfig.disableEmbedding) { + expected = trimmedSafe; + msg.appendChild(h('span', [ + 'This instance has been configured to disable support for embedding assets and documents in third-party websites. ', + 'In order for this setting to be effective while still permitting encrypted media to load locally ', + 'the ', + code(header), + ' should only match trusted domains.', + ' Under most circumstances it is sufficient to permit only the sandbox domain to load assets.', + " Remote embedding can be enabled via the admin panel.", + ])); + } else { + expected = '*'; + msg.appendChild(h('span', [ + "This instance has been configured to permit embedding assets and documents in third-party websites.", + 'Assets must be served with an ', + code(header), + ' header with a value of ', + code("'*'"), + '.', + ' Remote embedding can be disabled via the admin panel.', + ])); + } + if (raw === expected) { return void cb(true); } + cb({ + url: url, + response: raw, + disableEmbedding: ApiConfig.disableEmbedding, + }); + }; + 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 url = new URL('/', trimmedUnsafe).href; + Tools.common_xhr(url, function (xhr) { var raw = xhr.getResponseHeader(header); - cb(raw === "*" || raw); + checkAllowedOrigins(raw, url, msg, cb); }); }); @@ -1248,6 +1283,33 @@ define([ }); }); + assert(function (cb, msg) { + var url; + try { + url = new URL('/', trimmedUnsafe); + } catch (err) { + // if your configuration is bad enough that this throws + // then other tests should detect it. Let's just bail out + return void cb(true); + } + + // xhr.getResponseHeader and similar APIs don't behave as expected in insecure cross-origin contexts + // which prevents us from inspecting headers in a development context. We bail out early + // and assume it passed. The proper test will run as normal in production + if (url.protocol !== 'https') { return void cb(true); } + + var header = 'Access-Control-Allow-Origin'; + deferredPostMessage({ + command: 'GET_HEADER', + content: { + url: url.href, + header: header, + }, + }, function (raw) { + checkAllowedOrigins(raw, url.href, msg, cb); + }); + }); + var serverToken; Tools.common_xhr('/', function (xhr) { serverToken = xhr.getResponseHeader('server'); diff --git a/www/common/common-ui-elements.js b/www/common/common-ui-elements.js index 01e19f0ef..422820361 100644 --- a/www/common/common-ui-elements.js +++ b/www/common/common-ui-elements.js @@ -523,6 +523,15 @@ define([ UI.openCustomModal(modal); }; + UIElements.openDirectlyConfirmation = function (common, cb) { + cb = cb || Util.noop; + UI.confirm(h('p', Messages.ui_openDirectly), yes => { + if (!yes) { return void cb(yes); } + common.openDirectly(); + cb(yes); + }); + }; + UIElements.createButton = function (common, type, rightside, data, callback) { var AppConfig = common.getAppConfig(); var button; @@ -879,6 +888,14 @@ define([ .text(Messages.propertiesButton)) .click(common.prepareFeedback(type)) .click(function () { + var isTop; + try { + isTop = common.getMetadataMgr().getPrivateData().isTop; + } catch (err) { console.error(err); } + if (!isTop) { + return void UIElements.openDirectlyConfirmation(common); + } + sframeChan.event('EV_PROPERTIES_OPEN'); }); break; diff --git a/www/common/inner/share.js b/www/common/inner/share.js index 73d736346..7e2f0a6b0 100644 --- a/www/common/inner/share.js +++ b/www/common/inner/share.js @@ -1,5 +1,6 @@ define([ 'jquery', + '/api/config', '/common/common-util.js', '/common/common-hash.js', '/common/common-interface.js', @@ -11,10 +12,19 @@ define([ '/customize/messages.js', '/bower_components/nthen/index.js', '/customize/pages.js', -], function ($, Util, Hash, UI, UIElements, Feedback, Modal, h, Clipboard, +], function ($, ApiConfig, Util, Hash, UI, UIElements, Feedback, Modal, h, Clipboard, Messages, nThen, Pages) { var Share = {}; + var embeddableApps = [ + 'code', + 'form', + 'kanban', + 'pad', + 'slide', + 'whiteboard', + ].map(app => `/${app}/`); + var createShareWithFriends = function (config, onShare, linkGetter) { var common = config.common; var sframeChan = common.getSframeChannel(); @@ -771,7 +781,7 @@ define([ icon: "fa fa-link", active: !contactsActive, }]; - if (!opts.static) { + if (!opts.static && !ApiConfig.disableEmbedding && embeddableApps.includes(pathname)) { tabs.push({ getTab: getEmbedTab, title: Messages.share_embedCategory, @@ -965,11 +975,16 @@ define([ title: Messages.share_linkCategory, icon: "fa fa-link", active: !hasFriends, - }, { - getTab: getFileEmbedTab, - title: Messages.share_embedCategory, - icon: "fa fa-code", }]; + + if (!ApiConfig.disableEmbedding) { + tabs.push({ + getTab: getFileEmbedTab, + title: Messages.share_embedCategory, + icon: "fa fa-code", + }); + } + Modal.getModal(common, opts, tabs, cb); }; diff --git a/www/common/sframe-app-framework.js b/www/common/sframe-app-framework.js index e0962daf7..04821ce15 100644 --- a/www/common/sframe-app-framework.js +++ b/www/common/sframe-app-framework.js @@ -734,6 +734,10 @@ define([ var createFilePicker = function () { if (!common.isLoggedIn()) { return; } $embedButton = common.createButton('mediatag', true).click(function () { + if (!cpNfInner.metadataMgr.getPrivateData().isTop) { + return void UIElements.openDirectlyConfirmation(common); + } + var cfg = { types: ['file', 'link'], where: ['root'] diff --git a/www/common/sframe-common-outer.js b/www/common/sframe-common-outer.js index 91d11c20d..ed6962328 100644 --- a/www/common/sframe-common-outer.js +++ b/www/common/sframe-common-outer.js @@ -8,7 +8,33 @@ define([ ], function (nThen, ApiConfig, RequireConfig, Messages, $) { var common = {}; + var embeddableApps = [ + 'code', + 'form', + 'kanban', + 'pad', + 'slide', + 'whiteboard', + ].map(function (x) { + return `/${x}/`; + }); + common.initIframe = function (waitFor, isRt, pathname) { + if (window.top !== window) { + if (ApiConfig.disableEmbedding) { + return void window.alert(`This CryptPad instance's administrators have disabled remote embedding of its editors.`); + } + // even where embedding is not forbidden it should still be limited + // to apps that are explicitly permitted + if (!embeddableApps.includes(window.location.pathname)) { + return void window.alert(`Embedding this CryptPad editor in remote pages is not supported.`); + } + } + + if (window.location.origin !== ApiConfig.httpUnsafeOrigin) { + return void window.alert(`This page is configured to only be accessed via ${ApiConfig.httpUnsafeOrigin}.`); + } + var requireConfig = RequireConfig(); var lang = Messages._languageUsed; var themeKey = 'CRYPTPAD_STORE|colortheme'; @@ -641,6 +667,7 @@ define([ prefersDriveRedirect: Utils.LocalStore.getDriveRedirectPreference(), isPresent: parsed.hashData && parsed.hashData.present, isEmbed: parsed.hashData && parsed.hashData.embed, + isTop: window.top === window, canEdit: hashes && hashes.editHash, oldVersionHash: parsed.hashData && parsed.hashData.version < 2, // password isHistoryVersion: parsed.hashData && parsed.hashData.versionHash, @@ -838,14 +865,19 @@ define([ } }); - sframeChan.on('EV_OPEN_URL', function (url) { - if (url) { - var a = window.open(url); - if (!a) { - sframeChan.event('EV_POPUP_BLOCKED'); - } + var openURL = function (url) { + if (!url) { return; } + var a = window.open(url); + if (!a) { + sframeChan.event('EV_POPUP_BLOCKED'); } + }; + + sframeChan.on('EV_OPEN_URL_DIRECTLY', function () { + var url = currentPad.href; + openURL(url); }); + sframeChan.on('EV_OPEN_URL', openURL); sframeChan.on('EV_OPEN_UNSAFE_URL', function (url) { if (url) { diff --git a/www/common/sframe-common.js b/www/common/sframe-common.js index 8f97ba265..66977d664 100644 --- a/www/common/sframe-common.js +++ b/www/common/sframe-common.js @@ -662,6 +662,9 @@ define([ }); }; + funcs.openDirectly = function () { + ctx.sframeChan.event('EV_OPEN_URL_DIRECTLY'); + }; funcs.gotoURL = function (url) { ctx.sframeChan.event('EV_GOTO_URL', url); }; funcs.openURL = function (url) { ctx.sframeChan.event('EV_OPEN_URL', url); }; funcs.getBounceURL = function (url) { diff --git a/www/common/toolbar.js b/www/common/toolbar.js index 8931c57f5..11772ca06 100644 --- a/www/common/toolbar.js +++ b/www/common/toolbar.js @@ -582,6 +582,9 @@ MessengerUI, Messages, Pages) { hidden: true }); $shareBlock.click(function () { + if (!config.metadataMgr.getPrivateData().isTop) { + return void UIElements.openDirectlyConfirmation(Common); + } if (toolbar.isDeleted) { return void UI.warn(Messages.deletedFromServer); } @@ -609,7 +612,10 @@ MessengerUI, Messages, Pages) { h('i.fa.fa-unlock-alt'), h('span.cp-button-name', Messages.accessButton) ])); - $accessBlock.click(function () { + $accessBlock.click(function () { + if (!config.metadataMgr.getPrivateData().isTop) { + return void UIElements.openDirectlyConfirmation(Common); + } if (toolbar.isDeleted) { return void UI.warn(Messages.deletedFromServer); }