diff --git a/CHANGES b/CHANGES index f978cd437..0f06d82ec 100644 --- a/CHANGES +++ b/CHANGES @@ -2599,4 +2599,5 @@ * eventlog: keep 3 months * services: give static IPs to internal databases * eventlog: only prune login and logout events +* Support HSTS preloading diff --git a/dashboard/src/translation/en.json b/dashboard/src/translation/en.json index 69f0686e9..8c0cca562 100644 --- a/dashboard/src/translation/en.json +++ b/dashboard/src/translation/en.json @@ -1515,7 +1515,8 @@ "title": "Robots.txt", "txtPlaceholder": "Leave empty to allow all bots to index this app", "disableIndexingAction": "Disable indexing" - } + }, + "hstsPreload": "Enable HSTS preload for this site and all subdomains" }, "updates": { "info": { diff --git a/dashboard/src/translation/nl.json b/dashboard/src/translation/nl.json index c4feac03b..723c87664 100644 --- a/dashboard/src/translation/nl.json +++ b/dashboard/src/translation/nl.json @@ -812,7 +812,8 @@ "vultrToken": "Vultr Token", "jitsiHostname": "Jitsi Locatie", "wellKnownDescription": "De waardes worden door Cloudron gebruikt om te reageren op /.well-known/ URLs. Let op: de app moet bereikbaar zijn op het hoofddomein {{ domain }} om te kunnen werken. Lees de documentatie voor meer informatie.", - "hetznerToken": "Hetzner Token" + "hetznerToken": "Hetzner Token", + "cloudflareDefaultProxyStatus": "Inschakelen proxy voor nieuwe DNS regels" }, "title": "Domeinen & Certificaten", "addDomain": "Domein toevoegen", diff --git a/dashboard/src/translation/ru.json b/dashboard/src/translation/ru.json index 9513b9ceb..269694656 100644 --- a/dashboard/src/translation/ru.json +++ b/dashboard/src/translation/ru.json @@ -1364,7 +1364,8 @@ "fallbackCertInfo": "Сертификаты автоматически получаются и обновляются при помощи Let’s Encrypt. Смотрите текущий лимит запросов здесь.\nЭтот wildcard-сертификат будет использоваться в случае сбоя при получении сертификата Let’s Encrypt. В случае его отсутствия будет использован автоматически сгенерированный самоподписанный сертификат.", "jitsiHostname": "Расположение Jitsi", "wellKnownDescription": "Значения будут использованы Cloudron для /.well-known/ адресов. Учтите, что для функционирования необходимо, чтобы приложение было доступно на основном домене {{ domain }}. Подробнее можно узнать в документации.", - "hetznerToken": "Токен Hetzner" + "hetznerToken": "Токен Hetzner", + "cloudflareDefaultProxyStatus": "Активировать прокси для новых DNS записей" }, "addDomain": "Добавить домен", "removeDialog": { diff --git a/dashboard/src/views/app.html b/dashboard/src/views/app.html index 961fbf81c..41421a0e4 100644 --- a/dashboard/src/views/app.html +++ b/dashboard/src/views/app.html @@ -1282,6 +1282,15 @@ +
+
+ +
+
+ diff --git a/dashboard/src/views/app.js b/dashboard/src/views/app.js index a122e75c4..cee0bd48b 100644 --- a/dashboard/src/views/app.js +++ b/dashboard/src/views/app.js @@ -1063,11 +1063,13 @@ angular.module('Application').controller('AppController', ['$scope', '$location' robotsTxt: '', csp: '', + hstsPreload: false, show: function () { $scope.security.error = {}; $scope.security.robotsTxt = $scope.app.reverseProxyConfig.robotsTxt || ''; $scope.security.csp = $scope.app.reverseProxyConfig.csp || ''; + $scope.security.hstsPreload = $scope.app.reverseProxyConfig.hstsPreload || false; }, submit: function () { @@ -1076,7 +1078,8 @@ angular.module('Application').controller('AppController', ['$scope', '$location' var reverseProxyConfig = { robotsTxt: $scope.security.robotsTxt || null, // empty string resets - csp: $scope.security.csp || null // empty string resets + csp: $scope.security.csp || null, // empty string resets + hstsPreload: $scope.security.hstsPreload }; Client.configureApp($scope.app.id, 'reverse_proxy', reverseProxyConfig, function (error) { diff --git a/migrations/schema.sql b/migrations/schema.sql index a49a8828a..4f0736abe 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 - reverseProxyConfigJson TEXT, // { robotsTxt, csp } + reverseProxyConfigJson TEXT, // { robotsTxt, csp, hstsPreload } enableBackup BOOLEAN DEFAULT 1, // misnomer: controls automatic daily backups enableAutomaticUpdate BOOLEAN DEFAULT 1, enableMailbox BOOLEAN DEFAULT 1, // whether sendmail addon is enabled diff --git a/src/apps.js b/src/apps.js index e21d58e73..7fd7cc07d 100644 --- a/src/apps.js +++ b/src/apps.js @@ -1765,7 +1765,7 @@ async function setReverseProxyConfig(app, reverseProxyConfig, auditSource) { assert.strictEqual(typeof reverseProxyConfig, 'object'); assert.strictEqual(typeof auditSource, 'object'); - reverseProxyConfig = _.extend({ robotsTxt: null, csp: null }, reverseProxyConfig); + reverseProxyConfig = _.extend({ robotsTxt: null, csp: null, hstsPreload: false }, reverseProxyConfig); const appId = app.id; let error = validateCsp(reverseProxyConfig.csp); diff --git a/src/nginxconfig.ejs b/src/nginxconfig.ejs index d4731a4f3..be29caec1 100644 --- a/src/nginxconfig.ejs +++ b/src/nginxconfig.ejs @@ -95,7 +95,13 @@ server { # dhparams is generated only after dns setup ssl_dhparam /home/yellowtent/platformdata/dhparams.pem; <% } -%> + + <% if (hstsPreload) { -%> + # https://hstspreload.org/ + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"; + <% } else { -%> add_header Strict-Transport-Security "max-age=63072000"; + <% } -%> <% if ( ocsp ) { -%> # OCSP. LE certs are generated with must-staple flag so clients can enforce OCSP diff --git a/src/reverseproxy.js b/src/reverseproxy.js index 1e6b19f3c..d321ba16b 100644 --- a/src/reverseproxy.js +++ b/src/reverseproxy.js @@ -445,7 +445,8 @@ async function writeDashboardNginxConfig(vhost, certificatePath) { keyFilePath: certificatePath.keyFilePath, robotsTxtQuoted: JSON.stringify('User-agent: *\nDisallow: /\n'), proxyAuth: { enabled: false, id: null, location: nginxLocation('/') }, - ocsp: await isOcspEnabled(certificatePath.certFilePath) + ocsp: await isOcspEnabled(certificatePath.certFilePath), + hstsPreload: false }; const nginxConf = ejs.render(NGINX_APPCONFIG_EJS, data); const nginxConfigFilename = path.join(paths.NGINX_APPCONFIG_DIR, `dashboard/${vhost}.conf`); @@ -488,7 +489,8 @@ async function writeAppLocationNginxConfig(app, location, certificatePath) { hideHeaders: [], proxyAuth: { enabled: false }, upstreamUri: '', // only for endpoint === external - ocsp: await isOcspEnabled(certificatePath.certFilePath) + ocsp: await isOcspEnabled(certificatePath.certFilePath), + hstsPreload: !!app.reverseProxyConfig?.hstsPreload }; if (type === apps.LOCATION_TYPE_PRIMARY || type === apps.LOCATION_TYPE_ALIAS || type === apps.LOCATION_TYPE_SECONDARY) { @@ -708,7 +710,8 @@ async function writeDefaultConfig(options) { keyFilePath, robotsTxtQuoted: JSON.stringify('User-agent: *\nDisallow: /\n'), proxyAuth: { enabled: false, id: null, location: nginxLocation('/') }, - ocsp: false // self-signed cert + ocsp: false, // self-signed cert + hstsPreload: false }; const nginxConf = ejs.render(NGINX_APPCONFIG_EJS, data); const nginxConfigFilename = path.join(paths.NGINX_APPCONFIG_DIR, constants.NGINX_DEFAULT_CONFIG_FILE_NAME); diff --git a/src/routes/apps.js b/src/routes/apps.js index 007652301..babefe8b9 100644 --- a/src/routes/apps.js +++ b/src/routes/apps.js @@ -322,6 +322,8 @@ async function setReverseProxyConfig(req, res, next) { if (req.body.csp !== null && typeof req.body.csp !== 'string') return next(new HttpError(400, 'csp is not a string')); + if (typeof req.body.hstsPreload !== 'boolean') return next(new HttpError(400, 'hstsPreload must be a boolean')); + const [error] = await safe(apps.setReverseProxyConfig(req.app, req.body, AuditSource.fromRequest(req))); if (error) return next(BoxError.toHttpError(error));