diff --git a/CHANGES b/CHANGES index 1eab49298..dade402a9 100644 --- a/CHANGES +++ b/CHANGES @@ -2634,4 +2634,5 @@ * redis: update to 7.0.11 * ionos profitbricks: add new regions Berlin and Logrono * docker: update to 23.0.6 +* network: trusted IPs diff --git a/dashboard/src/js/client.js b/dashboard/src/js/client.js index b201d7e04..11935f20f 100644 --- a/dashboard/src/js/client.js +++ b/dashboard/src/js/client.js @@ -1128,9 +1128,7 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout }; Client.prototype.getBlocklist = function (callback) { - var config = {}; - - get('/api/v1/network/blocklist', config, function (error, data, status) { + get('/api/v1/network/blocklist', null, function (error, data, status) { if (error) return callback(error); if (status !== 200) return callback(new ClientError(status, data)); callback(null, data.blocklist); @@ -1146,6 +1144,23 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout }); }; + Client.prototype.getTrustedIps = function (callback) { + get('/api/v1/network/trusted_ips', null, function (error, data, status) { + if (error) return callback(error); + if (status !== 200) return callback(new ClientError(status, data)); + callback(null, data.trustedIps); + }); + }; + + Client.prototype.setTrustedIps = function (trustedIps, callback) { + post('/api/v1/network/trusted_ips', { trustedIps: trustedIps }, null, function (error, data, status) { + if (error) return callback(error); + if (status !== 200) return callback(new ClientError(status, data)); + + callback(null); + }); + }; + Client.prototype.setDynamicDnsConfig = function (enabled, callback) { post('/api/v1/settings/dynamic_dns', { enabled: enabled }, null, function (error, data, status) { if (error) return callback(error); diff --git a/dashboard/src/translation/en.json b/dashboard/src/translation/en.json index ef8d937c3..3d1610e7b 100644 --- a/dashboard/src/translation/en.json +++ b/dashboard/src/translation/en.json @@ -806,7 +806,13 @@ }, "configureIpv6": { "title": "Configure IPv6 Provider" - } + }, + "trustedIps": { + "description": "HTTP headers from matching IP addresses will be trusted", + "title": "Configure Trusted IPs", + "summary": "{{ trustCount }} IPs trusted" + }, + "trustedIpRanges": "Trusted IPs & Ranges " }, "services": { "title": "Services", diff --git a/dashboard/src/views/network.html b/dashboard/src/views/network.html index 4621cfeba..49ce72c77 100644 --- a/dashboard/src/views/network.html +++ b/dashboard/src/views/network.html @@ -71,6 +71,32 @@ + + + + +
+

{{ 'network.ipv6.title' | tr }}

+
+ +
+
+
+ {{ 'network.ipv6.description' | tr }} +
+
+ +
+
+
+ {{ 'network.ip.provider' | tr }} +
+
+ {{ prettyIpProviderName(ipv6Configure.provider) }} +
+
+ +
+
+ {{ 'network.ip.address' | tr }} +
+
+ {{ ipv6Configure.ipv6 }} + {{ ipv6Configure.serverIPv6 }} ({{ 'network.ip.detected' | tr }}) + {{ ipv6Configure.displayError }} +
+
+ +
+
+ {{ 'network.ip.interface' | tr }} +
+
+ {{ ipv6Configure.ifname }} +
+
+ +
+ +
+
+ +
+
+
+

{{ 'network.firewall.title' | tr }}

@@ -187,59 +264,18 @@ {{ 'network.firewall.blocklist' | tr:{ blockCount: blocklist.currentBlocklistLength } }}
- - -
-

{{ 'network.ipv6.title' | tr }}

-
- -
-
- {{ 'network.ipv6.description' | tr }} -
-
- -
-
-
- {{ 'network.ip.provider' | tr }} -
-
- {{ prettyIpProviderName(ipv6Configure.provider) }} -
-
- -
-
- {{ 'network.ip.address' | tr }} -
-
- {{ ipv6Configure.ipv6 }} - {{ ipv6Configure.serverIPv6 }} ({{ 'network.ip.detected' | tr }}) - {{ ipv6Configure.displayError }} -
-
- -
- {{ 'network.ip.interface' | tr }} + {{ 'network.trustedIpRanges' | tr }}
- {{ ipv6Configure.ifname }} -
-
- -
- -
-
- + {{ 'network.trustedIps.summary' | tr:{ trustCount: trustedIps.currentTrustedIpsLength } }}
+

{{ 'network.dyndns.title' | tr }}

diff --git a/dashboard/src/views/network.js b/dashboard/src/views/network.js index 0c5fb3813..6e6aa742d 100644 --- a/dashboard/src/views/network.js +++ b/dashboard/src/views/network.js @@ -192,6 +192,50 @@ angular.module('Application').controller('NetworkController', ['$scope', '$locat } }; + $scope.trustedIps = { + busy: false, + error: {}, + trustedIps: '', + currentTrustedIps: '', + currentTrustedIpsLength: 0, + + refresh: function () { + Client.getTrustedIps(function (error, result) { + if (error) return console.error(error); + + $scope.trustedIps.currentTrustedIps = result; + $scope.trustedIps.currentTrustedIpsLength = result.split('\n').filter(function (l) { return l.length !== 0 && l[0] !== '#'; }).length; + }); + }, + + show: function () { + $scope.trustedIps.error = {}; + $scope.trustedIps.trustedIps = $scope.trustedIps.currentTrustedIps; + + $('#trustedIpsModal').modal('show'); + }, + + submit: function () { + $scope.trustedIps.error = {}; + $scope.trustedIps.busy = true; + + Client.setTrustedIps($scope.trustedIps.trustedIps, function (error) { + $scope.trustedIps.busy = false; + if (error) { + $scope.trustedIps.error.trustedIps = error.message; + $scope.trustedIps.error.ip = error.message; + $scope.trustedIpsChangeForm.$setPristine(); + $scope.trustedIpsChangeForm.$setUntouched(); + return; + } + + $scope.trustedIps.refresh(); + + $('#trustedIpsModal').modal('hide'); + }); + } + }; + $scope.sysinfo = { busy: false, error: {}, @@ -276,6 +320,7 @@ angular.module('Application').controller('NetworkController', ['$scope', '$locat $scope.dyndnsConfigure.refresh(); $scope.ipv6Configure.refresh(); + $scope.trustedIps.refresh(); if ($scope.user.isAtLeastOwner) $scope.blocklist.refresh(); }); diff --git a/setup/start.sh b/setup/start.sh index b318a12b9..5d7215da8 100755 --- a/setup/start.sh +++ b/setup/start.sh @@ -167,6 +167,7 @@ mkdir -p "${PLATFORM_DATA_DIR}/nginx/applications/dashboard" mkdir -p "${PLATFORM_DATA_DIR}/nginx/cert" cp "${script_dir}/start/nginx/nginx.conf" "${PLATFORM_DATA_DIR}/nginx/nginx.conf" cp "${script_dir}/start/nginx/mime.types" "${PLATFORM_DATA_DIR}/nginx/mime.types" +touch "${PLATFORM_DATA_DIR}/nginx/trusted.ips" if ! grep -q "^Restart=" /etc/systemd/system/multi-user.target.wants/nginx.service; then # default nginx service file does not restart on crash echo -e "\n[Service]\nRestart=always\n" >> /etc/systemd/system/multi-user.target.wants/nginx.service diff --git a/setup/start/nginx/nginx.conf b/setup/start/nginx/nginx.conf index ce6803ab2..6d074a4f1 100644 --- a/setup/start/nginx/nginx.conf +++ b/setup/start/nginx/nginx.conf @@ -38,6 +38,7 @@ http { # zones for rate limiting limit_req_zone $binary_remote_addr zone=admin_login:10m rate=10r/s; # 10 request a second + include trusted.ips; include applications/*.conf; include applications/*/*.conf; } diff --git a/src/network.js b/src/network.js index afb2d8e83..87ab3235d 100644 --- a/src/network.js +++ b/src/network.js @@ -2,7 +2,7 @@ exports = module.exports = { getBlocklist, - setBlocklist + setBlocklist, }; const assert = require('assert'), diff --git a/src/paths.js b/src/paths.js index f41c12fda..d1af78d9e 100644 --- a/src/paths.js +++ b/src/paths.js @@ -50,6 +50,7 @@ exports = module.exports = { FIREWALL_BLOCKLIST_FILE: path.join(baseDir(), 'platformdata/firewall/blocklist.txt'), LDAP_ALLOWLIST_FILE: path.join(baseDir(), 'platformdata/firewall/ldap_allowlist.txt'), REVERSE_PROXY_REBUILD_FILE: path.join(baseDir(), 'platformdata/nginx/rebuild-needed'), + NGINX_TRUSTED_IPS_FILE: path.join(baseDir(), 'platformdata/nginx/trusted.ips'), BOX_DATA_DIR: path.join(baseDir(), 'boxdata/box'), MAIL_DATA_DIR: path.join(baseDir(), 'boxdata/mail'), diff --git a/src/reverseproxy.js b/src/reverseproxy.js index d321ba16b..4fdddb4b0 100644 --- a/src/reverseproxy.js +++ b/src/reverseproxy.js @@ -27,7 +27,10 @@ exports = module.exports = { removeAppConfigs, restoreFallbackCertificates, - handleCertificateProviderChanged + handleCertificateProviderChanged, + + getTrustedIps, + setTrustedIps }; const acme2 = require('./acme2.js'), @@ -52,7 +55,8 @@ const acme2 = require('./acme2.js'), settings = require('./settings.js'), shell = require('./shell.js'), sysinfo = require('./sysinfo.js'), - util = require('util'); + util = require('util'), + validator = require('validator'); const NGINX_APPCONFIG_EJS = fs.readFileSync(__dirname + '/nginxconfig.ejs', { encoding: 'utf8' }); const RESTART_SERVICE_CMD = path.join(__dirname, 'scripts/restartservice.sh'); @@ -728,3 +732,25 @@ async function handleCertificateProviderChanged(domain) { safe.fs.appendFileSync(paths.REVERSE_PROXY_REBUILD_FILE, `${domain}\n`, 'utf8'); } + +async function getTrustedIps() { + return await settings.getTrustedIps(); +} + +async function setTrustedIps(trustedIps) { + assert.strictEqual(typeof trustedIps, 'string'); + + let trustedIpsConfig = 'real_ip_header X-Forwarded-For;\nreal_ip_recursive on;\n'; + + for (const line of trustedIps.split('\n')) { + if (!line || line.startsWith('#')) continue; + const rangeOrIP = line.trim(); + // this checks for IPv4 and IPv6 + if (!validator.isIP(rangeOrIP) && !validator.isIPRange(rangeOrIP)) throw new BoxError(BoxError.BAD_FIELD, `${rangeOrIP} is not a valid IP or range`); + trustedIpsConfig += `set_real_ip_from ${rangeOrIP};\n`; + } + + await settings.setTrustedIps(trustedIps); + if (!safe.fs.writeFileSync(paths.NGINX_TRUSTED_IPS_FILE, trustedIpsConfig, 'utf8')) throw new BoxError(BoxError.FS_ERROR, safe.error.message); + await reload(); +} diff --git a/src/routes/network.js b/src/routes/network.js index 070448ac2..fe460a44b 100644 --- a/src/routes/network.js +++ b/src/routes/network.js @@ -2,7 +2,10 @@ exports = module.exports = { getBlocklist, - setBlocklist + setBlocklist, + + getTrustedIps, + setTrustedIps }; const assert = require('assert'), @@ -11,6 +14,7 @@ const assert = require('assert'), HttpError = require('connect-lastmile').HttpError, HttpSuccess = require('connect-lastmile').HttpSuccess, network = require('../network.js'), + reverseProxy = require('../reverseproxy.js'), safe = require('safetydance'); async function getBlocklist(req, res, next) { @@ -32,3 +36,21 @@ async function setBlocklist(req, res, next) { next(new HttpSuccess(200, {})); } + +async function getTrustedIps(req, res, next) { + const [error, trustedIps] = await safe(reverseProxy.getTrustedIps()); + if (error) return next(BoxError.toHttpError(error)); + + next(new HttpSuccess(200, { trustedIps })); +} + +async function setTrustedIps(req, res, next) { + assert.strictEqual(typeof req.body, 'object'); + + if (typeof req.body.trustedIps !== 'string') return next(new HttpError(400, 'trustedIps must be a string')); + + const [error] = await safe(reverseProxy.setTrustedIps(req.body.trustedIps, AuditSource.fromRequest(req))); + if (error) return next(BoxError.toHttpError(error)); + + next(new HttpSuccess(200, {})); +} diff --git a/src/server.js b/src/server.js index d93d6d0e8..70509cf45 100644 --- a/src/server.js +++ b/src/server.js @@ -282,8 +282,10 @@ async function initializeExpressSync() { }, routes.branding.set); // network routes - router.get ('/api/v1/network/blocklist', token, authorizeOwner, routes.network.getBlocklist); - router.post('/api/v1/network/blocklist', json, token, authorizeOwner, routes.network.setBlocklist); + router.get ('/api/v1/network/blocklist', token, authorizeOwner, routes.network.getBlocklist); + router.post('/api/v1/network/blocklist', json, token, authorizeOwner, routes.network.setBlocklist); + router.get ('/api/v1/network/trusted_ips', token, authorizeOwner, routes.network.getTrustedIps); + router.post('/api/v1/network/trusted_ips', json, token, authorizeOwner, routes.network.setTrustedIps); // settings routes (these are for the settings tab - avatar & name have public routes for normal users. see above) router.get ('/api/v1/settings/:setting', token, authorizeAdmin, routes.settings.get); diff --git a/src/settings.js b/src/settings.js index 0188bf2cd..41fe76e6c 100644 --- a/src/settings.js +++ b/src/settings.js @@ -67,6 +67,9 @@ exports = module.exports = { getFirewallBlocklist, setFirewallBlocklist, + getTrustedIps, + setTrustedIps, + getGhosts, setGhosts, @@ -119,6 +122,7 @@ exports = module.exports = { APPSTORE_API_TOKEN_KEY: 'appstore_api_token', APPSTORE_WEB_TOKEN_KEY: 'appstore_web_token', FIREWALL_BLOCKLIST_KEY: 'firewall_blocklist', + TRUSTED_IPS_KEY: 'trusted_ips_key', API_SERVER_ORIGIN_KEY: 'api_server_origin', WEB_SERVER_ORIGIN_KEY: 'web_server_origin', @@ -216,6 +220,7 @@ const gDefaults = (function () { result[exports.MAIL_FQDN_KEY] = ''; result[exports.FIREWALL_BLOCKLIST_KEY] = ''; + result[exports.TRUSTED_IPS_KEY] = ''; result[exports.API_SERVER_ORIGIN_KEY] = 'https://api.cloudron.io'; result[exports.WEB_SERVER_ORIGIN_KEY] = 'https://cloudron.io'; @@ -636,6 +641,19 @@ async function setFirewallBlocklist(blocklist) { await setBlob(exports.FIREWALL_BLOCKLIST_KEY, Buffer.from(blocklist)); } +async function getTrustedIps() { + const value = await get(exports.TRUSTED_IPS_KEY); + if (value === null) return gDefaults[exports.TRUSTED_IPS_KEY]; + + return value; +} + +async function setTrustedIps(trustedIps) { + assert.strictEqual(typeof trustedIps, 'string'); + + await set(exports.TRUSTED_IPS_KEY, trustedIps); +} + async function getGhosts() { const value = await get(exports.GHOSTS_CONFIG_KEY); if (value === null) return gDefaults[exports.GHOSTS_CONFIG_KEY];