// 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, 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 disposer = installGuard(server, guard, { audit, loginPath: '/panel-auth/login', logoutPath: '/panel-auth/logout' }) 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(), /用户名或密码错误/) // 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(), /非法请求来源/) // 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) // 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(), /登录以继续/) // 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.match(co.host, /^127\.0\.0\.1:/) 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: '' }) assert.ok(!hostileError.includes('