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
This commit is contained in:
Clynt Neiko Rupinta 2026-01-22 11:58:20 +08:00
parent 7747db994d
commit 19086ce709
5 changed files with 89 additions and 0 deletions

View file

@ -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}`);
}
}
}

View file

@ -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 %}

View file

@ -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 %}
}

View file

@ -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;

View file

@ -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 \