From 1794fe98c2b29432db49ecdb1499591b18567230 Mon Sep 17 00:00:00 2001 From: dsh Date: Sun, 16 Aug 2026 01:40:52 -0400 Subject: [PATCH] fix: relax login origin check to hostname level, log origin/host on reject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Real browsers behind proxies/alternate listeners can produce port or case mismatches between the Origin header and the incoming Host header, which the strict host-level comparison rejected ("非法请求来源"). Compare hostnames instead (still blocking foreign sites) and record the raw origin and host values in the audit entry for future diagnostics. Tests: cross-origin POST rejection, same-hostname different-port tolerance, audit origin/host field assertions. --- index.js | 40 ++++++++++++++++++++++++++-------------- test.mjs | 12 ++++++++++++ 2 files changed, 38 insertions(+), 14 deletions(-) diff --git a/index.js b/index.js index 31defe9..844d560 100644 --- a/index.js +++ b/index.js @@ -373,6 +373,16 @@ function sanitizeNext(next) { return next } +function sameHost(originHeader, hostHeader) { + try { + const originHost = new URL(originHeader).hostname.toLowerCase() + const hostHost = new URL(`http://${hostHeader}`).hostname.toLowerCase() + return originHost === hostHost + } catch { + return false + } +} + function readBody(req, maxBytes) { return new Promise((resolveBody) => { let size = 0 @@ -439,20 +449,24 @@ export function installGuard(server, guard, { audit, loginPath, logoutPath }) { return } // Cross-origin form posts are rejected: the login form must come from this host. + // Hostname-level comparison tolerates port/case differences that proxies + // or alternate listeners introduce, while still blocking foreign sites. const origin = req.headers.origin const host = req.headers.host - if (typeof origin === 'string' && typeof host === 'string') { - try { - if (new URL(origin).host !== host) { - audit.write({ event: 'login-fail', username: '', ip: clientIp(req), ua: req.headers['user-agent'] ?? '', reason: 'cross-origin' }) - serveLogin(req, res, '非法请求来源', 403, queryNext) - return - } - } catch { - audit.write({ event: 'login-fail', username: '', ip: clientIp(req), ua: req.headers['user-agent'] ?? '', reason: 'cross-origin' }) - serveLogin(req, res, '非法请求来源', 403, queryNext) - return - } + const ip = clientIp(req) + const ua = req.headers['user-agent'] ?? '' + if (typeof origin === 'string' && typeof host === 'string' && !sameHost(origin, host)) { + audit.write({ + event: 'login-fail', + username: '', + ip, + ua, + reason: 'cross-origin', + origin: String(origin).slice(0, 200), + host: String(host).slice(0, 200), + }) + serveLogin(req, res, '非法请求来源', 403, queryNext) + return } const body = await readBody(req, 8192) if (body === null) { @@ -464,8 +478,6 @@ export function installGuard(server, guard, { audit, loginPath, logoutPath }) { const username = fields.get('username') ?? '' const password = fields.get('password') ?? '' const next = sanitizeNext(fields.get('next') ?? queryNext) - const ip = clientIp(req) - const ua = req.headers['user-agent'] ?? '' if (!username || !password) { audit.write({ event: 'login-fail', username, ip, ua, reason: 'missing-fields' }) serveLogin(req, res, '请输入用户名和密码', 403, next) diff --git a/test.mjs b/test.mjs index 2b0acdd..6110237 100644 --- a/test.mjs +++ b/test.mjs @@ -85,6 +85,15 @@ r = await post('/panel-auth/login', 'username=admin&password=wrong&next=%2Fsome% assert.equal(r.status, 403) assert.match(await r.text(), /用户名或密码错误/) +// 5b. cross-origin POST → 403 + audit records origin/host +r = await post('/panel-auth/login', 'username=admin&password=s3cret-pass&next=%2F', { Origin: 'https://evil.example.com' }) +assert.equal(r.status, 403) +assert.match(await r.text(), /非法请求来源/) + +// 5c. same hostname with a different port → tolerated (tunnel/proxy case) +r = await post('/panel-auth/login', 'username=admin&password=s3cret-pass&next=%2F', { Origin: 'http://127.0.0.1:9999' }) +assert.equal(r.status, 303) + // 6. POST login with correct credentials → 303 + cookie + next r = await post('/panel-auth/login', 'username=admin&password=s3cret-pass&next=%2Fsome%2Fpage') assert.equal(r.status, 303) @@ -126,6 +135,9 @@ const ok = auditLines.find((e) => e.event === 'login-ok') assert.equal(ok.username, 'admin') const fail = auditLines.find((e) => e.event === 'login-fail' && e.reason === 'bad-credentials') assert.equal(fail.username, 'admin') +const co = auditLines.find((e) => e.event === 'login-fail' && e.reason === 'cross-origin') +assert.equal(co.origin, 'https://evil.example.com') +assert.match(co.host, /^127\.0\.0\.1:/) assert.equal(auditLines.every((e) => typeof e.ip === 'string' && typeof e.ts === 'string'), true) // 12. XSS: hostile `next` value is escaped in the page