Loopback Fastify auth-broker + esbuild SPA. Operator password login (constant-time, signed HttpOnly session cookie, per-forwarded-IP rate-limit) → session-gated proxy that mints a fresh 60s manage capability token per call to the control-plane admin API: list hosts, mint pairing codes (with QR + pair command), revoke hosts. Security headers + CSP, CP_URL pinned loopback (anti-SSRF), hostId dot-segment guard. 55 tests pass; security-reviewed. Deployed behind nginx panel.terminal.yaojia.wang.
103 lines
4.5 KiB
TypeScript
103 lines
4.5 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import { buildApp } from '../src/app.js'
|
|
import { SESSION_COOKIE_NAME } from '../src/security/cookies.js'
|
|
import { createSlidingWindowLimiter, type RateLimiter } from '../src/security/rate-limit.js'
|
|
import { makeConfig, fakeCpClient, fakeMinter, authCookieHeader, TEST_SESSION_SECRET } from './helpers.js'
|
|
|
|
async function makeApp(overrides: Parameters<typeof buildApp>[0] extends infer T ? Partial<T> : never = {}) {
|
|
return buildApp({
|
|
config: makeConfig(),
|
|
cpClient: fakeCpClient(),
|
|
minter: fakeMinter(),
|
|
staticRoot: null,
|
|
...overrides,
|
|
})
|
|
}
|
|
|
|
describe('POST /login', () => {
|
|
it('returns 503 when PANEL_PASSWORD is unset (fail-closed)', async () => {
|
|
const app = await makeApp({ config: makeConfig({ panelPassword: undefined }) })
|
|
const res = await app.inject({ method: 'POST', url: '/login', payload: { password: 'anything' } })
|
|
expect(res.statusCode).toBe(503)
|
|
await app.close()
|
|
})
|
|
|
|
it('returns 401 on the wrong password (no cookie set)', async () => {
|
|
const app = await makeApp()
|
|
const res = await app.inject({ method: 'POST', url: '/login', payload: { password: 'wrong' } })
|
|
expect(res.statusCode).toBe(401)
|
|
expect(res.headers['set-cookie']).toBeUndefined()
|
|
await app.close()
|
|
})
|
|
|
|
it('returns 200 and sets an HttpOnly SameSite=Strict session cookie on success', async () => {
|
|
const app = await makeApp({ config: makeConfig({ panelPassword: 'secret-pw' }) })
|
|
const res = await app.inject({ method: 'POST', url: '/login', payload: { password: 'secret-pw' } })
|
|
expect(res.statusCode).toBe(200)
|
|
expect(res.json()).toEqual({ authenticated: true })
|
|
const cookie = String(res.headers['set-cookie'])
|
|
expect(cookie).toContain(`${SESSION_COOKIE_NAME}=`)
|
|
expect(cookie).toContain('HttpOnly')
|
|
expect(cookie).toContain('SameSite=Strict')
|
|
await app.close()
|
|
})
|
|
|
|
it('returns 429 when rate-limited (before credential work)', async () => {
|
|
const denyAll: RateLimiter = { allow: () => false }
|
|
const app = await makeApp({ rateLimiter: denyAll })
|
|
const res = await app.inject({ method: 'POST', url: '/login', payload: { password: 'whatever' } })
|
|
expect(res.statusCode).toBe(429)
|
|
await app.close()
|
|
})
|
|
|
|
it('returns 400 on a malformed body', async () => {
|
|
const app = await makeApp()
|
|
const res = await app.inject({ method: 'POST', url: '/login', payload: { notpassword: 1 } })
|
|
expect(res.statusCode).toBe(400)
|
|
await app.close()
|
|
})
|
|
|
|
// trustProxy: true (app.ts) makes req.ip read X-Forwarded-For, so the limiter buckets by REAL client
|
|
// IP. Without it every request would share the single 127.0.0.1 bucket (a global lockout DoS).
|
|
it('rate-limits per forwarded client IP: same X-Forwarded-For shares a budget, a different one is independent', async () => {
|
|
const limiter = createSlidingWindowLimiter(1, 60_000, () => 0) // one attempt per window per key
|
|
const app = await makeApp({ rateLimiter: limiter })
|
|
const login = (xff: string) =>
|
|
app.inject({ method: 'POST', url: '/login', headers: { 'x-forwarded-for': xff }, payload: { password: 'wrong' } })
|
|
|
|
// IP A, 1st attempt: budget available → reaches credential check → 401 (not throttled).
|
|
expect((await login('203.0.113.10')).statusCode).toBe(401)
|
|
// IP A, 2nd attempt: same bucket exhausted → 429.
|
|
expect((await login('203.0.113.10')).statusCode).toBe(429)
|
|
// IP B: its own bucket → 401, proving req.ip reflects the forwarded address (else it would be 429).
|
|
expect((await login('198.51.100.20')).statusCode).toBe(401)
|
|
await app.close()
|
|
})
|
|
})
|
|
|
|
describe('POST /logout', () => {
|
|
it('clears the session cookie', async () => {
|
|
const app = await makeApp()
|
|
const res = await app.inject({ method: 'POST', url: '/logout' })
|
|
expect(res.statusCode).toBe(200)
|
|
expect(String(res.headers['set-cookie'])).toContain('Max-Age=0')
|
|
await app.close()
|
|
})
|
|
})
|
|
|
|
describe('GET /api/session', () => {
|
|
it('reports authenticated:false without a cookie', async () => {
|
|
const app = await makeApp()
|
|
const res = await app.inject({ method: 'GET', url: '/api/session' })
|
|
expect(res.json()).toEqual({ authenticated: false })
|
|
await app.close()
|
|
})
|
|
|
|
it('reports authenticated:true with a valid session cookie', async () => {
|
|
const app = await makeApp()
|
|
const res = await app.inject({ method: 'GET', url: '/api/session', headers: { cookie: authCookieHeader(TEST_SESSION_SECRET) } })
|
|
expect(res.json()).toEqual({ authenticated: true })
|
|
await app.close()
|
|
})
|
|
})
|