From 808f9eca4c316b6d2f5fcc4404c8007a914520a3 Mon Sep 17 00:00:00 2001 From: CallMeTechie <34693633+CallMeTechie@users.noreply.github.com> Date: Sat, 25 Jul 2026 22:17:24 +0200 Subject: [PATCH] fix(security): bind loopback by default and stop ACME attempts for the ownership marker --- .env.example | 6 ++- CHANGELOG.md | 5 +++ config/default.js | 7 ++- deploy/.env.example | 6 ++- src/services/caddyConfig.js | 8 +++- src/services/caddyOwner.js | 1 + tests/bind_host_and_marker_tls.test.js | 60 ++++++++++++++++++++++++++ 7 files changed, 89 insertions(+), 4 deletions(-) create mode 100644 tests/bind_host_and_marker_tls.test.js diff --git a/.env.example b/.env.example index 7640ed93..362d2aa9 100644 --- a/.env.example +++ b/.env.example @@ -1,6 +1,10 @@ # ─── Application ───────────────────────────────────── GC_APP_NAME=GateControl -GC_HOST=0.0.0.0 +# Loopback by default: the shipped compose uses network_mode: host, so +# 0.0.0.0 would expose the admin UI in plain HTTP on every interface — +# past Caddy, TLS and HSTS. Caddy reaches the app on 127.0.0.1. +# Set 0.0.0.0 only if you publish the port from a bridge network. +GC_HOST=127.0.0.1 GC_PORT=3000 GC_BASE_URL=https://gate.example.com GC_SECRET= diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ad8e026..ba2aa6e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,13 @@ ## [Unreleased] +### Security +- **Das Admin-UI war standardmäßig unverschlüsselt aus dem Internet erreichbar.** Die ausgelieferte Compose-Datei nutzt `network_mode: host`; zusammen mit dem bisherigen Default `GC_HOST=0.0.0.0` lauschte der Node-Prozess damit auf allen Interfaces des Hosts — an Caddy, TLS und HSTS vorbei. Der Default bindet jetzt `127.0.0.1`, in `config/default.js` **und** in beiden `.env.example`. Caddy erreicht die App unverändert über `127.0.0.1:3000`. + **Achtung beim Update:** Wer den Port aus einem Bridge-Netzwerk veröffentlicht statt Host-Networking zu nutzen, muss `GC_HOST=0.0.0.0` künftig ausdrücklich setzen. Bestehende Installationen übernehmen die Änderung erst nach Anpassung der `.env` und einem Neustart des Containers. + ### Fixed - Einstellungen → Allgemein: Das Standard-Design ließ sich in den Themes Classic und Pro nicht auf Aurora stellen — der Aurora-Knopf fehlte dort, obwohl der Server den Wert längst akzeptiert. Beide Templates bieten jetzt alle drei Designs an, ein Paritätstest hält die Auswahl über alle Themes und beide Seiten (Profil + Einstellungen) synchron. +- Caddy versuchte dauerhaft, für den internen Ownership-Marker `gc-owner.invalid` ein öffentliches Zertifikat zu beziehen, was den Log mit fehlschlagenden ACME-Versuchen füllte. Die Marker-Route wird erst nach dem Aufbau der TLS-Automation angehängt und erreichte deren TLD-Klassifizierung nie; sie ist jetzt ausdrücklich vom automatischen HTTPS ausgenommen. --- diff --git a/config/default.js b/config/default.js index 8ba78f95..a79622e3 100644 --- a/config/default.js +++ b/config/default.js @@ -25,7 +25,12 @@ function envList(key, fallback) { const config = { app: { name: env('GC_APP_NAME', 'GateControl'), - host: env('GC_HOST', '0.0.0.0'), + // Loopback by default: the shipped compose runs with `network_mode: host`, + // so binding 0.0.0.0 would put the admin UI on every interface in plain + // HTTP — past Caddy, TLS and HSTS. Caddy reaches the app on 127.0.0.1 + // (see caddyConfig.js upstreams). Deployments that publish the port from a + // bridge network must set GC_HOST=0.0.0.0 explicitly. + host: env('GC_HOST', '127.0.0.1'), port: envInt('GC_PORT', 3000), baseUrl: env('GC_BASE_URL', 'http://localhost:3000'), secret: env('GC_SECRET', ''), diff --git a/deploy/.env.example b/deploy/.env.example index bfb7273c..f869bb69 100644 --- a/deploy/.env.example +++ b/deploy/.env.example @@ -1,6 +1,10 @@ # ─── Application ───────────────────────────────────── GC_APP_NAME=GateControl -GC_HOST=0.0.0.0 +# Loopback by default: the shipped compose uses network_mode: host, so +# 0.0.0.0 would expose the admin UI in plain HTTP on every interface — +# past Caddy, TLS and HSTS. Caddy reaches the app on 127.0.0.1. +# Set 0.0.0.0 only if you publish the port from a bridge network. +GC_HOST=127.0.0.1 GC_PORT=3000 GC_BASE_URL=https://gate.example.com GC_SECRET= diff --git a/src/services/caddyConfig.js b/src/services/caddyConfig.js index e3cf9aff..8a1b4260 100644 --- a/src/services/caddyConfig.js +++ b/src/services/caddyConfig.js @@ -46,7 +46,7 @@ const { buildRouteAuthProxy, buildAuthHandlerChain } = require('./caddyAuthSubro const { getAclPeers, setAclPeers } = require('./caddyAcl'); const { renderMaintenancePage } = require('./caddyMaintenance'); const { renderAccessWindowPage } = require('./caddyAccessWindow'); -const { getOwnerId, ownerMarkerRoute, extractOwner, ownershipDecision } = require('./caddyOwner'); +const { getOwnerId, ownerMarkerRoute, extractOwner, ownershipDecision, MARKER_HOST } = require('./caddyOwner'); const { caddyApi, _caddyApi, @@ -843,6 +843,12 @@ function buildCaddyConfig(injectedRoutes, options = {}) { caddyConfig.apps.http.servers.srv0 = { listen: [':443', ':80'], routes: serverRoutes, + // The marker host is an RFC 6761 reserved name that can never be issued a + // public certificate. It is appended AFTER buildTlsAutomation() has run, + // so it never reaches the TLD classification there and would otherwise + // fall through to Caddy's automatic HTTPS — producing a permanent stream + // of failing ACME attempts in the log. + automatic_https: { skip: [MARKER_HOST] }, logs: { default_logger_name: 'access', }, diff --git a/src/services/caddyOwner.js b/src/services/caddyOwner.js index cc8721fd..eaecc4eb 100644 --- a/src/services/caddyOwner.js +++ b/src/services/caddyOwner.js @@ -159,6 +159,7 @@ function _resetOwnerCache() { module.exports = { OWNER_ID_PREFIX, + MARKER_HOST, getOwnerId, ownerMarkerRoute, extractOwner, diff --git a/tests/bind_host_and_marker_tls.test.js b/tests/bind_host_and_marker_tls.test.js new file mode 100644 index 00000000..632f2a88 --- /dev/null +++ b/tests/bind_host_and_marker_tls.test.js @@ -0,0 +1,60 @@ +'use strict'; +const { test } = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); + +const root = path.join(__dirname, '..'); +const read = (p) => fs.readFileSync(path.join(root, p), 'utf8'); + +// ── Bind-Adresse ──────────────────────────────────────────────────────────── +// Die ausgelieferte Compose-Datei nutzt `network_mode: host`. Es gibt also kein +// Port-Mapping, das die Bindung eingrenzen könnte: was der Prozess bindet, liegt +// direkt auf allen Interfaces des Hosts. Ein Default von 0.0.0.0 stellt damit das +// Admin-UI unverschlüsselt ins Netz — an Caddy, TLS und HSTS vorbei. + +test('the shipped compose files still use host networking (premise of this guard)', () => { + for (const f of ['docker-compose.yml', 'deploy/docker-compose.yml']) { + assert.match(read(f), /network_mode:\s*host/, `${f} nutzt kein host networking mehr — Annahme dieses Tests prüfen`); + } +}); + +test('the code default binds loopback, not every interface', () => { + const src = read('config/default.js'); + const m = src.match(/host:\s*env\('GC_HOST',\s*'([^']+)'\)/); + assert.ok(m, 'GC_HOST-Default nicht gefunden'); + assert.equal(m[1], '127.0.0.1'); +}); + +test('both shipped .env examples default to loopback', () => { + for (const f of ['.env.example', 'deploy/.env.example']) { + const m = read(f).match(/^GC_HOST=(.*)$/m); + assert.ok(m, `${f}: GC_HOST nicht gefunden`); + assert.equal(m[1].trim(), '127.0.0.1', `${f} bindet auf ${m[1]}`); + } +}); + +test('caddy reaches the app over loopback, so the loopback bind cannot break it', () => { + const src = read('src/services/caddyConfig.js'); + assert.match(src, /dial:\s*`127\.0\.0\.1:\$\{config\.app\.port\}`/); + assert.doesNotMatch(src, /dial:\s*`0\.0\.0\.0:/); +}); + +// ── Marker-Host ───────────────────────────────────────────────────────────── +// `gc-owner.invalid` wird als Ownership-Marker NACH buildTlsAutomation() an die +// Routen gehängt und erreicht dort die TLD-Klassifizierung nie. Ohne expliziten +// Ausschluss greift Caddys automatisches HTTPS und versucht dauerhaft, für einen +// RFC-6761-Namen ein öffentliches Zertifikat zu holen. + +test('the marker host is excluded from automatic HTTPS', () => { + const { MARKER_HOST } = require('../src/services/caddyOwner'); + assert.equal(MARKER_HOST, 'gc-owner.invalid'); + const src = read('src/services/caddyConfig.js'); + assert.match(src, /automatic_https:\s*\{\s*skip:\s*\[MARKER_HOST\]\s*\}/, + 'srv0 schließt den Marker-Host nicht von der automatischen Zertifikatsvergabe aus'); +}); + +test('the marker host is still classified as non-public if it ever reaches the classifier', () => { + const { NON_PUBLIC_TLDS } = require('../src/services/caddyTlsAutomation'); + assert.ok(NON_PUBLIC_TLDS.has('invalid'), 'invalid fehlt in NON_PUBLIC_TLDS'); +});