From 19086ce7095f42f6a4f488b875665cd9fe4dcb2e Mon Sep 17 00:00:00 2001 From: Clynt Neiko Rupinta Date: Thu, 22 Jan 2026 11:58:20 +0800 Subject: [PATCH] fix: check access list before force SSL redirect Fixes #5207 - Security issue where Force SSL leaks host existence When both Force SSL and an Access List are active on a Proxy Host, HTTP requests from unauthorized IPs were receiving a 301 redirect instead of being blocked. This allowed attackers to enumerate valid hosts by brute-forcing the Host header. Solution: Use nginx geo module to check IP access before the SSL redirect. Only allowed IPs get redirected to HTTPS; denied IPs fall through to the access phase and receive 403. Changes: - Add geo block template for IP-based access control - Modify _forced_ssl.conf to check geo variable before redirecting - Generate geo config files when access lists are created/updated - Include geo configs at http level in nginx.conf - Create access_geo directory on startup --- backend/internal/access-list.js | 57 +++++++++++++++++++ backend/templates/_forced_ssl.conf | 18 ++++++ backend/templates/access_list_geo.conf | 10 ++++ docker/rootfs/etc/nginx/nginx.conf | 3 + .../s6-overlay/s6-rc.d/prepare/20-paths.sh | 1 + 5 files changed, 89 insertions(+) create mode 100644 backend/templates/access_list_geo.conf diff --git a/backend/internal/access-list.js b/backend/internal/access-list.js index 60a7105d..0a84b187 100644 --- a/backend/internal/access-list.js +++ b/backend/internal/access-list.js @@ -1,4 +1,6 @@ import fs from "node:fs"; +import { dirname } from "node:path"; +import { fileURLToPath } from "node:url"; import batchflow from "batchflow"; import _ from "lodash"; import errs from "../lib/error.js"; @@ -11,6 +13,9 @@ import proxyHostModel from "../models/proxy_host.js"; import internalAuditLog from "./audit-log.js"; import internalNginx from "./nginx.js"; +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + const omissions = () => { return ["is_deleted"]; }; @@ -311,6 +316,13 @@ const internalAccessList = { // do nothing } + // delete the geo config file + try { + fs.unlinkSync(`/data/nginx/access_geo/${row.id}.conf`); + } catch (_err) { + // do nothing + } + // 4. audit log await internalAuditLog.add(access, { action: "deleted", @@ -432,6 +444,7 @@ const internalAccessList = { * @param {Integer} list.id * @param {String} list.name * @param {Array} list.items + * @param {Array} list.clients * @returns {Promise} */ build: async (list) => { @@ -482,6 +495,50 @@ const internalAccessList = { }); }); } + + // 4. Build geo config file for IP-based access control (used by Force SSL) + await internalAccessList.buildGeoConfig(list); + }, + + /** + * Build geo config file for IP-based access control + * This is used to check IP access before Force SSL redirect + * @param {Object} list + * @param {Integer} list.id + * @param {String} list.name + * @param {Array} list.clients + * @returns {Promise} + */ + buildGeoConfig: async (list) => { + const geoDir = '/data/nginx/access_geo'; + const geoFile = `${geoDir}/${list.id}.conf`; + + // Ensure directory exists + try { + fs.mkdirSync(geoDir, { recursive: true }); + } catch (_err) { + // do nothing + } + + // Remove existing geo file + try { + fs.unlinkSync(geoFile); + } catch (_err) { + // do nothing + } + + // Only create geo config if there are client IP rules + if (list.clients && list.clients.length > 0) { + logger.info(`Building Geo config file #${list.id} for: ${list.name}`); + + const renderEngine = utils.getRenderEngine(); + const template = fs.readFileSync(`${__dirname}/../templates/access_list_geo.conf`, { encoding: 'utf8' }); + + const config = await renderEngine.parseAndRender(template, list); + fs.writeFileSync(geoFile, config, { encoding: 'utf8' }); + + logger.success(`Built Geo config file #${list.id} for: ${list.name}`); + } } } diff --git a/backend/templates/_forced_ssl.conf b/backend/templates/_forced_ssl.conf index 7fade20c..610cde7a 100644 --- a/backend/templates/_forced_ssl.conf +++ b/backend/templates/_forced_ssl.conf @@ -1,6 +1,24 @@ {% if certificate and certificate_id > 0 -%} {% if ssl_forced == 1 or ssl_forced == true %} +{% if access_list %}{% assign clients_size = access_list.clients | size %}{% else %}{% assign clients_size = 0 %}{% endif %} +{% if access_list_id > 0 and clients_size > 0 %} + # Force SSL (only for allowed IPs - denied IPs will get 403 from access rules) + set $redirect_to_ssl 0; + if ($scheme = "http") { + set $redirect_to_ssl 1; + } + if ($access_list_{{ access_list_id }}_allowed = 0) { + set $redirect_to_ssl 0; + } + if ($request_uri = /.well-known/acme-challenge/test-challenge) { + set $redirect_to_ssl 0; + } + if ($redirect_to_ssl = 1) { + return 301 https://$host$request_uri; + } +{% else %} # Force SSL include conf.d/include/force-ssl.conf; {% endif %} +{% endif %} {% endif %} \ No newline at end of file diff --git a/backend/templates/access_list_geo.conf b/backend/templates/access_list_geo.conf new file mode 100644 index 00000000..35da5698 --- /dev/null +++ b/backend/templates/access_list_geo.conf @@ -0,0 +1,10 @@ +# Geo-based access control for access list {{ id }} +# Used to check IP access before Force SSL redirect +geo $access_list_{{ id }}_allowed { + default 0; + {% for client in clients %} + {% if client.directive == "allow" %} + {{ client.address }} 1; + {% endif %} + {% endfor %} +} diff --git a/docker/rootfs/etc/nginx/nginx.conf b/docker/rootfs/etc/nginx/nginx.conf index 0a83ef0c..a5e5faf0 100644 --- a/docker/rootfs/etc/nginx/nginx.conf +++ b/docker/rootfs/etc/nginx/nginx.conf @@ -72,6 +72,9 @@ http { # Custom include /data/nginx/custom/http_top[.]conf; + # Access list geo definitions for Force SSL + include /data/nginx/access_geo/*.conf; + # Files generated by NPM include /etc/nginx/conf.d/*.conf; include /data/nginx/default_host/*.conf; diff --git a/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/20-paths.sh b/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/20-paths.sh index 2f59ef41..9b9f3223 100755 --- a/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/20-paths.sh +++ b/docker/rootfs/etc/s6-overlay/s6-rc.d/prepare/20-paths.sh @@ -27,6 +27,7 @@ mkdir -p \ /data/nginx/stream \ /data/nginx/dead_host \ /data/nginx/temp \ + /data/nginx/access_geo \ /data/letsencrypt-acme-challenge \ /run/nginx \ /tmp/nginx/body \