Files
dsh 96b5fb6ed0 refactor: move logout/change-password UI from floating widget to settings
The injected floating buttons overlapped the mobile composer send button.
Replace the tapIndex-injected widget with a proper client plugin
(panel-auth-ui, ui/ package): registers an account & security section in
the panel settings via the settings.section slot, with change-password
form and logout action. Host plugin keeps only the /panel-auth/change-
password and /panel-auth/logout endpoints.

- ui/: ModuleLoader-format client bundle (dsh.client declaration,
  exports ./client and ./package.json), host no-op stub, bare-package
  symlink required in the profile node_modules (documented in README).
- Host: renderAuthWidget/injectWidget/tapIndex injection removed.
- Tests: widget tests removed; change-password flows unchanged.
2026-08-16 03:28:31 -04:00

302 lines
13 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 })
let currentPassword = 's3cret-pass'
const changePassword = async ({ username, oldPassword, newPassword }) => {
if (oldPassword !== currentPassword) return { status: 403, message: '当前密码错误', results: [] }
if (typeof newPassword !== 'string' || newPassword.length < 8) return { status: 400, message: '新密码至少 8 位', results: [] }
currentPassword = newPassword
return { status: 200, message: '密码已更新', results: ['stub'] }
}
const disposer = installGuard(server, guard, {
audit,
loginPath: '/panel-auth/login',
logoutPath: '/panel-auth/logout',
changePasswordPath: '/panel-auth/change-password',
changePassword,
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')
// 10f. change-password endpoint
lockout.clear('127.0.0.1')
const cpHeaders = { cookie: `test_cookie=${cookieValue}`, 'Content-Type': 'application/x-www-form-urlencoded' }
r = await post('/panel-auth/change-password', 'oldPassword=wrong&newPassword=newpass-123')
assert.equal(r.status, 401) // anonymous rejected
r = await raw('/panel-auth/change-password', cpHeaders, { method: 'POST', body: 'oldPassword=wrong&newPassword=newpass-123' })
assert.equal(r.status, 403) // wrong old password
assert.match(await r.text(), /当前密码错误/)
r = await raw('/panel-auth/change-password', cpHeaders, { method: 'POST', body: 'oldPassword=s3cret-pass&newPassword=short' })
assert.equal(r.status, 400) // policy violation
r = await raw('/panel-auth/change-password', cpHeaders, { method: 'POST', body: 'oldPassword=s3cret-pass&newPassword=newpass-123' })
assert.equal(r.status, 200)
assert.match(await r.text(), /密码已更新/)
// emulate the live config update and confirm the credential switch
cfg.users = [{ username: 'admin', passwordHash: hashPassword('newpass-123') }]
r = await post('/panel-auth/login', 'username=admin&password=s3cret-pass&next=%2F')
assert.equal(r.status, 403)
r = await post('/panel-auth/login', 'username=admin&password=newpass-123&next=%2F')
assert.equal(r.status, 303)
// 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.ok(auditLines.some((e) => e.event === 'password-change'), 'audit missing password-change entry')
assert.ok(auditLines.some((e) => e.event === 'login-fail' && e.reason === 'password-change-old-mismatch'), 'audit missing old-mismatch 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')