Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,17 @@
### 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.
- **API-Token konnten sitzungs-exklusive Einstellungen über eine Pfadvariante schreiben.** Die Sperrliste in `/api/v1/settings` prüfte den Pfad exakt, Express routet aber ohne Beachtung von Groß-/Kleinschreibung und abschließendem Schrägstrich — `PUT /api/v1/settings/security/` oder `.../ROUTE-BLOCK-DEFAULT` liefen am Guard vorbei (und dabei auch an der CSRF-Prüfung, die für Token-Anfragen entfällt). Der Pfad wird jetzt vor dem Abgleich normalisiert; das wirkt für alle gesperrten Routen gleichzeitig.

### Added
- Die Kontaktadresse für Let's Encrypt lässt sich in Einstellungen → Allgemein setzen und wirkt sofort, ohne Container-Neustart. Bisher war sie nur über `GC_CADDY_EMAIL` in der `.env` konfigurierbar. Ist das Feld leer, gilt weiterhin der Wert aus der `.env`. Die Einstellung ist bewusst nur mit einer Sitzung schreibbar, nicht per API-Token — sie bestimmt das Konto für alle öffentlichen Zertifikate.

### 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.
- Einstellungen → Portal: Beim Setzen einer Portal-Basisdomain wurde nur die Basisdomain auf ihre DNS-Verifizierung geprüft, nie der daraus zusammengesetzte Host `<präfix>.<basis>`. Fehlte dessen A/AAAA-Eintrag, landete er trotzdem in der TLS-Automation und Caddy versuchte 30 Tage lang (`max_duration`) erfolglos ein Zertifikat beim produktiven Let's-Encrypt-CA zu beziehen — das Kontingent von 5 fehlgeschlagenen Prüfungen pro Stunde und Hostname war sofort erschöpft, ohne jede Rückmeldung im UI. Der zusammengesetzte Host wird jetzt vor dem Speichern selbst aufgelöst und mit der Server-IP verglichen; passt er nicht, lehnt die API mit einem eigenen Fehlertext ab. Ein nicht erreichbarer Resolver blockiert nicht.
- Der Host des Verwaltungs-UI (`GC_BASE_URL`) wurde der TLS-Automation nie mitgegeben, weil seine Route erst danach angelegt wird. Ohne passende Route-Domain fiel er auf Caddys Standard-Automation zurück — ein ACME-Konto ganz ohne Kontaktadresse, obwohl `GC_CADDY_EMAIL` gesetzt war, sodass für dieses Zertifikat nie Ablaufbenachrichtigungen verschickt wurden. Er wird jetzt wie der Portal-Host ausdrücklich übergeben.
- Die Autosave-Bestätigung in den Einstellungen zeigte auch in der deutschen Oberfläche „Saved" — der Übersetzungsschlüssel fehlte in der Client-Whitelist aller drei Designs.

---

Expand Down
28 changes: 28 additions & 0 deletions public/js/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -1428,6 +1428,34 @@
});
})();

// ─── ACME contact email ───────────────────────────────
(function () {
var el = document.getElementById('acme-email');
if (!el) return;
var t = (window.GC && window.GC.t) || {};
SettingsAutosave.bind({
cluster: 'acme-email',
fields: [el],
statusEl: document.getElementById('acme-email-status'),
valuesById: function () { return { 'acme-email': el.value }; },
save: function () {
return api.put('/api/v1/settings/acme-email', { email: el.value }).then(function (data) {
// Der Server speichert auch dann, wenn der Caddy-Push scheitert. Die
// Warnung MUSS als ok:false zurückgegeben werden — gäbe man data
// unverändert zurück, überschriebe settingsAutosave.js:90 mit flash()
// jeden selbst gesetzten Text durch "Gespeichert". Als ok:false greift
// showError(), und der Snapshot bleibt alt → das nächste Verlassen des
// Feldes wiederholt den Push.
// ponytail: Retry ohne Extra-Code; der Wert liegt serverseitig bereits.
if (data && data.warning) {
return { ok: false, error: t[data.warning] || undefined };
}
return data;
});
},
});
})();

