fix: tolerate loopback Host behind reverse proxy, document Caddy setup

DSH pins privileged /api methods (settings.*, credentials.*, agentPreset.*,
host.pickDirectory, ...) to loopback hosts by design. When a reverse proxy
fronts the panel with the public Host header, those methods return 403.

The supported deployment shape is forwarding Host as loopback upstream
(header_up Host 127.0.0.1 in Caddy). This change:
- skips the login origin check when the incoming Host is loopback (proxy
  context), while still rejecting real cross-site posts on public hosts;
- documents the reverse-proxy requirement in the README;
- extends tests with raw-request coverage for non-loopback cross-site
  rejection, port tolerance, and loopback-Host skip.
This commit is contained in:
dsh
2026-08-16 02:18:07 -04:00
parent 8823049b66
commit ddb32ce129
3 changed files with 95 additions and 7 deletions
+63 -7
View File
@@ -86,19 +86,75 @@ 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(), /非法请求来源/)
const rawPost = async (host, origin, body) => {
const http = await import('node:http')
const port = server.address().port
return new Promise((resolve) => {
const req = http.request({
host: '127.0.0.1',
port,
method: 'POST',
path: '/panel-auth/login',
setHost: false,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...(origin === undefined ? {} : { Origin: origin }),
Host: host,
'Content-Length': Buffer.byteLength(body),
},
}, (res) => {
let text = ''
res.on('data', (d) => (text += d))
res.on('end', () => resolve({ status: res.statusCode, text }))
})
req.end(body)
})
}
// 5b. cross-site POST (non-loopback Host + foreign Origin) → 403 + audit origin/host
{
const res = await rawPost('panel.example', 'https://evil.example.com', 'username=admin&password=s3cret-pass&next=%2F')
assert.equal(res.status, 403)
assert.match(res.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)
{
const res = await rawPost('panel.example:8443', 'https://panel.example', 'username=admin&password=s3cret-pass&next=%2F')
assert.equal(res.status, 303)
}
// 5d. `Origin: null` (opaque context: privacy proxy / sandboxed iframe) → tolerated
r = await post('/panel-auth/login', 'username=admin&password=s3cret-pass&next=%2F', { Origin: 'null' })
assert.equal(r.status, 303)
// 5e. reverse-proxy mode: loopback Host + foreign-looking Origin → tolerated
{
const http = await import('node:http')
const port = server.address().port
const result = await new Promise((resolve) => {
const body = 'username=admin&password=s3cret-pass&next=%2F'
const req = http.request({
host: '127.0.0.1',
port,
method: 'POST',
path: '/panel-auth/login',
setHost: false,
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Origin: 'https://dsh.lmve.net',
Host: '127.0.0.1',
'Content-Length': Buffer.byteLength(body),
},
}, (res) => {
res.resume()
resolve(res.statusCode)
})
req.end(body)
})
assert.equal(result, 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)
@@ -177,7 +233,7 @@ const fail = auditLines.find((e) => e.event === 'login-fail' && e.reason === 'ba
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(co.host, 'panel.example')
assert.ok(auditLines.some((e) => e.event === 'login-fail' && e.reason === 'rate-limited'), 'audit missing rate-limited entry')
assert.equal(auditLines.every((e) => typeof e.ip === 'string' && typeof e.ts === 'string'), true)