diff --git a/FEATURES.md b/FEATURES.md index 1111daae..27494451 100644 --- a/FEATURES.md +++ b/FEATURES.md @@ -173,6 +173,8 @@ curl -X DELETE "https://gate.example.com/api/v1/route-auth?route_id=1" \ ### Important Notes - SMTP configuration required for Email & Code and email alerts +- **No address enumeration (Email & Code):** asking for a code answers the same for every address — status, text ("Falls diese Adresse für diese Seite freigeschaltet ist, haben wir Ihnen einen Code geschickt." / "If this address has access to this page, we have sent you a code."), and at least 300 ms. The mail goes out in the background, so its latency or a failed delivery (activity `route_auth_otp_failed`) never shows in the answer. Only the route's own address gets a code (case and surrounding spaces do not matter). Entering a code after a wrong address fails exactly like a wrong code ("Der Code stimmt nicht oder ist abgelaufen …"). "Code erneut senden" of the second factor goes to the address of the password step. +- Failed route logins are logged as `route_auth_failed` (warning) with route, masked address (`s***@example.com`), IP and step (`password`, `code_request`, `code`; `unknownUser: true` for an address without access) — never a password or code. - TOTP codes tracked in-memory to prevent replay (90s expiry) - Route-auth CSRF key derived from app secret via HMAC (domain-bound) - Lockout is email-based (not IP-based) to prevent IP rotation bypass @@ -575,6 +577,7 @@ A failed login with a name or e-mail address that matches **no account** counts | GateControl login (also from the portal) | weight of the setting | 1 | 1 | | App sign-in (password) | weight of the setting | 1 | 1 | | Route login (password step) | weight of the setting — an e-mail address other than the route's own account | 1 | 1 (code step) | +| Route login (e-mail code) | weight of the setting — a code requested for an address other than the route's own | — | 1 (wrong or expired code) | - "Account exists" means: the name is a GateControl user name, or a directory account resolves it (Windows name, `DOMAIN\name`, UPN, e-mail). A disabled account counts as existing. - One attempt counts once: typing the e-mail address of an existing account no longer adds a second point for the account's own name. diff --git a/docs/guide/07-faq.md b/docs/guide/07-faq.md index c57999d6..9f6b5f0c 100644 --- a/docs/guide/07-faq.md +++ b/docs/guide/07-faq.md @@ -299,11 +299,17 @@ failures of one address over every entry point together and blocks it after setting **"Unknown user names count"**, **triple** by default. With 10 failures the 4th attempt with an unknown name already blocks. This applies to the GateControl login, the login from the portal, the sign-in in the apps and -the logins of protected routes. A wrong password of an existing account and a -wrong second factor count once. +the logins of protected routes – there also when someone asks for an e-mail +code for an address that has no access to the route. A wrong password of an +existing account, a wrong second factor and a wrong or expired e-mail code +count once. The log shows such failures of a route as `route_auth_failed` +with the route, the masked address (`s***@example.com`) and the IP. Whoever signs in sees no difference: the answer is the same for unknown and -known names, so nobody can find out which accounts exist. If you often mistype +known names, so nobody can find out which accounts exist. With the e-mail code +login every address sees the same sentence: "If this address has access to +this page, we have sent you a code." Only the address with access really gets +a mail. If you often mistype your own name, set it to "twice" or "once", or add your network under **"Never block"**. Addresses in the VPN and in your own network are never blocked anyway. diff --git a/docs/handbuch/07-faq.md b/docs/handbuch/07-faq.md index 9b2cab56..d24199ba 100644 --- a/docs/handbuch/07-faq.md +++ b/docs/handbuch/07-faq.md @@ -310,12 +310,18 @@ Benutzername zählt dabei mehrfach – Einstellung **„Unbekannte Benutzernamen zählen“**, voreingestellt **dreifach**. Mit 10 Fehlversuchen sperrt also schon der 4. Versuch mit einem unbekannten Namen. Das gilt für den GateControl-Login, den Login aus dem Portal, die Anmeldung in den Apps und -die Logins geschützter Routen. Ein falsches Passwort zu einem bestehenden -Konto und ein falscher zweiter Faktor zählen einfach. +die Logins geschützter Routen – dort auch, wenn jemand einen E-Mail-Code für +eine Adresse anfordert, die für die Route nicht freigeschaltet ist. Ein +falsches Passwort zu einem bestehenden Konto, ein falscher zweiter Faktor und +ein falscher oder abgelaufener E-Mail-Code zählen einfach. Im Protokoll stehen +solche Fehlversuche einer Route als `route_auth_failed` mit Route, gekürzter +Adresse (`s***@example.com`) und IP. Wer sich anmeldet, merkt keinen Unterschied: Die Antwort ist für unbekannte und bekannte Namen gleich, so lässt sich nicht herausfinden, welche Konten es -gibt. Vertippst du dich selbst öfter beim Namen, stell die Einstellung auf +gibt. Beim Login mit E-Mail-Code sieht jede Adresse denselben Satz: „Falls +diese Adresse für diese Seite freigeschaltet ist, haben wir Ihnen einen Code +geschickt.“ Nur die freigeschaltete Adresse bekommt wirklich eine Mail. Vertippst du dich selbst öfter beim Namen, stell die Einstellung auf „doppelt“ oder „einfach“ oder trag dein Netz unter **„Nie sperren“** ein. Adressen im VPN und im eigenen Netz werden ohnehin nie gesperrt. diff --git a/public/js/route-auth-login.js b/public/js/route-auth-login.js index 6e416dc9..980252a7 100644 --- a/public/js/route-auth-login.js +++ b/public/js/route-auth-login.js @@ -350,8 +350,12 @@ function initResendButton() { if (!ok) { const msg = (data && data.error) ? data.error : 'An error occurred. Please try again.'; showError(msg); + } else if (data && data.message) { + // E-mail code login: the same neutral text for every address + // ("Falls diese Adresse … freigeschaltet ist …"). + const infoText = document.getElementById('code-info-text'); + if (infoText) infoText.textContent = data.message; } - // On success: code re-sent, no UI change needed } catch (err) { showError('An error occurred. Please try again.'); } diff --git a/src/i18n/de.json b/src/i18n/de.json index 8fae98c9..d9a6e754 100644 --- a/src/i18n/de.json +++ b/src/i18n/de.json @@ -1535,6 +1535,10 @@ "route_auth.invalid_code": "Ungültiger Code", "route_auth.session_expired": "Sitzung abgelaufen", "route_auth.code_sent": "Code gesendet", + "route_auth.code_sent_generic": "Falls diese Adresse für diese Seite freigeschaltet ist, haben wir Ihnen einen Code geschickt. Er ist 5 Minuten gültig.", + "route_auth.invalid_email_code": "Der Code stimmt nicht oder ist abgelaufen. Prüfen Sie ihn oder fordern Sie einen neuen Code an.", + "route_auth.email_invalid": "Bitte geben Sie eine gültige E-Mail-Adresse ein.", + "route_auth.code_unavailable": "Der Code kann gerade nicht verschickt werden. Bitte wenden Sie sich an die Person, die diese Seite betreibt.", "route_auth.too_many_attempts": "Zu viele Versuche. Bitte später erneut versuchen.", "route_auth.auth_type": "Authentifizierung", "route_auth.auth_none": "Keine", diff --git a/src/i18n/en.json b/src/i18n/en.json index ce048e4f..39e21d7d 100644 --- a/src/i18n/en.json +++ b/src/i18n/en.json @@ -1535,6 +1535,10 @@ "route_auth.invalid_code": "Invalid code", "route_auth.session_expired": "Session expired", "route_auth.code_sent": "Code sent", + "route_auth.code_sent_generic": "If this address has access to this page, we have sent you a code. It is valid for 5 minutes.", + "route_auth.invalid_email_code": "The code is wrong or has expired. Check it or request a new code.", + "route_auth.email_invalid": "Please enter a valid e-mail address.", + "route_auth.code_unavailable": "The code cannot be sent right now. Please contact whoever runs this page.", "route_auth.too_many_attempts": "Too many attempts. Try again later.", "route_auth.auth_type": "Authentication", "route_auth.auth_none": "None", diff --git a/src/routes/routeAuth.js b/src/routes/routeAuth.js index e2671247..61cb6170 100644 --- a/src/routes/routeAuth.js +++ b/src/routes/routeAuth.js @@ -15,7 +15,7 @@ const { completeTwoFactor, deleteSession, verifyPassword, - createAndSendOtp, + sendOtpInBackground, verifyOtp, verifyTotp, maskEmail, @@ -113,6 +113,61 @@ function clearSessionCookie(res) { res.clearCookie(COOKIE_SID); } +// ─── No enumeration of the route's address ───────────────────────────── +// A route with the e-mail code login has ONE address. Asking for a code +// answers the same for every address — same status, same body, at least +// SEND_CODE_MIN_MS — and the mail goes out in the background, so neither +// the text nor the time tells whether the address has access. An address +// without access counts for the IP block with the weight of "Unbekannte +// Benutzernamen" (services/ipBlock.js unknown_user_weight), like an unknown +// name at the GateControl login; the code step then fails like a wrong code. +const SEND_CODE_MIN_MS = 300; + +/** Lower-case, trimmed e-mail address, or '' when it is no plausible address. */ +function normalizeEmail(value) { + if (typeof value !== 'string') return ''; + const v = value.trim().toLowerCase(); + if (v.length < 3 || v.length > 254 || /[\s\x00-\x1f\x7f]/.test(v)) return ''; + const at = v.lastIndexOf('@'); + if (at < 1 || at === v.length - 1) return ''; + return v; +} + +function waitUntil(startedAt, ms) { + const left = ms - (Date.now() - startedAt); + return left > 0 ? new Promise((resolve) => setTimeout(resolve, left)) : Promise.resolve(); +} + +/** Pending 2FA session of THIS route (password step done), else null. */ +function pendingSessionFor(req, authConfig) { + const sessionId = req.cookies && req.cookies[COOKIE_SID]; + const s = sessionId ? getSession(sessionId) : null; + return s && s.two_factor_pending && s.route_id === authConfig.route_id ? s : null; +} + +/** + * Activity entry of a failed route login (Logs & Monitoring): route, masked + * address, IP and the step. Only for the admin — the requester's answer is + * the same either way. Never contains a password or code. + */ +function logRouteAuthFailure(req, authConfig, domain, step, email, { unknownUser = false } = {}) { + try { + // Typed by the visitor: no control characters, bounded, then masked. + const masked = maskEmail(typeof email === 'string' ? email.replace(/[\x00-\x1f\x7f]/g, '').trim().slice(0, 254) : ''); + const what = { + password: 'wrong e-mail or password', + code_request: 'code requested for an address without access', + code: 'wrong or expired code', + }[step] || step; + activity.log('route_auth_failed', `Failed route login for ${domain}: ${what} (${masked})`, { + details: { routeId: authConfig.route_id, domain, step, email: masked, ...(unknownUser ? { unknownUser: true } : {}) }, + source: 'system', + ipAddress: req.ip, + severity: 'warning', + }); + } catch { /* best-effort */ } +} + // GET /route-auth/verify — Caddy forward_auth endpoint router.get('/verify', (req, res) => { (async () => { @@ -253,14 +308,10 @@ router.get('/login', (req, res) => { } } - // Check for pending 2FA session - let twoFactorPending = false; - if (sessionId) { - const pendingSession = getSession(sessionId); - if (pendingSession && pendingSession.two_factor_pending) { - twoFactorPending = true; - } - } + // Check for pending 2FA session (of this route: the password step was + // done here — a pending session of another route shows nothing) + const pendingSession = authConfig ? pendingSessionFor(req, authConfig) : null; + const twoFactorPending = !!pendingSession; // Set CSRF double-submit cookie (bound to domain) const csrfToken = generateSignedCsrf(domain); @@ -277,7 +328,7 @@ router.get('/login', (req, res) => { twoFactorEnabled: !!authConfig?.two_factor_enabled, twoFactorMethod: authConfig?.two_factor_method || null, is2faStep2: twoFactorPending, - maskedEmail: authConfig ? maskEmail(authConfig.email) : '', + maskedEmail: pendingSession ? maskEmail(pendingSession.email) : '', routeCsrfToken: csrfToken, branding: routeData ? { title: routeData.branding_title, @@ -333,7 +384,9 @@ router.post('/login', routeAuthLoginLimiter, (req, res) => { // matches no account and counts with the weight of "Unbekannte // Benutzernamen" for the IP block (verifyPassword ran argon2 anyway; // the answer below is the same). - lockout.recordFailedAttempt(lockoutId, 'route_auth', req.ip, { unknownUser: !authConfig.email || authConfig.email !== email }); + const unknownUser = !authConfig.email || authConfig.email !== email; + lockout.recordFailedAttempt(lockoutId, 'route_auth', req.ip, { unknownUser }); + logRouteAuthFailure(req, authConfig, domain, 'password', email, { unknownUser }); return res.status(401).json({ ok: false, error: req.t('route_auth.invalid_credentials') }); } @@ -352,9 +405,10 @@ router.post('/login', routeAuthLoginLimiter, (req, res) => { ); setSessionCookie(res, sessionId, 5 * 60 * 1000); - // Send OTP if email_code 2FA + // Send OTP if email_code 2FA (in the background: the step answer does + // not wait for the mail server; a failed delivery is logged) if (authConfig.two_factor_method === 'email_code') { - await createAndSendOtp(authConfig.route_id, email, domain, req.language); + sendOtpInBackground(authConfig.route_id, email, domain, req.language, { ip: req.ip }); } return res.json({ ok: true, twoFactorRequired: true }); @@ -369,14 +423,20 @@ router.post('/login', routeAuthLoginLimiter, (req, res) => { })().catch((err) => res.status(500).json({ ok: false, error: err.message })); }); -// POST /route-auth/send-code — request a new OTP code +// POST /route-auth/send-code — request a (new) e-mail code +// • e-mail code login (auth type email_code): for the typed address. The +// answer is the same for every address (see SEND_CODE_MIN_MS above); +// only the route's own address gets a code. +// • second factor by e-mail ("Code erneut senden" after the password +// step): to the address of the pending session; the typed one is ignored. router.post('/send-code', routeAuthCodeLimiter, (req, res) => { (async () => { - const { email, domain: bodyDomain, _csrf } = req.body; - const domain = bodyDomain || req.headers['x-forwarded-host'] || req.headers.host; + const startedAt = Date.now(); + const body = req.body || {}; + const domain = body.domain || req.headers['x-forwarded-host'] || req.headers.host; // CSRF check (bound to domain) - const csrfToken = _csrf || req.headers['x-csrf-token']; + const csrfToken = body._csrf || req.headers['x-csrf-token']; if (!verifySignedCsrf(csrfToken, domain)) { return res.status(403).json({ ok: false, error: 'CSRF validation failed' }); } @@ -391,21 +451,48 @@ router.post('/send-code', routeAuthCodeLimiter, (req, res) => { return res.status(404).json({ ok: false, error: req.t('route_auth.not_configured') }); } - // Verify pending 2FA session exists before allowing code resend - const sessionId = req.cookies && req.cookies[COOKIE_SID]; - const pendingSession = sessionId ? getSession(sessionId) : null; - if (!pendingSession || !pendingSession.two_factor_pending) { + const pending = pendingSessionFor(req, authConfig); + if (pending) { + // Resend of the second factor: the password step proved the address. + if (!authConfig.two_factor_enabled || authConfig.two_factor_method !== 'email_code') { + return res.status(403).json({ ok: false, error: req.t('route_auth.session_expired') }); + } + if (!isSmtpConfigured()) { + return res.status(503).json({ ok: false, error: req.t('route_auth.code_unavailable') }); + } + sendOtpInBackground(authConfig.route_id, pending.email, domain, req.language, { ip: req.ip }); + return res.json({ ok: true, message: req.t('route_auth.code_sent') }); + } + + if (authConfig.auth_type !== 'email_code') { + // Password route without a pending second factor (expired / never logged in). return res.status(403).json({ ok: false, error: req.t('route_auth.session_expired') }); } - // Verify email matches config - if (authConfig.email !== email) { - return res.status(400).json({ ok: false, error: req.t('route_auth.email_mismatch') }); + // Checks that do not depend on WHICH address was typed come first. + const typed = normalizeEmail(body.email); + if (!typed) { + return res.status(400).json({ ok: false, error: req.t('route_auth.email_invalid') }); + } + if (!isSmtpConfigured()) { + return res.status(503).json({ ok: false, error: req.t('route_auth.code_unavailable') }); } - await createAndSendOtp(authConfig.route_id, email, domain, req.language); - res.json({ ok: true }); - })().catch((err) => res.status(500).json({ ok: false, error: err.message })); + try { + if (authConfig.email && normalizeEmail(authConfig.email) === typed) { + // To the stored address (the typed one may differ in case). + sendOtpInBackground(authConfig.route_id, authConfig.email, domain, req.language, { ip: req.ip }); + } else { + lockout.recordFailedAttempt(`${typed}:${authConfig.route_id}`, 'route_auth', req.ip, { unknownUser: true }); + logRouteAuthFailure(req, authConfig, domain, 'code_request', typed, { unknownUser: true }); + } + } catch (err) { + require('../utils/logger').warn({ err: err && err.message, routeId: authConfig.route_id }, 'route auth: code request failed'); + } + + await waitUntil(startedAt, SEND_CODE_MIN_MS); + return res.json({ ok: true, message: req.t('route_auth.code_sent_generic') }); + })().catch(() => res.status(500).json({ ok: false, error: req.t('route_auth.code_unavailable') })); }); // POST /route-auth/verify-code — verify OTP or TOTP code @@ -431,12 +518,19 @@ router.post('/verify-code', routeAuthLoginLimiter, (req, res) => { return res.status(404).json({ ok: false, error: req.t('route_auth.not_configured') }); } - // Get current session (pending or full) + // Second factor: the pending session of THIS route (password step done). + // Single factor (email_code / totp): always the route's own account — the + // address typed when asking for the code does not matter here, so a + // wrong code and "no code was ever sent to that address" fail alike + // (same answer, same lockout counter). const sessionId = req.cookies && req.cookies[COOKIE_SID]; - const existingSession = sessionId ? getSession(sessionId) : null; - const isTwoFactor = existingSession && existingSession.two_factor_pending; + const pending = pendingSessionFor(req, authConfig); + const isTwoFactor = !!pending; + if (isTwoFactor ? !authConfig.two_factor_enabled : !['email_code', 'totp'].includes(authConfig.auth_type)) { + return res.status(403).json({ ok: false, error: req.t('route_auth.session_expired') }); + } - const email = (existingSession && existingSession.email) || req.body.email || authConfig.email || 'anonymous'; + const email = (pending && pending.email) || authConfig.email || 'anonymous'; // Check account lockout for code verification const lockoutId = `${email}:${authConfig.route_id}`; @@ -459,12 +553,15 @@ router.post('/verify-code', routeAuthLoginLimiter, (req, res) => { isValid = verifyTotp(authConfig.totp_secret_encrypted, code, authConfig.route_id); } else { // email OTP - isValid = verifyOtp(authConfig.route_id, email, code); + isValid = verifyOtp(authConfig.route_id, email, String(code == null ? '' : code)); } if (!isValid) { + // Counts once for the IP block (the account exists); a wrong address + // already counted with the "unknown names" weight when it asked. lockout.recordFailedAttempt(lockoutId, 'route_auth', req.ip); - return res.status(401).json({ ok: false, error: req.t('route_auth.invalid_code') }); + logRouteAuthFailure(req, authConfig, domain, 'code', email === 'anonymous' ? '' : email); + return res.status(401).json({ ok: false, error: req.t(method === 'totp' ? 'route_auth.invalid_code' : 'route_auth.invalid_email_code') }); } // Clear lockout on successful verification diff --git a/src/services/ipBlock.js b/src/services/ipBlock.js index ebc946ce..89dc0a30 100644 --- a/src/services/ipBlock.js +++ b/src/services/ipBlock.js @@ -17,7 +17,8 @@ // rate_limit 429 answers (off by default — own apps can hit it) // // Unknown names (login, route_auth): a failed login with a name or e-mail -// address that matches no account counts `unknown_user_weight` times (1, 2, +// address that matches no account (also an e-mail code of a route asked for +// another address than the route's own) counts `unknown_user_weight` times (1, 2, // 3 or 5; default 3) — the account lockout cannot stop those (credential // stuffing with leaked lists), so the address is stopped earlier. A wrong // second factor or a wrong password of an existing account counts once. The diff --git a/src/services/routeAuth.js b/src/services/routeAuth.js index daf32e3f..66a24e1a 100644 --- a/src/services/routeAuth.js +++ b/src/services/routeAuth.js @@ -7,7 +7,9 @@ const { getDb } = require('../db/connection'); const { encrypt, decrypt } = require('../utils/crypto'); const argon2Options = require('../utils/argon2Options'); const activity = require('./activity'); -const { sendOtpEmail } = require('./email'); +// Looked up on the module at call time (not destructured) so tests can stub +// the mail transport. +const emailService = require('./email'); const logger = require('../utils/logger'); // --------------------------------------------------------------------------- @@ -349,9 +351,11 @@ function hashOtp(code) { } /** - * Invalidate previous OTPs for this route+email, insert a new one, send via email + * Invalidate previous OTPs for this route+email and store a new one. + * Synchronous, so a code verified right after the request finds it. + * → { code, expiresAt } */ -async function createAndSendOtp(routeId, email, domain, lang) { +function issueOtp(routeId, email) { const db = getDb(); // Invalidate previous OTPs @@ -369,14 +373,51 @@ async function createAndSendOtp(routeId, email, domain, lang) { VALUES (?, ?, ?, ?) `).run(routeId, codeHash, email, expiresAt); - await sendOtpEmail({ to: email, code, domain, lang }); + return { code, expiresAt }; +} +function logOtpSent(routeId, email, domain, ip) { activity.log('route_auth_otp_sent', `OTP sent to ${maskEmail(email)} for route ${routeId}`, { details: { routeId, email: maskEmail(email), domain }, source: 'system', + ipAddress: ip || null, severity: 'info', }); +} +/** + * Invalidate previous OTPs for this route+email, insert a new one, send via email + * (waits for the mail server). + */ +async function createAndSendOtp(routeId, email, domain, lang, { ip = null } = {}) { + const { code, expiresAt } = issueOtp(routeId, email); + await emailService.sendOtpEmail({ to: email, code, domain, lang }); + logOtpSent(routeId, email, domain, ip); + return { expiresAt }; +} + +/** + * Like createAndSendOtp, but the mail goes out in the background: the code is + * stored at once, the answer of the login page does not wait for (and so + * cannot reveal anything through) the mail server. A failed delivery is + * logged (route_auth_otp_failed); never throws. + */ +function sendOtpInBackground(routeId, email, domain, lang, { ip = null } = {}) { + const { code, expiresAt } = issueOtp(routeId, email); + Promise.resolve() + .then(() => emailService.sendOtpEmail({ to: email, code, domain, lang })) + .then(() => logOtpSent(routeId, email, domain, ip)) + .catch((err) => { + logger.warn({ routeId, domain, err: err && err.message }, 'route auth: code e-mail could not be sent'); + try { + activity.log('route_auth_otp_failed', `OTP e-mail to ${maskEmail(email)} for route ${routeId} could not be sent`, { + details: { routeId, email: maskEmail(email), domain, error: String((err && err.message) || err).slice(0, 200) }, + source: 'system', + ipAddress: ip || null, + severity: 'warning', + }); + } catch { /* best-effort */ } + }); return { expiresAt }; } @@ -551,7 +592,7 @@ function verifyTotp(encryptedSecret, token, routeId) { * Mask an email address for safe logging: m***@example.com */ function maskEmail(email) { - if (!email || !email.includes('@')) return '***'; + if (!email || typeof email !== 'string' || !email.includes('@')) return '***'; const [local, domain] = email.split('@'); if (!local || local.length === 0) return `***@${domain}`; return `${local[0]}***@${domain}`; @@ -579,6 +620,8 @@ module.exports = { generateOtp, hashOtp, createAndSendOtp, + sendOtpInBackground, + issueOtp, verifyOtp, // TOTP generateTotpSecret, diff --git a/tests/route_auth_no_enumeration.test.js b/tests/route_auth_no_enumeration.test.js new file mode 100644 index 00000000..e9453226 --- /dev/null +++ b/tests/route_auth_no_enumeration.test.js @@ -0,0 +1,228 @@ +'use strict'; + +// Route login with an e-mail code (routes/routeAuth.js): asking for a code +// must not reveal whether the typed address has access. Same status, same +// body and at least SEND_CODE_MIN_MS for every address; the mail goes out in +// the background (its latency or failure does not reach the answer). An +// address without access counts for the IP block with the weight of +// "Unbekannte Benutzernamen" (unknown_user_weight) and is logged for the +// admin (route, masked address, IP). The code step then fails exactly like +// a wrong code. Also: the resend of the second factor goes to the address of +// the pending session, and the login page shows a masked address only for a +// pending session of its own route. + +const crypto = require('node:crypto'); +process.env.GC_ENCRYPTION_KEY = process.env.GC_ENCRYPTION_KEY || crypto.randomBytes(32).toString('hex'); +process.env.GC_SECRET = process.env.GC_SECRET || crypto.randomBytes(32).toString('hex'); + +const fs = require('node:fs'); +const path = require('node:path'); +const { test, before, after, beforeEach } = require('node:test'); +const assert = require('node:assert/strict'); +const supertest = require('supertest'); +const { setup, teardown } = require('./helpers/setup'); + +let app, db, ipBlock, emailService, routeAuth, tmp, origSend; +const DOMAIN = 'ranoenum-code.example.de'; +const PW_DOMAIN = 'ranoenum-2fa.example.de'; +const OWNER = 'owner@example.de'; +let rid, pwRid; +let sent = []; +let ipSeq = 0; +const nextIp = () => `185.220.103.${++ipSeq}`; + +function counted(ip) { + return db.prepare('SELECT COALESCE(SUM(weight), 0) AS points, COUNT(*) AS rows, COALESCE(SUM(unknown_user), 0) AS unknown FROM ip_block_failures WHERE ip = ?').get(ip); +} +function setSmtp(on) { + for (const k of ['smtp_host', 'smtp_port', 'smtp_from']) db.prepare('DELETE FROM settings WHERE key = ?').run(k); + if (!on) return; + db.prepare("INSERT INTO settings (key, value) VALUES ('smtp_host', 'mail.example.de'), ('smtp_port', '587'), ('smtp_from', 'gc@example.de')").run(); +} +async function csrfFor(agent, domain, ip) { + const page = await agent.get('/route-auth/login').query({ route: domain }).set('X-Forwarded-For', ip).expect(200); + return { token: page.text.match(/name="_csrf"\s+value="([^"]+)"/)[1], html: page.text }; +} +async function askCode(email, { ip = nextIp(), domain = DOMAIN, agent = supertest.agent(app) } = {}) { + const { token } = await csrfFor(agent, domain, ip); + const t0 = Date.now(); + const r = await agent.post('/route-auth/send-code').set('X-Forwarded-For', ip).send({ email, domain, _csrf: token }); + return { status: r.status, body: r.body, ms: Date.now() - t0, ip, agent, token }; +} +async function enterCode(ctx, code) { + const r = await ctx.agent.post('/route-auth/verify-code').set('X-Forwarded-For', ctx.ip) + .send({ code, domain: ctx.domain || DOMAIN, _csrf: ctx.token, redirect: '/' }); + return { status: r.status, body: r.body, cookie: r.headers['set-cookie'] || null }; +} +const flush = () => new Promise((resolve) => setTimeout(resolve, 20)); + +before(async () => { + ({ app } = await setup()); + db = require('../src/db/connection').getDb(); + ipBlock = require('../src/services/ipBlock'); + emailService = require('../src/services/email'); + routeAuth = require('../src/services/routeAuth'); + tmp = fs.mkdtempSync(path.join(process.env.GC_DATA_DIR, 'ranoenum-')); + rid = Number(db.prepare("INSERT INTO routes (domain, target_ip, target_port, route_type, enabled) VALUES (?, '10.0.0.5', 80, 'http', 1)").run(DOMAIN).lastInsertRowid); + db.prepare("INSERT INTO route_auth (route_id, auth_type, email) VALUES (?, 'email_code', ?)").run(rid, OWNER); + pwRid = Number(db.prepare("INSERT INTO routes (domain, target_ip, target_port, route_type, enabled) VALUES (?, '10.0.0.6', 80, 'http', 1)").run(PW_DOMAIN).lastInsertRowid); + db.prepare("INSERT INTO route_auth (route_id, auth_type, email, password_hash, two_factor_enabled, two_factor_method) VALUES (?, 'email_password', ?, 'x', 1, 'email_code')").run(pwRid, OWNER); + origSend = emailService.sendOtpEmail; +}); + +after(() => { emailService.sendOtpEmail = origSend; teardown(); }); + +beforeEach(() => { + db.prepare('DELETE FROM ip_blocks').run(); + db.prepare('DELETE FROM ip_block_failures').run(); + db.prepare('DELETE FROM ip_block_strikes').run(); + db.prepare('DELETE FROM login_attempts').run(); + db.prepare('DELETE FROM route_auth_otp').run(); + db.prepare("DELETE FROM activity_log WHERE event_type LIKE 'route_auth_%'").run(); + db.prepare("DELETE FROM settings WHERE key LIKE 'ipblock.%'").run(); + ipBlock._resetForTest({ blockFile: path.join(tmp, 'ipblock.list'), dataDir: path.join(tmp, 'lists'), engine: true }); + setSmtp(true); + sent = []; + emailService.sendOtpEmail = async (m) => { sent.push(m); }; +}); + +test('known and unknown address: same status, same body, same minimum time', async () => { + const k = await askCode(OWNER); + const u = await askCode('stranger@leak.example'); + assert.equal(k.status, 200); + assert.deepEqual(u.body, k.body); + assert.equal(u.status, k.status); + assert.equal(k.body.ok, true); + assert.match(k.body.message, /we have sent you a code|haben wir Ihnen einen Code geschickt/); + assert.ok(!JSON.stringify(k.body).includes('***'), 'no masked address in the answer'); + assert.ok(k.ms >= 290 && u.ms >= 290, `both wait the minimum (${k.ms} / ${u.ms} ms)`); + await flush(); + assert.equal(sent.length, 1, 'only the route\'s own address gets a mail'); + assert.equal(sent[0].to, OWNER); + assert.equal(db.prepare('SELECT COUNT(*) AS n FROM route_auth_otp WHERE route_id = ?').get(rid).n, 1); +}); + +test('unknown address counts with the unknown-name weight and is logged for the admin', async () => { + const u = await askCode('stranger@leak.example'); + const k = await askCode(OWNER); + assert.deepEqual(counted(u.ip), { points: 3, rows: 1, unknown: 1 }); + assert.deepEqual(counted(k.ip), { points: 0, rows: 0, unknown: 0 }); + const row = db.prepare("SELECT message, details, ip_address, severity FROM activity_log WHERE event_type = 'route_auth_failed' ORDER BY id DESC").get(); + assert.ok(row, 'activity entry written'); + assert.equal(row.ip_address, u.ip); + assert.equal(row.severity, 'warning'); + assert.ok(row.message.includes(DOMAIN), row.message); + assert.ok(row.message.includes('s***@leak.example'), row.message); + assert.ok(!row.message.includes('stranger@'), 'address is masked'); + const d = JSON.parse(row.details); + assert.deepEqual(d, { routeId: rid, domain: DOMAIN, step: 'code_request', email: 's***@leak.example', unknownUser: true }); +}); + +test('weight follows the setting (unknown_user_weight 5)', async () => { + ipBlock.setConfig({ unknown_user_weight: 5 }); + const u = await askCode('other@leak.example'); + assert.deepEqual(counted(u.ip), { points: 5, rows: 1, unknown: 1 }); +}); + +test('address in other case / with spaces still gets the code at the stored address', async () => { + const k = await askCode(' Owner@EXAMPLE.de '); + assert.equal(k.status, 200); + await flush(); + assert.equal(sent.length, 1); + assert.equal(sent[0].to, OWNER); + assert.deepEqual(counted(k.ip), { points: 0, rows: 0, unknown: 0 }); +}); + +test('mail latency does not reach the answer', async () => { + let release; + emailService.sendOtpEmail = (m) => new Promise((resolve) => { release = () => { sent.push(m); resolve(); }; }); + const k = await askCode(OWNER); + assert.equal(k.status, 200); + assert.ok(k.ms < 1500, `answered before the mail went out (${k.ms} ms)`); + assert.equal(sent.length, 0, 'mail still pending'); + release(); + await flush(); + assert.equal(sent.length, 1); +}); + +test('failed delivery: same answer, logged as route_auth_otp_failed', async () => { + emailService.sendOtpEmail = async () => { throw new Error('connect ECONNREFUSED'); }; + const k = await askCode(OWNER); + const u = await askCode('stranger@leak.example'); + assert.deepEqual(k.body, u.body); + await flush(); + const row = db.prepare("SELECT message, details FROM activity_log WHERE event_type = 'route_auth_otp_failed'").get(); + assert.ok(row, 'failure logged'); + assert.ok(row.message.includes('o***@example.de')); +}); + +test('no plausible address / no mail server: same answer for every address', async () => { + const bad = await askCode('not-an-address'); + assert.equal(bad.status, 400); + assert.deepEqual(counted(bad.ip), { points: 0, rows: 0, unknown: 0 }); + setSmtp(false); + const k = await askCode(OWNER); + const u = await askCode('stranger@leak.example'); + assert.equal(k.status, 503); + assert.equal(u.status, k.status); + assert.deepEqual(u.body, k.body); + assert.equal(sent.length, 0); +}); + +test('code step: wrong code and "no code ever sent" fail alike, a right code logs in', async () => { + const u = await askCode('stranger@leak.example'); + const fromUnknown = await enterCode(u, '123456'); + const k = await askCode(OWNER); + await flush(); + const wrong = await enterCode(k, sent[0].code === '000000' ? '000001' : '000000'); + assert.equal(fromUnknown.status, 401); + assert.deepEqual(fromUnknown.body, wrong.body); + assert.equal(fromUnknown.cookie, null); + assert.equal(wrong.cookie, null); + assert.match(wrong.body.error, /wrong or has expired|stimmt nicht oder ist abgelaufen/); + // A wrong code counts once (the unknown address already counted 3 when it asked). + assert.deepEqual(counted(u.ip), { points: 4, rows: 2, unknown: 1 }); + assert.deepEqual(counted(k.ip), { points: 1, rows: 1, unknown: 0 }); + const codeRow = db.prepare("SELECT details, ip_address FROM activity_log WHERE event_type = 'route_auth_failed' AND details LIKE '%\"step\":\"code\"%' ORDER BY id DESC").get(); + assert.equal(codeRow.ip_address, k.ip); + assert.equal(JSON.parse(codeRow.details).email, 'o***@example.de'); + + const k2 = await askCode(OWNER); + await flush(); + const ok = await enterCode(k2, sent[sent.length - 1].code); + assert.equal(ok.status, 200); + assert.equal(ok.body.ok, true); + assert.ok(ok.cookie && ok.cookie.some((c) => c.startsWith('gc.route.sid='))); +}); + +test('second factor: resend goes to the pending session\'s address, typed address ignored', async () => { + const agent = supertest.agent(app); + const ip = nextIp(); + const { id } = routeAuth.createSession(pwRid, OWNER, ip, 5 * 60 * 1000, true); + const page = await agent.get('/route-auth/login').query({ route: PW_DOMAIN }).set('X-Forwarded-For', ip).set('Cookie', `gc.route.sid=${id}`).expect(200); + const token = page.text.match(/name="_csrf"\s+value="([^"]+)"/)[1]; + assert.ok(page.text.includes('o***@example.de'), 'step 2 shows the masked address of the session'); + const r = await agent.post('/route-auth/send-code').set('X-Forwarded-For', ip).set('Cookie', `gc.route.sid=${id}`) + .send({ email: '', domain: PW_DOMAIN, _csrf: token }); + assert.equal(r.status, 200); + await flush(); + assert.equal(sent.length, 1); + assert.equal(sent[0].to, OWNER); +}); + +test('login page: no masked address without a pending session of this route', async () => { + const agent = supertest.agent(app); + const ip = nextIp(); + const plain = await csrfFor(agent, PW_DOMAIN, ip); + assert.ok(!plain.html.includes('***@'), 'step 1 shows no address'); + // A pending session of ANOTHER route does not open step 2 here. + const { id } = routeAuth.createSession(rid, OWNER, ip, 5 * 60 * 1000, true); + const page = await supertest(app).get('/route-auth/login').query({ route: PW_DOMAIN }) + .set('X-Forwarded-For', ip).set('Cookie', `gc.route.sid=${id}`).expect(200); + assert.ok(!page.text.includes('***@')); + const r = await supertest(app).post('/route-auth/send-code').set('X-Forwarded-For', ip).set('Cookie', `gc.route.sid=${id}`) + .send({ email: OWNER, domain: PW_DOMAIN, _csrf: plain.token }); + assert.equal(r.status, 403, 'password route without its own pending session sends nothing'); + await flush(); + assert.equal(sent.length, 0); +});