forked from dsh/panel-auth
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.
302 lines
13 KiB
JavaScript
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')
|