fix: relax login origin check to hostname level, log origin/host on reject
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.
This commit is contained in:
@@ -373,6 +373,16 @@ function sanitizeNext(next) {
|
|||||||
return 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) {
|
function readBody(req, maxBytes) {
|
||||||
return new Promise((resolveBody) => {
|
return new Promise((resolveBody) => {
|
||||||
let size = 0
|
let size = 0
|
||||||
@@ -439,21 +449,25 @@ export function installGuard(server, guard, { audit, loginPath, logoutPath }) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
// Cross-origin form posts are rejected: the login form must come from this host.
|
// 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 origin = req.headers.origin
|
||||||
const host = req.headers.host
|
const host = req.headers.host
|
||||||
if (typeof origin === 'string' && typeof host === 'string') {
|
const ip = clientIp(req)
|
||||||
try {
|
const ua = req.headers['user-agent'] ?? ''
|
||||||
if (new URL(origin).host !== host) {
|
if (typeof origin === 'string' && typeof host === 'string' && !sameHost(origin, host)) {
|
||||||
audit.write({ event: 'login-fail', username: '', ip: clientIp(req), ua: req.headers['user-agent'] ?? '', reason: 'cross-origin' })
|
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)
|
serveLogin(req, res, '非法请求来源', 403, queryNext)
|
||||||
return
|
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 body = await readBody(req, 8192)
|
const body = await readBody(req, 8192)
|
||||||
if (body === null) {
|
if (body === null) {
|
||||||
audit.write({ event: 'login-fail', username: '', ip: clientIp(req), ua: req.headers['user-agent'] ?? '', reason: 'body-too-large' })
|
audit.write({ event: 'login-fail', username: '', ip: clientIp(req), ua: req.headers['user-agent'] ?? '', reason: 'body-too-large' })
|
||||||
@@ -464,8 +478,6 @@ export function installGuard(server, guard, { audit, loginPath, logoutPath }) {
|
|||||||
const username = fields.get('username') ?? ''
|
const username = fields.get('username') ?? ''
|
||||||
const password = fields.get('password') ?? ''
|
const password = fields.get('password') ?? ''
|
||||||
const next = sanitizeNext(fields.get('next') ?? queryNext)
|
const next = sanitizeNext(fields.get('next') ?? queryNext)
|
||||||
const ip = clientIp(req)
|
|
||||||
const ua = req.headers['user-agent'] ?? ''
|
|
||||||
if (!username || !password) {
|
if (!username || !password) {
|
||||||
audit.write({ event: 'login-fail', username, ip, ua, reason: 'missing-fields' })
|
audit.write({ event: 'login-fail', username, ip, ua, reason: 'missing-fields' })
|
||||||
serveLogin(req, res, '请输入用户名和密码', 403, next)
|
serveLogin(req, res, '请输入用户名和密码', 403, next)
|
||||||
|
|||||||
@@ -85,6 +85,15 @@ r = await post('/panel-auth/login', 'username=admin&password=wrong&next=%2Fsome%
|
|||||||
assert.equal(r.status, 403)
|
assert.equal(r.status, 403)
|
||||||
assert.match(await r.text(), /用户名或密码错误/)
|
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
|
// 6. POST login with correct credentials → 303 + cookie + next
|
||||||
r = await post('/panel-auth/login', 'username=admin&password=s3cret-pass&next=%2Fsome%2Fpage')
|
r = await post('/panel-auth/login', 'username=admin&password=s3cret-pass&next=%2Fsome%2Fpage')
|
||||||
assert.equal(r.status, 303)
|
assert.equal(r.status, 303)
|
||||||
@@ -126,6 +135,9 @@ const ok = auditLines.find((e) => e.event === 'login-ok')
|
|||||||
assert.equal(ok.username, 'admin')
|
assert.equal(ok.username, 'admin')
|
||||||
const fail = auditLines.find((e) => e.event === 'login-fail' && e.reason === 'bad-credentials')
|
const fail = auditLines.find((e) => e.event === 'login-fail' && e.reason === 'bad-credentials')
|
||||||
assert.equal(fail.username, 'admin')
|
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)
|
assert.equal(auditLines.every((e) => typeof e.ip === 'string' && typeof e.ts === 'string'), true)
|
||||||
|
|
||||||
// 12. XSS: hostile `next` value is escaped in the page
|
// 12. XSS: hostile `next` value is escaped in the page
|
||||||
|
|||||||
Reference in New Issue
Block a user