(function () {
var sliderEl = document.getElementById('gw-down-threshold');
var sliderOut = document.getElementById('gw-down-threshold-value');
Expand Down
6 changes: 6 additions & 0 deletions src/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -775,6 +775,8 @@
"error.settings.app_get": "App-Einstellungen konnten nicht geladen werden",
"error.settings.logs_clear": "Logs konnten nicht gelöscht werden",
"error.settings.user_not_found": "Benutzer nicht gefunden",
"error.settings.acme_email_invalid": "Ungültige E-Mail-Adresse",
"error.settings.acme_email_save": "Kontaktadresse konnte nicht gespeichert werden",
"error.backup.create": "Backup konnte nicht erstellt werden",
"error.backup.preview": "Backup-Vorschau fehlgeschlagen",
"error.backup.restore": "Backup konnte nicht wiederhergestellt werden",
Expand Down Expand Up @@ -1982,6 +1984,10 @@
"portal.pihole.owner.devices_in_snapshot_hint": "Nur Geräte in Pi-holes Top-10; weitere folgen in einem späteren Update.",
"portal.pihole.household.active_clients": "{n} aktive Clients",
"settings.autosave.saved": "Gespeichert",
"settings.acme_email": "ACME-Kontaktadresse",
"settings.acme_email_hint": "Für Ablaufwarnungen von Let's Encrypt. Leer lassen, um den Wert aus der .env zu verwenden.",
"settings.acme_email.inherited": "Aus der .env übernommen",
"settings.acme_email.push_failed": "Gespeichert — wirkt erst mit dem nächsten erfolgreichen Schreiben der Caddy-Konfiguration",
"settings.autosave.error": "Speichern fehlgeschlagen",
"settings.autosave.pending": "Wird gespeichert, sobald alle Pflichtfelder ausgefüllt sind",
"settings.autosave.confirm_self": "Diese Änderung kann deine aktuelle Sitzung betreffen. Übernehmen?",
Expand Down
6 changes: 6 additions & 0 deletions src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -795,6 +795,8 @@
"error.settings.app_get": "Failed to get app settings",
"error.settings.logs_clear": "Failed to clear logs",
"error.settings.user_not_found": "User not found",
"error.settings.acme_email_invalid": "Invalid email address",
"error.settings.acme_email_save": "Could not save the contact address",

"error.backup.create": "Failed to create backup",
"error.backup.preview": "Failed to preview backup",
Expand Down Expand Up @@ -2038,6 +2040,10 @@
"portal.pihole.owner.devices_in_snapshot_hint": "Only devices in Pi-hole's top 10; more in a later update.",
"portal.pihole.household.active_clients": "{n} active clients",
"settings.autosave.saved": "Saved",
"settings.acme_email": "ACME contact address",
"settings.acme_email_hint": "Used for expiry warnings from Let's Encrypt. Leave empty to use the value from .env.",
"settings.acme_email.inherited": "Inherited from .env",
"settings.acme_email.push_failed": "Saved — takes effect with the next successful write of the Caddy configuration",
"settings.autosave.error": "Save failed",
"settings.autosave.pending": "Will save once all required fields are filled",
"settings.autosave.confirm_self": "This change can affect your current session. Apply it?",
Expand Down
58 changes: 58 additions & 0 deletions src/routes/api/settings/appearance.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ const { getDb } = require('../../../db/connection');
const settings = require('../../../services/settings');
const config = require('../../../../config/default');
const logger = require('../../../utils/logger');
const { validateEmail } = require('../../../utils/validate');
const activity = require('../../../services/activity');

const router = Router();

Expand Down Expand Up @@ -60,4 +62,60 @@ router.put('/default-theme', (req, res) => {
}
});

/**
* PUT /api/settings/acme-email — Kontaktadresse für Let's Encrypt.
*
* Zugriffsschutz: requireAuth vom API-Router plus der TOKEN_FORBIDDEN-Eintrag
* in settings/index.js. Das ist KEINE Administratorprüfung — der gesamte
* Settings-Baum hat heute keine Rollenprüfung (middleware/auth.js:33).
* Bewusst Status quo; ein einzelner Rollen-Guard hier würde Schutz suggerieren,
* den die Nachbarrouten nicht haben.
*/
router.put('/acme-email', async (req, res) => {
try {
const raw = req.body ? req.body.email : undefined;
// Ein fehlendes Feld ist ein Client-Fehler, KEIN Löschbefehl. Der Rohwert
// darf deshalb nicht über String(raw || '') normalisiert werden — das ließe
// {} still eine gültige Konfiguration löschen.
if (typeof raw !== 'string') {
return res.status(400).json({ ok: false, error: req.t('error.settings.acme_email_invalid') });
}
const trimmed = raw.trim();
if (trimmed && validateEmail(trimmed)) {
return res.status(400).json({ ok: false, error: req.t('error.settings.acme_email_invalid') });
}

if (settings.get('caddy.acme_email', '') !== trimmed) {
settings.set('caddy.acme_email', trimmed);
activity.log('acme_email_updated',
trimmed ? 'ACME contact email updated' : 'ACME contact email cleared (falls back to .env)',
{ source: 'admin', ipAddress: req.ip, severity: 'info' });
}

// Der Push läuft AUCH bei unverändertem Wert — bewusst anders als der
// changed-Guard in network.js:176. Nach einem gescheiterten Push liegt der
// Wert schon in der DB; ein Retry mit derselben Adresse muss ihn erneut
// ausliefern, sonst quittiert die Oberfläche einen Erfolg, den es nie gab.
//
// syncToCaddy signalisiert Misserfolg auf ZWEI Wegen: es wirft (Caddy nicht
// erreichbar) oder liefert false, wenn der Ownership-Guard das /load
// verweigert (caddyConfig.js:962/:969). Unter NODE_ENV=test kehrt es sofort
// zurück und liefert undefined — das zählt als Erfolg, weil es nicht false ist.
// Nebenläufigkeit ist gedeckt: syncToCaddy serialisiert global über _syncChain
// (:925-929) und baut die Config erst beim Ausführen des Kettenglieds, liest
// also immer den dann aktuellen DB-Wert.
let pushed = true;
try {
pushed = (await require('../../../services/caddyConfig').syncToCaddy()) !== false;
} catch (e) {
logger.error({ error: e.message }, 'ACME email saved but Caddy push failed');
pushed = false;
}
res.json(pushed ? { ok: true } : { ok: true, warning: 'settings.acme_email.push_failed' });
} catch (err) {
logger.error({ error: err.message }, 'Failed to set ACME contact email');
res.status(500).json({ ok: false, error: req.t('error.settings.acme_email_save') });
}
});

module.exports = router;
10 changes: 9 additions & 1 deletion src/routes/api/settings/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,18 @@ const TOKEN_FORBIDDEN = [
/^\/profile$/,
/^\/route-block-default$/,
/^\/domains(\/|$)/,
// TLS-steuernd: die ACME-Kontaktadresse entscheidet, unter welchem
// Let's-Encrypt-Konto ALLE öffentlichen Zertifikate ausgestellt werden,
// und jedes Schreiben löst ein Caddy /load aus. Session-only, wie /dns.
/^\/acme-email$/,
];
router.use((req, res, next) => {
if (!req.tokenAuth) return next();
if (TOKEN_FORBIDDEN.some(rx => rx.test(req.path))) {
// Express läuft mit strict:false/caseSensitive:false — `/security/` und
// `/SECURITY` erreichen dieselbe Route. Vor dem Abgleich normalisieren,
// sonst umgeht jede Schreibweise die Liste.
const p = req.path.toLowerCase().replace(/\/+$/, '') || '/';
if (TOKEN_FORBIDDEN.some(rx => rx.test(p))) {
return res.status(403).json({ ok: false, error: 'Forbidden for token auth' });
}
next();
Expand Down
11 changes: 10 additions & 1 deletion src/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,16 @@ pages.forEach(({ path, template, titleKey }) => {
extraLocals.settings = {
gateway_down_threshold_s: require('../services/settings').get('gateway_down_threshold_s'),
};
} catch { extraLocals.settings = {}; }
extraLocals.settingsAcmeEmail = String(require('../services/settings').get('caddy.acme_email', '') || '').trim();
} catch { extraLocals.settings = {}; extraLocals.settingsAcmeEmail = ''; }
// Ob eine Adresse aus der .env geerbt wird — als BOOLEAN, nicht als Wert.
// config.caddy.email steht heute in KEINER API-Antwort (GET /settings/app
// liefert settings.getAll() plus einen festen config-Ausschnitt ohne caddy,
// appearance.js:21-29), und /settings ist nur durch requireAuth geschützt.
// Den Klartext auszuliefern wäre also eine neue Preisgabe an jede Session
// inklusive role='user'; der Hinweis "Aus der .env übernommen" trägt
// dieselbe Information ohne den Wert.
extraLocals.acmeEmailInherited = Boolean(String((config.caddy || {}).email || '').trim());
}

// Dashboard-only: gateways that need re-pairing after master-key rotation
Expand Down
16 changes: 15 additions & 1 deletion src/services/caddyConfig.js
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,19 @@ function buildExternalBlockHandler(route) {
return [{ handler: 'static_response', status_code: 404 }];
}

// Die Einstellung schlägt die Umgebungsvariable; ist sie leer, gilt weiter
// GC_CADDY_EMAIL. Gleiche Bauform wie effectivePortalHost() (portalConfig.js:33).
// Der Auflöser lebt hier und nicht in caddyTlsAutomation.js — jenes Modul ist
// bewusst importfrei und seine Tests kommen ohne Datenbank aus.
function effectiveAcmeEmail() {
// settings ist in dieser Datei NICHT auf Modulebene importiert — lokal
// requiren, genau wie in buildExternalBlockHandler (:167).
const settings = require('./settings');
const fromDb = String(settings.get('caddy.acme_email', '') || '').trim();
if (fromDb) return fromDb;
return String((config.caddy && config.caddy.email) || '').trim();
}

// ─── Build Caddy JSON config from all enabled routes ────
/**
* Build Caddy configuration JSON. Overloaded:
Expand Down Expand Up @@ -686,7 +699,7 @@ function buildCaddyConfig(injectedRoutes, options = {}) {
try { gcHost = new URL(config.app.baseUrl || '').hostname.toLowerCase(); } catch { /* unset/invalid baseUrl */ }
const forceInternal = portal.public ? [] : [homeHost];
const tlsDomains = [...new Set([...Object.keys(caddyRoutes), homeHost, gcHost].filter(Boolean))];
const tlsConfig = buildTlsAutomation(tlsDomains, config.caddy, forceInternal);
const tlsConfig = buildTlsAutomation(tlsDomains, { ...config.caddy, email: effectiveAcmeEmail() }, forceInternal);
if (tlsConfig) caddyConfig.apps.tls = tlsConfig;

// GateControl management UI route
Expand Down Expand Up @@ -1037,6 +1050,7 @@ async function _syncToCaddyInner() {
module.exports = {
caddyApi,
buildCaddyConfig,
effectiveAcmeEmail,
syncToCaddy,
getAclPeers,
setAclPeers,
Expand Down
12 changes: 2 additions & 10 deletions src/services/skoda/skodaAccounts.js
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,13 @@

const { getDb } = require('../../db/connection');
const { encrypt, decrypt } = require('../../utils/crypto');
const { validateEmail } = require('../../utils/validate');

function err(message, code) { const e = new Error(message); e.code = code; return e; }

function isValidEmail(email) {
// Linear index checks instead of a regex: /.+@.+/ on user input is a
// polynomial-ReDoS risk (CodeQL js/polynomial-redos). indexOf is O(n) and
// the length cap is the RFC 5321 maximum.
if (email.length > 254) return false;
const at = email.indexOf('@');
return at > 0 && at < email.length - 1;
}

function createAccount({ email, password }) {
const trimmed = typeof email === 'string' ? email.trim() : '';
if (!trimmed || !isValidEmail(trimmed)) throw err('valid email required', 'SKODA_VALIDATION');
if (validateEmail(trimmed)) throw err('valid email required', 'SKODA_VALIDATION');
if (!password || typeof password !== 'string') throw err('password required', 'SKODA_VALIDATION');
const db = getDb();
try {
Expand Down
37 changes: 37 additions & 0 deletions src/utils/validate.js
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,42 @@ function validatePasswordComplexity(password) {
return errors.length > 0 ? errors : null;
}

function validateEmail(email) {
if (typeof email !== 'string') return 'Email is required';
const trimmed = email.trim();
if (!trimmed) return 'Email is required';
// Lineare Prüfungen statt Regex über die Nutzereingabe: /.+@.+/ ist ein
// polynomial-ReDoS-Risiko (CodeQL js/polynomial-redos) — genau dieser Befund
// trat in skodaAccounts.createAccount auf. Streng, weil der Wert in eine
// ACME-Kontoregistrierung wandert: was Let's Encrypt ablehnt, blockiert die
// Ausstellung für ALLE öffentlichen Domains.
if (trimmed.length > 254) return 'Email too long (max 254 chars)';
for (let i = 0; i < trimmed.length; i++) {
const c = trimmed.charCodeAt(i);
// nur druckbares ASCII → keine Steuerzeichen, kein inneres Whitespace,
// keine Unicode-Tricks (RTL-Override, Homoglyphen)
if (c < 0x21 || c > 0x7e) return 'Invalid email format';
}
const at = trimmed.indexOf('@');
if (at <= 0 || at !== trimmed.lastIndexOf('@') || at === trimmed.length - 1) return 'Invalid email format';
if (at > 64) return 'Invalid email format'; // RFC 5321 local part
const local = trimmed.slice(0, at);
if (local.startsWith('.') || local.endsWith('.') || local.includes('..')) return 'Invalid email format';
const domain = trimmed.slice(at + 1);
if (domain.length > 253) return 'Invalid email format';
if (!domain.includes('.') || domain.includes('..')) return 'Invalid email format';
if (domain.startsWith('.') || domain.startsWith('-') || domain.endsWith('.') || domain.endsWith('-')) {
return 'Invalid email format';
}
for (let i = 0; i < domain.length; i++) {
const c = domain.charCodeAt(i);
const ldh = (c >= 0x30 && c <= 0x39) || (c >= 0x41 && c <= 0x5a)
|| (c >= 0x61 && c <= 0x7a) || c === 0x2d || c === 0x2e;
if (!ldh) return 'Invalid email format';
}
return null;
}

module.exports = {
validatePeerName,
validateDomain,
Expand All @@ -228,4 +264,5 @@ module.exports = {
parsePortRange,
isPrivateIpv4,
isLoopbackHost,
validateEmail,
};
4 changes: 3 additions & 1 deletion templates/aurora/layout.njk
Original file line number Diff line number Diff line change
Expand Up @@ -617,7 +617,9 @@
'skoda.details.score_monthly': {{ t('skoda.details.score_monthly') | dump | safe }},
'skoda.details.score_as_of': {{ t('skoda.details.score_as_of') | dump | safe }},
'skoda.details.load_error': {{ t('skoda.details.load_error') | dump | safe }},
'skoda.details.rate_limited': {{ t('skoda.details.rate_limited') | dump | safe }}
'skoda.details.rate_limited': {{ t('skoda.details.rate_limited') | dump | safe }},
'settings.acme_email.push_failed': {{ t('settings.acme_email.push_failed') | dump | safe }},
'settings.autosave.saved': {{ t('settings.autosave.saved') | dump | safe }}
}
};
</script>
Expand Down
12 changes: 12 additions & 0 deletions templates/aurora/pages/settings.njk
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,18 @@
</div>
</div>

<div class="card">
<div class="card-title">
<span class="ic"><svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16v16H4z"/><path d="m4 6 8 6 8-6"/></svg></span>
{{ t('settings.acme_email') }}
</div>
<div style="margin-top:14px;display:flex;flex-direction:column;gap:12px">
<input type="text" id="acme-email" value="{{ settingsAcmeEmail or '' }}" placeholder="{{ t('settings.acme_email.inherited') if acmeEmailInherited else '' }}" aria-label="{{ t('settings.acme_email') }}" style="width:100%;padding:8px 12px;font-size:12px" maxlength="254" autocomplete="off">
<div style="font-size:11px;color:var(--text-3)">{{ t('settings.acme_email_hint') }}</div>
<div id="acme-email-status" class="autosave-status"></div>
</div>
</div>

<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:14px">
<div class="card">
<div class="card-title">
Expand Down
Loading
Loading