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.
100 lines
4.7 KiB
TypeScript
100 lines
4.7 KiB
TypeScript
import { describe, it, expect } from 'vitest'
|
|
import { buildApp } from '../src/app.js'
|
|
import { CpClientError } from '../src/cp-client.js'
|
|
import { HostIdSchema } from '../src/routes/api-routes.js'
|
|
import { makeConfig, fakeCpClient, fakeMinter, authCookieHeader } from './helpers.js'
|
|
|
|
const AUTH = { cookie: authCookieHeader() }
|
|
|
|
describe('session gating', () => {
|
|
it('rejects every proxy route without a valid session cookie (401)', async () => {
|
|
const app = await buildApp({ config: makeConfig(), cpClient: fakeCpClient(), minter: fakeMinter(), staticRoot: null })
|
|
for (const r of [
|
|
{ method: 'GET' as const, url: '/api/hosts' },
|
|
{ method: 'POST' as const, url: '/api/pairing-codes' },
|
|
{ method: 'DELETE' as const, url: '/api/hosts/abc' },
|
|
]) {
|
|
const res = await app.inject(r)
|
|
expect(res.statusCode).toBe(401)
|
|
}
|
|
await app.close()
|
|
})
|
|
})
|
|
|
|
describe('GET /api/hosts', () => {
|
|
it('mints a token and returns the CP host list for the operator account', async () => {
|
|
const cpClient = fakeCpClient({
|
|
hosts: [{ hostId: 'h1', subdomain: 'alpha', status: 'online', lastSeen: 'x', createdAt: 'y', revokedAt: null, notAfter: undefined }],
|
|
})
|
|
const minter = fakeMinter('MINTED')
|
|
const app = await buildApp({ config: makeConfig({ operatorAccountId: 'acct-XYZ' }), cpClient, minter, staticRoot: null })
|
|
|
|
const res = await app.inject({ method: 'GET', url: '/api/hosts', headers: AUTH })
|
|
expect(res.statusCode).toBe(200)
|
|
expect(res.json()).toEqual({ hosts: [{ hostId: 'h1', subdomain: 'alpha', status: 'online', lastSeen: 'x', createdAt: 'y', revokedAt: null, notAfter: undefined }] })
|
|
expect(minter.calls()).toBe(1)
|
|
expect(cpClient.calls[0]).toMatchObject({ method: 'listHosts', accountIdOrHostId: 'acct-XYZ', token: 'MINTED' })
|
|
await app.close()
|
|
})
|
|
|
|
it('maps an upstream CP failure to 502', async () => {
|
|
const cpClient = fakeCpClient({ throwErr: new CpClientError(403, 'denied') })
|
|
const app = await buildApp({ config: makeConfig(), cpClient, minter: fakeMinter(), staticRoot: null })
|
|
const res = await app.inject({ method: 'GET', url: '/api/hosts', headers: AUTH })
|
|
expect(res.statusCode).toBe(502)
|
|
expect(res.json()).toEqual({ error: 'upstream_error' })
|
|
await app.close()
|
|
})
|
|
})
|
|
|
|
describe('POST /api/pairing-codes', () => {
|
|
it('returns the code, pair command, and a QR data URL', async () => {
|
|
const cpClient = fakeCpClient({ pairing: { code: 'ABCD-EFGH', expiresAt: '2026-03-01T00:00:00.000Z' } })
|
|
const app = await buildApp({ config: makeConfig({ tunnelZone: 'z.test' }), cpClient, minter: fakeMinter(), staticRoot: null })
|
|
|
|
const res = await app.inject({ method: 'POST', url: '/api/pairing-codes', headers: AUTH })
|
|
expect(res.statusCode).toBe(201)
|
|
const body = res.json()
|
|
expect(body.code).toBe('ABCD-EFGH')
|
|
expect(body.expiresAt).toBe('2026-03-01T00:00:00.000Z')
|
|
expect(body.pairCommand).toBe('web-terminal-agent pair ABCD-EFGH --install --zone z.test')
|
|
expect(String(body.qrDataUrl).startsWith('data:image/png;base64,')).toBe(true)
|
|
await app.close()
|
|
})
|
|
})
|
|
|
|
describe('DELETE /api/hosts/:hostId', () => {
|
|
it('revokes the host and returns 204', async () => {
|
|
const cpClient = fakeCpClient()
|
|
const app = await buildApp({ config: makeConfig(), cpClient, minter: fakeMinter('T'), staticRoot: null })
|
|
const res = await app.inject({ method: 'DELETE', url: '/api/hosts/host-42', headers: AUTH })
|
|
expect(res.statusCode).toBe(204)
|
|
expect(cpClient.calls[0]).toMatchObject({ method: 'deleteHost', accountIdOrHostId: 'host-42', token: 'T' })
|
|
await app.close()
|
|
})
|
|
|
|
it('rejects an invalid host id with 400', async () => {
|
|
const cpClient = fakeCpClient()
|
|
const app = await buildApp({ config: makeConfig(), cpClient, minter: fakeMinter(), staticRoot: null })
|
|
const res = await app.inject({ method: 'DELETE', url: '/api/hosts/' + encodeURIComponent('bad id!'), headers: AUTH })
|
|
expect(res.statusCode).toBe(400)
|
|
expect(cpClient.calls).toHaveLength(0)
|
|
await app.close()
|
|
})
|
|
})
|
|
|
|
describe('HostIdSchema dot-segment rejection', () => {
|
|
// The HTTP router already normalizes literal `.`/`..` path segments, but the outbound CP admin URL is
|
|
// built as `${cpUrl}/hosts/${hostId}` — so the schema itself must refuse dot-segments (defense-in-depth).
|
|
it('rejects a bare "." and ".."', () => {
|
|
expect(HostIdSchema.safeParse('.').success).toBe(false)
|
|
expect(HostIdSchema.safeParse('..').success).toBe(false)
|
|
})
|
|
|
|
it('accepts a UUID host id, a hyphenated id, and "..." (not a dot-segment)', () => {
|
|
expect(HostIdSchema.safeParse('550e8400-e29b-41d4-a716-446655440000').success).toBe(true)
|
|
expect(HostIdSchema.safeParse('host-42').success).toBe(true)
|
|
expect(HostIdSchema.safeParse('...').success).toBe(true)
|
|
})
|
|
})
|