Files
panel-auth/test.mjs
T
dsh ddb32ce129 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.
2026-08-16 02:18:07 -04:00

265 lines
11 KiB
JavaScript

// Throwaway verification of panel-auth against a server structured exactly
// like dsh-host-webserver: a createServer callback ('request') plus an
// 'upgrade' listener. Does not touch the running panel.
import { createServer } from 'node:http'
import { strict as assert } from 'node:assert'
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { createGuard, installGuard, createAuditWriter, createLockout, renderLoginPage } from './index.js'
import { hashPassword, verifyPassword } from './crypto.js'
const tmp = mkdtempSync(join(tmpdir(), 'panel-auth-test-'))
const audit = createAuditWriter(join(tmp, 'audit.jsonl'))
const cfg = {
users: [{ username: 'admin', passwordHash: hashPassword('s3cret-pass') }],
secret: 'unit-test-secret-0123456789abcdef',
realm: 'Test Realm',
cookieName: 'test_cookie',
cookieTtlSeconds: 3600,
}
const guard = createGuard({ getConfig: () => cfg, logger: null })
let upgradeCount = 0
const server = createServer((req, res) => {
res.statusCode = 200
res.setHeader('Content-Type', 'text/plain')
res.end('panel')
})
server.on('upgrade', (req, socket, head) => {
upgradeCount++
socket.end('HTTP/1.1 101 Switching Protocols\r\nUpgrade: test\r\nConnection: Upgrade\r\n\r\n')
})
const lockout = createLockout({ maxFailures: 4, lockoutBaseSeconds: 1, lockoutMaxSeconds: 2, failureWindowSeconds: 60, enabled: true })
const disposer = installGuard(server, guard, { audit, loginPath: '/panel-auth/login', logoutPath: '/panel-auth/logout', lockout, failedLoginDelayMs: 0 })
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
const base = `http://127.0.0.1:${server.address().port}`
const raw = (path, headers = {}, init = {}) => fetch(base + path, { headers, redirect: 'manual', ...init })
const post = (path, body, headers = {}) =>
raw(path, { 'Content-Type': 'application/x-www-form-urlencoded', ...headers }, { method: 'POST', body: String(body) })
const upgrade = async (headers = []) => {
const net = await import('node:net')
const sock = net.connect(server.address().port, '127.0.0.1')
const response = await new Promise((resolve) => {
let buf = ''
sock.on('data', (d) => {
buf += d.toString()
if (buf.includes('\r\n\r\n')) resolve(buf)
})
const h = ['GET /api/events.mux HTTP/1.1', 'Host: 127.0.0.1', 'Connection: Upgrade', 'Upgrade: websocket', ...headers]
sock.write(h.join('\r\n') + '\r\n\r\n')
})
sock.destroy()
return response
}
// 1. anonymous API call → 401 + Basic challenge (curl/script contract kept)
let r = await raw('/api/x')
assert.equal(r.status, 401)
assert.match(r.headers.get('www-authenticate'), /^Basic realm="Test Realm"/)
assert.equal(await r.text(), '{"error":"authentication required"}')
// 2. anonymous navigation → pretty login page, NO native challenge header
r = await raw('/some/page', { accept: 'text/html' })
assert.equal(r.status, 200)
assert.equal(r.headers.get('www-authenticate'), null)
const page = await r.text()
assert.match(page, /登录以继续/)
assert.match(page, /name="username"/)
assert.match(page, /value="\/some\/page"/) // next preserved
// 3. GET the login path itself → 200 page
r = await raw('/panel-auth/login', { accept: 'text/html' })
assert.equal(r.status, 200)
assert.match(await r.text(), /DSH|Test Realm/)
// 4. POST login with missing fields → 403 error page
r = await post('/panel-auth/login', 'username=admin')
assert.equal(r.status, 403)
assert.match(await r.text(), /请输入用户名和密码/)
// 5. POST login with wrong credentials → 403 error page
r = await post('/panel-auth/login', 'username=admin&password=wrong&next=%2Fsome%2Fpage')
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)
{
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)
assert.equal(r.headers.get('location'), '/some/page')
const setCookie = r.headers.get('set-cookie')
assert.match(setCookie, /^test_cookie=.+; Path=\/; HttpOnly; SameSite=Lax; Max-Age=3600$/)
const cookieValue = setCookie.split(';')[0].slice('test_cookie='.length)
// 7. cookie reuse → panel content, no new cookie
r = await raw('/', { cookie: `test_cookie=${cookieValue}` })
assert.equal(r.status, 200)
assert.equal(await r.text(), 'panel')
assert.equal(r.headers.get('set-cookie'), null)
// 8. tampered cookie → 401 (API path)
r = await raw('/api/x', { cookie: 'test_cookie=' + cookieValue.slice(0, -2) + 'xx' })
assert.equal(r.status, 401)
// 9. websocket: anonymous → 401, with cookie → 101
assert.match(await upgrade(), /^HTTP\/1\.1 401 /)
assert.match(await upgrade([`Cookie: test_cookie=${cookieValue}`]), /^HTTP\/1\.1 101 /)
assert.equal(upgradeCount, 1)
// 10. logout → 303 + expired cookie, then anonymous navigation → login page again
r = await raw('/panel-auth/logout', { cookie: `test_cookie=${cookieValue}` })
assert.equal(r.status, 303)
assert.match(r.headers.get('set-cookie'), /Max-Age=0/)
r = await raw('/', { accept: 'text/html' })
assert.equal(r.status, 200)
assert.match(await r.text(), /登录以继续/)
// 10b. brute force: lockout after maxFailures bad logins (correct creds also rejected)
lockout.clear('127.0.0.1')
for (let i = 0; i < 4; i++) {
r = await post('/panel-auth/login', 'username=admin&password=nope&next=%2F')
assert.equal(r.status, 403)
}
r = await post('/panel-auth/login', 'username=admin&password=s3cret-pass&next=%2F')
assert.equal(r.status, 429)
assert.ok(Number(r.headers.get('retry-after')) >= 1)
assert.match(await r.text(), /尝试次数过多/)
// 10c. lockout expiry restores access
await new Promise((resolve) => setTimeout(resolve, 1200))
r = await post('/panel-auth/login', 'username=admin&password=s3cret-pass&next=%2F')
assert.equal(r.status, 303)
// 10d. Basic-auth path is counted too, and clears on success
lockout.clear('127.0.0.1')
for (let i = 0; i < 4; i++) {
r = await raw('/', { authorization: 'Basic ' + Buffer.from('admin:wrong').toString('base64') })
assert.equal(r.status, 401)
}
r = await raw('/', { authorization: 'Basic ' + Buffer.from('admin:s3cret-pass').toString('base64') })
assert.equal(r.status, 429)
assert.match(await r.text(), /too many attempts/)
lockout.clear('127.0.0.1')
r = await raw('/', { authorization: 'Basic ' + Buffer.from('admin:s3cret-pass').toString('base64') })
assert.equal(r.status, 200)
// 10e. X-Forwarded-For: the last hop wins as the IP key
lockout.clear('127.0.0.1')
await post('/panel-auth/login', 'username=admin&password=nope&next=%2F', { 'X-Forwarded-For': '1.2.3.4, 9.9.9.9' })
const auditAfterXff = readFileSync(join(tmp, 'audit.jsonl'), 'utf8').trim().split('\n').map((line) => JSON.parse(line))
assert.equal(auditAfterXff.at(-1).ip, '9.9.9.9')
// 11. audit log contains the expected events
const auditLines = readFileSync(join(tmp, 'audit.jsonl'), 'utf8').trim().split('\n').map((line) => JSON.parse(line))
const events = auditLines.map((e) => e.event)
for (const expected of ['reject', 'challenge', 'login-fail', 'login-ok', 'logout']) {
assert.ok(events.includes(expected), `audit missing event ${expected}`)
}
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.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)
// 12. XSS: hostile `next` value is escaped in the page
const hostile = renderLoginPage({ realm: 'T', next: '/" onmouseover="alert(1)', error: '' })
assert.ok(!hostile.includes('onmouseover="alert(1)'), 'next not escaped')
const hostileError = renderLoginPage({ realm: 'T', next: '/', error: '<script>alert(1)</script>' })
assert.ok(!hostileError.includes('<script>alert(1)'), 'error not escaped')
// 13. hot-disable via config → anonymous passes through (fail-open)
cfg.users = []
r = await raw('/')
assert.equal(r.status, 200)
cfg.users = [{ username: 'admin', passwordHash: hashPassword('s3cret-pass') }]
// 14. dispose restores original behavior
disposer()
r = await raw('/')
assert.equal(r.status, 200)
assert.equal(await r.text(), 'panel')
// 15. hash round-trip sanity
assert.equal(verifyPassword('s3cret-pass', cfg.users[0].passwordHash), true)
assert.equal(verifyPassword('other', cfg.users[0].passwordHash), false)
server.close()
rmSync(tmp, { recursive: true, force: true })
console.log('ALL PANEL-AUTH TESTS PASSED')