diff --git a/migrations/20191014002401-apps-add-reverseProxyConfigJson.js b/migrations/20191014002401-apps-add-reverseProxyConfigJson.js new file mode 100644 index 000000000..67c891914 --- /dev/null +++ b/migrations/20191014002401-apps-add-reverseProxyConfigJson.js @@ -0,0 +1,30 @@ +'use strict'; + +var async = require('async'); + +exports.up = function(db, callback) { + db.runSql('ALTER TABLE apps ADD COLUMN reverseProxyConfigJson TEXT', function (error) { + if (error) return callback(error); + + db.all('SELECT id, robotsTxt FROM apps', function (error, apps) { + if (error) return callback(error); + + async.eachSeries(apps, function (app, iteratorDone) { + if (!app.robotsTxt) return iteratorDone(); + + db.runSql('UPDATE apps SET reverseProxyConfigJson=? WHERE id=?', [ JSON.stringify({ robotsTxt: JSON.stringify(app.robotsTxt) }), app.id ], iteratorDone); + }, function (error) { + if (error) return callback(error); + + db.runSql('ALTER TABLE apps DROP COLUMN robotsTxt', callback); + }); + }); + }); +}; + +exports.down = function(db, callback) { + async.series([ + db.runSql.bind(db, 'ALTER TABLE apps DROP COLUMN reverseProxyConfigJson'), + ], callback); +}; + diff --git a/migrations/schema.sql b/migrations/schema.sql index 1061be7f0..deb3934d6 100644 --- a/migrations/schema.sql +++ b/migrations/schema.sql @@ -81,7 +81,7 @@ CREATE TABLE IF NOT EXISTS apps( xFrameOptions VARCHAR(512), sso BOOLEAN DEFAULT 1, // whether user chose to enable SSO debugModeJson TEXT, // options for development mode - robotsTxt TEXT, + reverseProxyConfigJson TEXT, // { robotsTxt, frameAncestors } enableBackup BOOLEAN DEFAULT 1, // misnomer: controls automatic daily backups enableAutomaticUpdate BOOLEAN DEFAULT 1, mailboxName VARCHAR(128), // mailbox of this app. default allocated as '.app' diff --git a/src/appdb.js b/src/appdb.js index 7ebcc683a..b27d36c47 100644 --- a/src/appdb.js +++ b/src/appdb.js @@ -41,8 +41,8 @@ var assert = require('assert'), var APPS_FIELDS_PREFIXED = [ 'apps.id', 'apps.appStoreId', 'apps.installationState', 'apps.errorJson', 'apps.runState', 'apps.health', 'apps.containerId', 'apps.manifestJson', 'apps.httpPort', 'subdomains.subdomain AS location', 'subdomains.domain', 'apps.accessRestrictionJson', 'apps.memoryLimit', - 'apps.label', 'apps.tagsJson', 'apps.taskId', - 'apps.sso', 'apps.debugModeJson', 'apps.robotsTxt', 'apps.enableBackup', + 'apps.label', 'apps.tagsJson', 'apps.taskId', 'apps.reverseProxyConfigJson', + 'apps.sso', 'apps.debugModeJson', 'apps.enableBackup', 'apps.creationTime', 'apps.updateTime', 'apps.mailboxName', 'apps.enableAutomaticUpdate', 'apps.dataDir', 'apps.ts', 'apps.healthTime' ].join(','); @@ -61,6 +61,10 @@ function postProcess(result) { result.tags = safe.JSON.parse(result.tagsJson) || []; delete result.tagsJson; + assert(result.reverseProxyConfigJson === null || typeof result.reverseProxyConfigJson === 'string'); + result.reverseProxyConfig = safe.JSON.parse(result.reverseProxyConfigJson) || {}; + delete result.reverseProxyConfigJson; + assert(result.hostPorts === null || typeof result.hostPorts === 'string'); assert(result.environmentVariables === null || typeof result.environmentVariables === 'string'); @@ -241,7 +245,6 @@ function add(id, appStoreId, manifest, location, domain, portBindings, data, cal const installationState = data.installationState; const runState = data.runState; const sso = 'sso' in data ? data.sso : null; - const robotsTxt = 'robotsTxt' in data ? data.robotsTxt : null; const debugModeJson = data.debugMode ? JSON.stringify(data.debugMode) : null; const env = data.env || {}; const label = data.label || null; @@ -252,10 +255,10 @@ function add(id, appStoreId, manifest, location, domain, portBindings, data, cal queries.push({ query: 'INSERT INTO apps (id, appStoreId, manifestJson, installationState, runState, accessRestrictionJson, memoryLimit, ' - + 'sso, debugModeJson, robotsTxt, mailboxName, label, tagsJson) ' - + ' VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + + 'sso, debugModeJson, mailboxName, label, tagsJson) ' + + ' VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', args: [ id, appStoreId, manifestJson, installationState, runState, accessRestrictionJson, memoryLimit, - sso, debugModeJson, robotsTxt, mailboxName, label, tagsJson ] + sso, debugModeJson, mailboxName, label, tagsJson ] }); queries.push({ @@ -420,7 +423,7 @@ function updateWithConstraints(id, app, constraints, callback) { var fields = [ ], values = [ ]; for (var p in app) { - if (p === 'manifest' || p === 'tags' || p === 'accessRestriction' || p === 'debugMode' || p === 'error') { + if (p === 'manifest' || p === 'tags' || p === 'accessRestriction' || p === 'debugMode' || p === 'error' || p === 'reverseProxyConfig') { fields.push(`${p}Json = ?`); values.push(JSON.stringify(app[p])); } else if (p !== 'portBindings' && p !== 'location' && p !== 'domain' && p !== 'alternateDomains' && p !== 'env') { diff --git a/src/apps.js b/src/apps.js index c279a17cb..c11c99de7 100644 --- a/src/apps.js +++ b/src/apps.js @@ -23,7 +23,7 @@ exports = module.exports = { setMemoryLimit: setMemoryLimit, setAutomaticBackup: setAutomaticBackup, setAutomaticUpdate: setAutomaticUpdate, - setRobotsTxt: setRobotsTxt, + setReverseProxyConfig: setReverseProxyConfig, setCertificate: setCertificate, setDebugMode: setDebugMode, setEnvironment: setEnvironment, @@ -305,6 +305,22 @@ function validateRobotsTxt(robotsTxt) { return null; } +function validateFrameAncestors(frameAncestors) { + if (frameAncestors.length > 10) return new AppsError(AppsError.BAD_FIELD, 'frameAncestors can have only 10 items', { field: 'frameAncestors' }); + + if (frameAncestors.some(fa => !/^[a-zA-Z_\-*.]*$/.test(fa))) return new AppsError(AppsError.BAD_FIELD, 'frameAncestors is invalid', { field: 'frameAncestors' }); + + return null; +} + +function validateNginxHeaders(headers) { + if (headers.length > 10) return new AppsError(AppsError.BAD_FIELD, 'hideHeaders can have only 10 items', { field: 'hideHeaders' }); + + if (headers.some(h => !/^[a-zA-Z-]*$/.test(h))) return new AppsError(AppsError.BAD_FIELD, 'hideHeaders is invalid', { field: 'hideHeaders' }); + + return null; +} + function validateBackupFormat(format) { assert.strictEqual(typeof format, 'string'); @@ -1106,26 +1122,34 @@ function setAutomaticUpdate(appId, enable, auditSource, callback) { }); } -function setRobotsTxt(appId, robotsTxt, auditSource, callback) { +function setReverseProxyConfig(appId, reverseProxyConfig, auditSource, callback) { assert.strictEqual(typeof appId, 'string'); - assert(robotsTxt === null || typeof robotsTxt === 'string'); + assert.strictEqual(typeof reverseProxyConfig, 'object'); assert.strictEqual(typeof auditSource, 'object'); assert.strictEqual(typeof callback, 'function'); + reverseProxyConfig = _.extend({ robotsTxt: null, frameAncestors: null, hideHeaders: null }, reverseProxyConfig); + get(appId, function (error, app) { if (error) return callback(error); - error = validateRobotsTxt(robotsTxt); + error = validateFrameAncestors(reverseProxyConfig.frameAncestors); if (error) return callback(error); - reverseProxy.writeAppConfig(_.extend({}, app, { robotsTxt }), function (error) { + error = validateRobotsTxt(reverseProxyConfig.robotsTxt); + if (error) return callback(error); + + error = validateNginxHeaders(reverseProxyConfig.hideHeaders); + if (error) return callback(error); + + reverseProxy.writeAppConfig(_.extend({}, app, { reverseProxyConfig }), function (error) { if (error) return callback(new AppsError(AppsError.EXTERNAL_ERROR, 'Error writing nginx config')); - appdb.update(appId, { robotsTxt: robotsTxt }, function (error) { + appdb.update(appId, { reverseProxyConfig }, function (error) { if (error && error.reason === DatabaseError.NOT_FOUND) return callback(new AppsError(AppsError.NOT_FOUND, 'No such app')); if (error) return callback(new AppsError(AppsError.INTERNAL_ERROR, error)); - eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId: appId, app: app, robotsTxt: robotsTxt }); + eventlog.add(eventlog.ACTION_APP_CONFIGURE, auditSource, { appId, app, reverseProxyConfig }); callback(); }); diff --git a/src/nginxconfig.ejs b/src/nginxconfig.ejs index a8e6042a0..a43a6290a 100644 --- a/src/nginxconfig.ejs +++ b/src/nginxconfig.ejs @@ -97,6 +97,16 @@ server { <% if ( endpoint === 'admin' ) { -%> # CSP headers for the admin/dashboard resources add_header Content-Security-Policy "default-src 'none'; frame-src 'self' cloudron.io *.cloudron.io; connect-src wss: https: 'self' *.cloudron.io; script-src https: 'self' 'unsafe-inline' 'unsafe-eval'; img-src * data:; style-src https: 'unsafe-inline'; object-src 'none'; font-src https: 'self'; frame-ancestors 'none'; base-uri 'none'; form-action 'self';"; +<% } else { %> + <% if (frameAncestorsQuoted) { %> + add_header Content-Security-Policy "Frame-ancestors <%= frameAncestorsQuoted %>"; + <% } else { %> + add_header Content-Security-Policy "Frame-ancestors 'self'"; + <% } %> + + <% for (var i = 0; i < hiddenUpstreamHeaders.length; i++) { -%> + proxy_hide_header <%= hiddenUpstreamHeaders[i] %>; + <% } %> <% } -%> proxy_http_version 1.1; diff --git a/src/reverseproxy.js b/src/reverseproxy.js index d96d56569..7d3afc5ae 100644 --- a/src/reverseproxy.js +++ b/src/reverseproxy.js @@ -464,6 +464,12 @@ function writeAppNginxConfig(app, bundle, callback) { var sourceDir = path.resolve(__dirname, '..'); var endpoint = 'app'; + let frameAncestorsQuoted = null, robotsTxtQuoted = null, hiddenUpstreamHeaders = []; + const reverseProxyConfig = app.reverseProxyConfig || {}; // some of our code uses fake app objects + if (reverseProxyConfig.robotsTxt) robotsTxtQuoted = JSON.stringify(app.reverseProxyConfig.robotsTxt); + if (reverseProxyConfig.frameAncestors) frameAncestorsQuoted = app.reverseProxyConfig.frameAncestors.map(fa => `'${fa}'`).join(' '); + if (reverseProxyConfig.hideHeaders) hiddenUpstreamHeaders = app.reverseProxyConfig.hideHeaders; + var data = { sourceDir: sourceDir, adminOrigin: settings.adminOrigin(), @@ -473,7 +479,9 @@ function writeAppNginxConfig(app, bundle, callback) { endpoint: endpoint, certFilePath: bundle.certFilePath, keyFilePath: bundle.keyFilePath, - robotsTxtQuoted: app.robotsTxt ? JSON.stringify(app.robotsTxt) : null + robotsTxtQuoted, + frameAncestorsQuoted, + hiddenUpstreamHeaders }; var nginxConf = ejs.render(NGINX_APPCONFIG_EJS, data); @@ -502,7 +510,9 @@ function writeAppRedirectNginxConfig(app, fqdn, bundle, callback) { endpoint: 'redirect', certFilePath: bundle.certFilePath, keyFilePath: bundle.keyFilePath, - robotsTxtQuoted: null + robotsTxtQuoted: null, + frameAncestorsQuoted: null, + hiddenUpstreamHeaders: [] }; var nginxConf = ejs.render(NGINX_APPCONFIG_EJS, data); diff --git a/src/routes/apps.js b/src/routes/apps.js index ce4407a9d..dee2ea3a9 100644 --- a/src/routes/apps.js +++ b/src/routes/apps.js @@ -21,7 +21,7 @@ exports = module.exports = { setMemoryLimit: setMemoryLimit, setAutomaticBackup: setAutomaticBackup, setAutomaticUpdate: setAutomaticUpdate, - setRobotsTxt: setRobotsTxt, + setReverseProxyConfig: setReverseProxyConfig, setCertificate: setCertificate, setDebugMode: setDebugMode, setEnvironment: setEnvironment, @@ -255,13 +255,19 @@ function setAutomaticUpdate(req, res, next) { }); } -function setRobotsTxt(req, res, next) { +function setReverseProxyConfig(req, res, next) { assert.strictEqual(typeof req.body, 'object'); assert.strictEqual(typeof req.params.id, 'string'); if (req.body.robotsTxt !== null && typeof req.body.robotsTxt !== 'string') return next(new HttpError(400, 'robotsTxt is not a string')); - apps.setRobotsTxt(req.params.id, req.body.robotsTxt, auditSource.fromRequest(req), function (error) { + if (!Array.isArray(req.body.frameAncestors)) return next(new HttpError(400, 'frameAncestors must be an array')); + if (req.body.frameAncestors.some(fa => typeof fa !== 'string')) return next(new HttpError(400, 'frameAncestors array must contain array of strings')); + + if (!Array.isArray(req.body.hideHeaders)) return next(new HttpError(400, 'hideHeaders must be an array')); + if (req.body.hideHeaders.some(h => typeof h !== 'string')) return next(new HttpError(400, 'hideHeaders array must contain array of strings')); + + apps.setReverseProxyConfig(req.params.id, req.body, auditSource.fromRequest(req), function (error) { if (error) return next(toHttpError(error)); next(new HttpSuccess(200, {})); diff --git a/src/routes/test/apps-test.js b/src/routes/test/apps-test.js index 647029c09..d53da7829 100644 --- a/src/routes/test/apps-test.js +++ b/src/routes/test/apps-test.js @@ -1040,18 +1040,9 @@ describe('App API', function () { }); }); - describe('configure robotsTxt', function () { - it('fails with missing robotsTxt', function (done) { - superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure/robots_txt') - .query({ access_token: token }) - .end(function (err, res) { - expect(res.statusCode).to.equal(400); - done(); - }); - }); - + describe('configure reverseProxy - robotsTxt', function () { it('fails with bad robotsTxt', function (done) { - superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure/robots_txt') + superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure/reverse_proxy') .query({ access_token: token }) .send({ robotsTxt: 34 }) .end(function (err, res) { @@ -1061,9 +1052,9 @@ describe('App API', function () { }); it('can set robotsTxt', function (done) { - superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure/robots_txt') + superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure/reverse_proxy') .query({ access_token: token }) - .send({ robotsTxt: 'any string is good' }) + .send({ robotsTxt: 'any string is good', frameAncestors: [], hideHeaders: [] }) .end(function (err, res) { expect(res.statusCode).to.equal(200); done(); @@ -1071,9 +1062,69 @@ describe('App API', function () { }); it('can reset robotsTxt', function (done) { - superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure/robots_txt') + superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure/reverse_proxy') .query({ access_token: token }) - .send({ robotsTxt: null }) + .send({ robotsTxt: null, frameAncestors: [], hideHeaders: [] }) + .end(function (err, res) { + expect(res.statusCode).to.equal(200); + done(); + }); + }); + + it('fails with bad frame-ancestors', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure/reverse_proxy') + .query({ access_token: token }) + .send({ robotsTxt: null, frameAncestors: [ 34 ], hideHeaders: [] }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + done(); + }); + }); + + it('can set frame-ancestors', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure/reverse_proxy') + .query({ access_token: token }) + .send({ robotsTxt: null, frameAncestors: [ 'www.example.com' ], hideHeaders: [] }) + .end(function (err, res) { + expect(res.statusCode).to.equal(200); + done(); + }); + }); + + it('can reset frame-ancestors', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure/reverse_proxy') + .query({ access_token: token }) + .send({ robotsTxt: null, frameAncestors: [], hideHeaders: [] }) + .end(function (err, res) { + expect(res.statusCode).to.equal(200); + done(); + }); + }); + + it('fails with bad hideHeaders', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure/reverse_proxy') + .query({ access_token: token }) + .send({ robotsTxt: null, frameAncestors: [], hideHeaders: 34 }) + .end(function (err, res) { + expect(res.statusCode).to.equal(400); + done(); + }); + }); + + it('can set hideHeaders', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure/reverse_proxy') + .query({ access_token: token }) + .send({ robotsTxt: null, frameAncestors: [], hideHeaders: [ 'Content-Security-Policy' ] }) + .end(function (err, res) { + expect(res.statusCode).to.equal(200); + done(); + }); + }); + + it('can reset hideHeaders', function (done) { + superagent.post(SERVER_URL + '/api/v1/apps/' + APP_ID + '/configure/reverse_proxy') + .query({ access_token: token }) + .send({ robotsTxt: null, frameAncestors: [], hideHeaders: [] }) .end(function (err, res) { expect(res.statusCode).to.equal(200); done(); diff --git a/src/server.js b/src/server.js index c237bf034..3ee30d52c 100644 --- a/src/server.js +++ b/src/server.js @@ -247,7 +247,7 @@ function initializeExpressSync() { router.post('/api/v1/apps/:id/configure/memory_limit', appsManageScope, routes.apps.setMemoryLimit); router.post('/api/v1/apps/:id/configure/automatic_backup', appsManageScope, routes.apps.setAutomaticBackup); router.post('/api/v1/apps/:id/configure/automatic_update', appsManageScope, routes.apps.setAutomaticUpdate); - router.post('/api/v1/apps/:id/configure/robots_txt', appsManageScope, routes.apps.setRobotsTxt); + router.post('/api/v1/apps/:id/configure/reverse_proxy', appsManageScope, routes.apps.setReverseProxyConfig); router.post('/api/v1/apps/:id/configure/cert', appsManageScope, routes.apps.setCertificate); router.post('/api/v1/apps/:id/configure/debug_mode', appsManageScope, routes.apps.setDebugMode); router.post('/api/v1/apps/:id/configure/mailbox', appsManageScope, routes.apps.setMailbox); diff --git a/src/test/apps-test.js b/src/test/apps-test.js index ae0314f4a..19027547b 100644 --- a/src/test/apps-test.js +++ b/src/test/apps-test.js @@ -113,7 +113,7 @@ describe('Apps', function () { portBindings: { PORT: 5678 }, accessRestriction: null, memoryLimit: 0, - robotsTxt: null, + reverseProxyConfig: null, sso: false, env: { 'CUSTOM_KEY': 'CUSTOM_VALUE' @@ -155,7 +155,7 @@ describe('Apps', function () { portBindings: {}, accessRestriction: { users: [ 'someuser', USER_0.id ], groups: [ GROUP_1.id ] }, memoryLimit: 0, - robotsTxt: null, + reverseProxyConfig: null, sso: false, env: {}, dataDir: '', diff --git a/src/test/database-test.js b/src/test/database-test.js index 3a15d6eda..c3a6cff64 100644 --- a/src/test/database-test.js +++ b/src/test/database-test.js @@ -407,7 +407,7 @@ describe('database', function () { memoryLimit: 4294967296, sso: true, debugMode: null, - robotsTxt: null, + reverseProxyConfig: {}, enableBackup: true, env: {}, mailboxName: 'talktome', @@ -984,7 +984,7 @@ describe('database', function () { memoryLimit: 4294967296, sso: true, debugMode: null, - robotsTxt: null, + reverseProxyConfig: {}, enableBackup: true, alternateDomains: [], env: { @@ -1015,7 +1015,7 @@ describe('database', function () { memoryLimit: 0, sso: true, debugMode: null, - robotsTxt: null, + reverseProxyConfig: {}, enableBackup: true, alternateDomains: [], env: {}, @@ -1024,7 +1024,8 @@ describe('database', function () { dataDir: null, tags: [], label: null, - taskId: null + taskId: null, + reverseProxyConfig: {} }; before(function (done) {