diff --git a/README.md b/README.md index 76d1e5f..fcf8543 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,26 @@ > 300ms 失败延迟 + scrypt 慢哈希兜底。公网反代场景建议再配合 Caddy > 层的 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 diff --git a/index.js b/index.js index 088a337..0ae6d8a 100644 --- a/index.js +++ b/index.js @@ -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) { return new Promise((resolveBody) => { let size = 0 @@ -521,6 +532,7 @@ export function installGuard(server, guard, { audit, loginPath, logoutPath, lock if (typeof origin === 'string' && origin !== 'null' && typeof host === 'string' && + !isLoopbackHost(host) && !sameHost(origin, host) ) { audit.write({ diff --git a/test.mjs b/test.mjs index 38e6e72..df99820 100644 --- a/test.mjs +++ b/test.mjs @@ -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)