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:
@@ -63,6 +63,26 @@
|
|||||||
> 300ms 失败延迟 + scrypt 慢哈希兜底。公网反代场景建议再配合 Caddy
|
> 300ms 失败延迟 + scrypt 慢哈希兜底。公网反代场景建议再配合 Caddy
|
||||||
> 层的 IP 白名单/云防火墙(如 Cloudflare)使用。
|
> 层的 IP 白名单/云防火墙(如 Cloudflare)使用。
|
||||||
|
|
||||||
|
## 反向代理部署(重要)
|
||||||
|
|
||||||
|
DSH 上游将 `settings.*`、`credentials.*`、`agentPreset.*`、`host.pickDirectory`
|
||||||
|
等**特权 /api 方法锁定为仅回环(loopback)Host 可访问**(浏览器信任围栏的
|
||||||
|
设计:面板预期经 SSH 隧道访问)。用反向代理(如 Caddy)前置面板时,需把
|
||||||
|
Host 以回环形式转发给面板,否则这些方法返回 `403 forbidden`:
|
||||||
|
|
||||||
|
```
|
||||||
|
dsh.example.com {
|
||||||
|
reverse_proxy 127.0.0.1:3080 {
|
||||||
|
header_up Host 127.0.0.1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- 认证不受影响:所有请求仍先过 panel-auth(密码/Cookie + 防爆破),
|
||||||
|
且 panel-auth 的 Cookie 是浏览器端存储,与 Host 头无关。
|
||||||
|
- panel-auth 的来源校验在 Host 为回环时自动跳过(代理场景);对外域名下的
|
||||||
|
真实跨站提交仍会被拒绝(`非法请求来源`)。
|
||||||
|
|
||||||
## 修改密码
|
## 修改密码
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@@ -439,6 +439,17 @@ function sameHost(originHeader, hostHeader) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** True when the Host header names the loopback authority (direct access or a
|
||||||
|
* reverse proxy presenting a loopback Host upstream). */
|
||||||
|
function isLoopbackHost(hostHeader) {
|
||||||
|
try {
|
||||||
|
const hostname = new URL(`http://${hostHeader}`).hostname.toLowerCase()
|
||||||
|
return hostname === 'localhost' || hostname === '[::1]' || /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname)
|
||||||
|
} 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
|
||||||
@@ -521,6 +532,7 @@ export function installGuard(server, guard, { audit, loginPath, logoutPath, lock
|
|||||||
if (typeof origin === 'string' &&
|
if (typeof origin === 'string' &&
|
||||||
origin !== 'null' &&
|
origin !== 'null' &&
|
||||||
typeof host === 'string' &&
|
typeof host === 'string' &&
|
||||||
|
!isLoopbackHost(host) &&
|
||||||
!sameHost(origin, host)
|
!sameHost(origin, host)
|
||||||
) {
|
) {
|
||||||
audit.write({
|
audit.write({
|
||||||
|
|||||||
@@ -86,19 +86,75 @@ 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
|
const rawPost = async (host, origin, body) => {
|
||||||
r = await post('/panel-auth/login', 'username=admin&password=s3cret-pass&next=%2F', { Origin: 'https://evil.example.com' })
|
const http = await import('node:http')
|
||||||
assert.equal(r.status, 403)
|
const port = server.address().port
|
||||||
assert.match(await r.text(), /非法请求来源/)
|
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)
|
// 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
|
// 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' })
|
r = await post('/panel-auth/login', 'username=admin&password=s3cret-pass&next=%2F', { Origin: 'null' })
|
||||||
assert.equal(r.status, 303)
|
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
|
// 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)
|
||||||
@@ -177,7 +233,7 @@ const fail = auditLines.find((e) => e.event === 'login-fail' && e.reason === 'ba
|
|||||||
assert.equal(fail.username, 'admin')
|
assert.equal(fail.username, 'admin')
|
||||||
const co = auditLines.find((e) => e.event === 'login-fail' && e.reason === 'cross-origin')
|
const co = auditLines.find((e) => e.event === 'login-fail' && e.reason === 'cross-origin')
|
||||||
assert.equal(co.origin, 'https://evil.example.com')
|
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.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)
|
assert.equal(auditLines.every((e) => typeof e.ip === 'string' && typeof e.ts === 'string'), true)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user