feat(control-panel): web admin UI for the zero-touch tunnel

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.
This commit is contained in:
Yaojia Wang
2026-07-19 19:47:51 +02:00
parent 7c1d43376d
commit 675de771c7
38 changed files with 5207 additions and 0 deletions

View File

@@ -0,0 +1,99 @@
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)
})
})

View File

@@ -0,0 +1,102 @@
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()
})
})

View File

@@ -0,0 +1,31 @@
import { describe, it, expect } from 'vitest'
import { constantTimeEqual, constantTimeEqualBytes } from '../src/security/compare.js'
describe('constantTimeEqual', () => {
it('returns true for identical strings', () => {
expect(constantTimeEqual('correct-horse', 'correct-horse')).toBe(true)
})
it('returns false for different strings', () => {
expect(constantTimeEqual('correct-horse', 'battery-staple')).toBe(false)
})
it('returns false for different-length strings (no length oracle)', () => {
expect(constantTimeEqual('abc', 'abcdef')).toBe(false)
})
it('returns false when either side is empty or undefined', () => {
expect(constantTimeEqual('', 'x')).toBe(false)
expect(constantTimeEqual('x', '')).toBe(false)
expect(constantTimeEqual(undefined, 'x')).toBe(false)
expect(constantTimeEqual('x', undefined)).toBe(false)
})
})
describe('constantTimeEqualBytes', () => {
it('true for equal buffers, false for differing or mismatched length', () => {
expect(constantTimeEqualBytes(Buffer.from('aa'), Buffer.from('aa'))).toBe(true)
expect(constantTimeEqualBytes(Buffer.from('aa'), Buffer.from('ab'))).toBe(false)
expect(constantTimeEqualBytes(Buffer.from('aa'), Buffer.from('aaa'))).toBe(false)
})
})

View File

@@ -0,0 +1,74 @@
import { describe, it, expect } from 'vitest'
import {
loadConfig,
DEFAULT_CP_URL,
DEFAULT_TUNNEL_ZONE,
DEFAULT_PANEL_BIND_PORT,
DEFAULT_CAPABILITY_SIGN_KEY_PATH,
} from '../src/config.js'
const base = {
SESSION_SECRET: 'a-sufficiently-long-secret-value',
BASE_DOMAIN: 'terminal.yaojia.wang',
OPERATOR_ACCOUNT_ID: 'acct-1',
}
describe('loadConfig', () => {
it('applies defaults for optional fields', () => {
const cfg = loadConfig({ ...base } as NodeJS.ProcessEnv)
expect(cfg.cpUrl).toBe(DEFAULT_CP_URL)
expect(cfg.tunnelZone).toBe(DEFAULT_TUNNEL_ZONE)
expect(cfg.panelBindPort).toBe(DEFAULT_PANEL_BIND_PORT)
expect(cfg.capabilitySignKeyPath).toBe(DEFAULT_CAPABILITY_SIGN_KEY_PATH)
expect(cfg.panelPassword).toBeUndefined()
})
it('reads all provided values and strips trailing slash from CP_URL', () => {
const cfg = loadConfig({
...base,
PANEL_PASSWORD: 'pw',
CP_URL: 'http://127.0.0.1:9000/',
TUNNEL_ZONE: 'z.example',
PANEL_BIND_PORT: '9999',
} as NodeJS.ProcessEnv)
expect(cfg.panelPassword).toBe('pw')
expect(cfg.cpUrl).toBe('http://127.0.0.1:9000')
expect(cfg.tunnelZone).toBe('z.example')
expect(cfg.panelBindPort).toBe(9999)
})
it('throws when SESSION_SECRET is missing (fail-closed)', () => {
const { SESSION_SECRET: _omit, ...rest } = base
expect(() => loadConfig(rest as NodeJS.ProcessEnv)).toThrow()
})
it('throws when SESSION_SECRET is too short', () => {
expect(() => loadConfig({ ...base, SESSION_SECRET: 'short' } as NodeJS.ProcessEnv)).toThrow()
})
it('throws when BASE_DOMAIN is missing', () => {
const { BASE_DOMAIN: _omit, ...rest } = base
expect(() => loadConfig(rest as NodeJS.ProcessEnv)).toThrow()
})
it('throws when OPERATOR_ACCOUNT_ID is missing', () => {
const { OPERATOR_ACCOUNT_ID: _omit, ...rest } = base
expect(() => loadConfig(rest as NodeJS.ProcessEnv)).toThrow()
})
it('throws when PANEL_BIND_PORT is out of range', () => {
expect(() => loadConfig({ ...base, PANEL_BIND_PORT: '70000' } as NodeJS.ProcessEnv)).toThrow()
})
it('accepts loopback CP_URL hosts (127.0.0.0/8, ::1, localhost)', () => {
for (const url of ['http://127.0.0.1:8080', 'http://127.9.9.9:1', 'http://[::1]:8080', 'http://localhost:8080']) {
expect(loadConfig({ ...base, CP_URL: url } as NodeJS.ProcessEnv).cpUrl).toBe(url)
}
})
it('throws when CP_URL host is not loopback (anti-SSRF, fail-closed)', () => {
for (const url of ['http://evil.example.com:8080', 'http://169.254.169.254/', 'http://10.0.0.5:8080', 'http://8.8.8.8']) {
expect(() => loadConfig({ ...base, CP_URL: url } as NodeJS.ProcessEnv)).toThrow()
}
})
})

View File

@@ -0,0 +1,94 @@
import { describe, it, expect } from 'vitest'
import { createCpClient, CpClientError } from '../src/cp-client.js'
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json' } })
}
describe('createCpClient.listHosts', () => {
it('maps the CP host records to a curated view and strips agentPubkey/enrollFpr', async () => {
const captured: { url?: string; init?: RequestInit | undefined } = {}
const fetchFn = (async (url: string | URL | Request, init?: RequestInit) => {
captured.url = String(url)
captured.init = init
return jsonResponse([
{
hostId: 'h1',
accountId: 'acct-1',
subdomain: 'alpha',
agentPubkey: 'BASE64PUBKEY',
enrollFpr: 'fpr',
status: 'online',
lastSeen: '2026-01-02T00:00:00.000Z',
createdAt: '2026-01-01T00:00:00.000Z',
revokedAt: null,
},
])
}) as typeof fetch
const client = createCpClient({ cpUrl: 'http://127.0.0.1:8080', fetchFn })
const hosts = await client.listHosts('acct-1', 'TOKEN123')
expect(captured.url).toBe('http://127.0.0.1:8080/accounts/acct-1/hosts')
expect((captured.init?.headers as Record<string, string>).authorization).toBe('Bearer TOKEN123')
expect(hosts).toHaveLength(1)
expect(hosts[0]).toMatchObject({ hostId: 'h1', subdomain: 'alpha', status: 'online', lastSeen: '2026-01-02T00:00:00.000Z' })
expect(hosts[0]).not.toHaveProperty('agentPubkey')
expect(hosts[0]).not.toHaveProperty('enrollFpr')
})
it('throws CpClientError carrying the upstream status on non-2xx', async () => {
const fetchFn = (async () => new Response('nope', { status: 403 })) as typeof fetch
const client = createCpClient({ cpUrl: 'http://127.0.0.1:8080', fetchFn })
await expect(client.listHosts('acct-1', 't')).rejects.toMatchObject({ name: 'CpClientError', status: 403 })
})
it('maps a network failure to a 502 CpClientError', async () => {
const fetchFn = (async () => {
throw new Error('ECONNREFUSED')
}) as typeof fetch
const client = createCpClient({ cpUrl: 'http://127.0.0.1:8080', fetchFn })
await expect(client.listHosts('acct-1', 't')).rejects.toMatchObject({ status: 502 })
})
})
describe('createCpClient.createPairingCode', () => {
it('POSTs and maps { code, expiresAt }', async () => {
const captured: { init?: RequestInit | undefined } = {}
const fetchFn = (async (_url: string | URL | Request, init?: RequestInit) => {
captured.init = init
return jsonResponse({ code: 'ABCD-EFGH', expiresAt: '2026-02-01T00:00:00.000Z' }, 201)
}) as typeof fetch
const client = createCpClient({ cpUrl: 'http://127.0.0.1:8080', fetchFn })
const issued = await client.createPairingCode('acct-1', 'TOK')
expect(captured.init?.method).toBe('POST')
expect(issued).toEqual({ code: 'ABCD-EFGH', expiresAt: '2026-02-01T00:00:00.000Z' })
})
it('rejects a malformed CP pairing response', async () => {
const fetchFn = (async () => jsonResponse({ nope: true }, 201)) as typeof fetch
const client = createCpClient({ cpUrl: 'http://127.0.0.1:8080', fetchFn })
await expect(client.createPairingCode('acct-1', 't')).rejects.toBeInstanceOf(Error)
})
})
describe('createCpClient.deleteHost', () => {
it('DELETEs and resolves on 204', async () => {
const captured: { url?: string; init?: RequestInit | undefined } = {}
const fetchFn = (async (url: string | URL | Request, init?: RequestInit) => {
captured.url = String(url)
captured.init = init
return new Response(null, { status: 204 })
}) as typeof fetch
const client = createCpClient({ cpUrl: 'http://127.0.0.1:8080', fetchFn })
await client.deleteHost('host-42', 'TOK')
expect(captured.url).toBe('http://127.0.0.1:8080/hosts/host-42')
expect(captured.init?.method).toBe('DELETE')
})
it('throws CpClientError on a non-2xx delete', async () => {
const fetchFn = (async () => new Response('no', { status: 404 })) as typeof fetch
const client = createCpClient({ cpUrl: 'http://127.0.0.1:8080', fetchFn })
await expect(client.deleteHost('h', 't')).rejects.toMatchObject({ status: 404 })
})
})

View File

@@ -0,0 +1,91 @@
/**
* Shared test helpers: a valid PanelConfig factory, an authed session cookie, an Ed25519 PKCS#8 PEM
* generator (for the manage-token test), and simple fakes for the CP client + token minter.
*/
import { SESSION_COOKIE_NAME } from '../src/security/cookies.js'
import { createSessionToken } from '../src/security/session.js'
import type { PanelConfig } from '../src/config.js'
import type { CpClient, HostView, IssuedPairing } from '../src/cp-client.js'
import type { ManageTokenMinter } from '../src/manage-token.js'
export const TEST_SESSION_SECRET = 'test-session-secret-0123456789'
export function makeConfig(overrides: Partial<PanelConfig> = {}): PanelConfig {
return {
panelPassword: 'hunter2-correct-horse',
sessionSecret: TEST_SESSION_SECRET,
cpUrl: 'http://127.0.0.1:8080',
baseDomain: 'terminal.yaojia.wang',
operatorAccountId: 'acct-operator-001',
capabilitySignKeyPath: '/nonexistent/key.pem',
tunnelZone: 'terminal.yaojia.wang',
panelBindPort: 8090,
...overrides,
}
}
/** A valid session cookie header value for injection (`cookie` header). */
export function authCookieHeader(secret: string = TEST_SESSION_SECRET, nowMs: number = Date.now()): string {
return `${SESSION_COOKIE_NAME}=${createSessionToken(secret, nowMs)}`
}
/** A minter that returns a fixed opaque token and records how many times it was called. */
export function fakeMinter(token = 'fake.manage.token'): ManageTokenMinter & { readonly calls: () => number } {
let n = 0
return {
async mint() {
n += 1
return token
},
calls: () => n,
}
}
export interface FakeCpCall {
readonly method: string
readonly accountIdOrHostId: string
readonly token: string
}
/** A CP client fake that records calls and returns canned data (or throws an injected error). */
export function fakeCpClient(opts: {
hosts?: readonly HostView[]
pairing?: IssuedPairing
throwErr?: Error
} = {}): CpClient & { readonly calls: readonly FakeCpCall[] } {
const calls: FakeCpCall[] = []
const guard = (): void => {
if (opts.throwErr) throw opts.throwErr
}
return {
calls,
async listHosts(accountId, token) {
calls.push({ method: 'listHosts', accountIdOrHostId: accountId, token })
guard()
return opts.hosts ?? []
},
async createPairingCode(accountId, token) {
calls.push({ method: 'createPairingCode', accountIdOrHostId: accountId, token })
guard()
return opts.pairing ?? { code: 'ABCD-EFGH', expiresAt: '2026-01-01T00:00:00.000Z' }
},
async deleteHost(hostId, token) {
calls.push({ method: 'deleteHost', accountIdOrHostId: hostId, token })
guard()
},
}
}
/** Generate an Ed25519 PKCS#8 PEM (private key) for signing-key tests. */
export async function generatePkcs8Pem(): Promise<{ pem: string; publicRaw: Uint8Array }> {
const pair = (await globalThis.crypto.subtle.generateKey({ name: 'Ed25519' }, true, ['sign', 'verify'])) as unknown as {
publicKey: CryptoKey
privateKey: CryptoKey
}
const pkcs8 = new Uint8Array(await globalThis.crypto.subtle.exportKey('pkcs8', pair.privateKey))
const raw = new Uint8Array(await globalThis.crypto.subtle.exportKey('raw', pair.publicKey))
const b64 = Buffer.from(pkcs8).toString('base64')
const lines = b64.match(/.{1,64}/g) ?? [b64]
const pem = `-----BEGIN PRIVATE KEY-----\n${lines.join('\n')}\n-----END PRIVATE KEY-----\n`
return { pem, publicRaw: raw }
}

View File

@@ -0,0 +1,59 @@
import { describe, it, expect } from 'vitest'
import { writeFile, mkdtemp } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { peekPasetoClaims } from 'relay-auth/src/crypto/paseto.js'
import { createManageTokenMinter, ManageTokenError } from '../src/manage-token.js'
import { generatePkcs8Pem } from './helpers.js'
async function writeKey(): Promise<string> {
const { pem } = await generatePkcs8Pem()
const dir = await mkdtemp(join(tmpdir(), 'cp-key-'))
const path = join(dir, 'capability-sign.key.pem')
await writeFile(path, pem, 'utf8')
return path
}
describe('createManageTokenMinter', () => {
it('mints a manage token with the correct aud, sub, rights, and TTL', async () => {
const keyPath = await writeKey()
const minter = createManageTokenMinter({
capabilitySignKeyPath: keyPath,
baseDomain: 'terminal.yaojia.wang',
operatorAccountId: 'acct-op-1',
})
const token = await minter.mint()
expect(token.startsWith('v4.public.')).toBe(true)
const claims = peekPasetoClaims(token) as Record<string, unknown>
expect(claims.aud).toBe('terminal.yaojia.wang')
expect(claims.sub).toBe('acct-op-1')
expect(claims.rights).toEqual(['manage'])
expect(typeof claims.host).toBe('string')
expect((claims.exp as number) - (claims.iat as number)).toBe(60)
// The additive DPoP proof-of-possession binding must be present.
expect((claims.cnf as { jkt?: string })?.jkt).toMatch(/^[A-Za-z0-9_-]{43}$/)
})
it('mints a fresh token each call (distinct jti)', async () => {
const keyPath = await writeKey()
const minter = createManageTokenMinter({
capabilitySignKeyPath: keyPath,
baseDomain: 'd',
operatorAccountId: 'a',
})
const a = peekPasetoClaims(await minter.mint()) as Record<string, unknown>
const b = peekPasetoClaims(await minter.mint()) as Record<string, unknown>
expect(a.jti).not.toBe(b.jti)
})
it('throws ManageTokenError when the key file is missing', async () => {
const minter = createManageTokenMinter({
capabilitySignKeyPath: '/nonexistent/does-not-exist.pem',
baseDomain: 'd',
operatorAccountId: 'a',
})
await expect(minter.mint()).rejects.toBeInstanceOf(ManageTokenError)
})
})

View File

@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest'
import { buildPairCommand, buildQrDataUrl, buildPairingArtifacts } from '../src/pairing.js'
describe('pairing artifacts', () => {
it('builds the ready-to-run pair command with the code and zone', () => {
const cmd = buildPairCommand('ABCD-EFGH', 'terminal.yaojia.wang')
expect(cmd).toBe('web-terminal-agent pair ABCD-EFGH --install --zone terminal.yaojia.wang')
})
it('renders a PNG data URL for the code', async () => {
const url = await buildQrDataUrl('ABCD-EFGH')
expect(url.startsWith('data:image/png;base64,')).toBe(true)
expect(url.length).toBeGreaterThan(100)
})
it('combines an issued code into the full artifacts payload', async () => {
const artifacts = await buildPairingArtifacts(
{ code: 'WXYZ-1234', expiresAt: '2026-05-01T00:00:00.000Z' },
'z.example',
)
expect(artifacts.code).toBe('WXYZ-1234')
expect(artifacts.expiresAt).toBe('2026-05-01T00:00:00.000Z')
expect(artifacts.pairCommand).toContain('WXYZ-1234')
expect(artifacts.pairCommand).toContain('z.example')
expect(artifacts.qrDataUrl.startsWith('data:image/png;base64,')).toBe(true)
})
})

View File

@@ -0,0 +1,32 @@
import { describe, it, expect } from 'vitest'
import { createSlidingWindowLimiter } from '../src/security/rate-limit.js'
describe('sliding-window rate limiter', () => {
it('allows up to max, then blocks within the window', () => {
let t = 0
const limiter = createSlidingWindowLimiter(3, 1000, () => t)
expect(limiter.allow('ip')).toBe(true)
expect(limiter.allow('ip')).toBe(true)
expect(limiter.allow('ip')).toBe(true)
expect(limiter.allow('ip')).toBe(false)
})
it('does not record a rejected attempt (window frees up after it elapses)', () => {
let t = 0
const limiter = createSlidingWindowLimiter(2, 1000, () => t)
expect(limiter.allow('ip')).toBe(true)
expect(limiter.allow('ip')).toBe(true)
expect(limiter.allow('ip')).toBe(false) // blocked, NOT recorded
t = 1001 // original two hits now outside the window
expect(limiter.allow('ip')).toBe(true)
expect(limiter.allow('ip')).toBe(true)
})
it('tracks buckets independently per key', () => {
let t = 0
const limiter = createSlidingWindowLimiter(1, 1000, () => t)
expect(limiter.allow('a')).toBe(true)
expect(limiter.allow('a')).toBe(false)
expect(limiter.allow('b')).toBe(true)
})
})

View File

@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest'
import { buildApp } from '../src/app.js'
import { makeConfig, fakeCpClient, fakeMinter } from './helpers.js'
async function makeApp() {
return buildApp({ config: makeConfig(), cpClient: fakeCpClient(), minter: fakeMinter(), staticRoot: null })
}
const HARDENING: Readonly<Record<string, string>> = {
'x-content-type-options': 'nosniff',
'x-frame-options': 'DENY',
'referrer-policy': 'no-referrer',
}
describe('security response headers', () => {
it('stamps CSP + hardening headers on a matched route', async () => {
const app = await makeApp()
const res = await app.inject({ method: 'GET', url: '/api/session' })
expect(res.statusCode).toBe(200)
for (const [name, value] of Object.entries(HARDENING)) expect(res.headers[name]).toBe(value)
const csp = String(res.headers['content-security-policy'])
expect(csp).toContain("default-src 'self'")
expect(csp).toContain("img-src 'self' data:") // pairing QR is a data: image
expect(csp).toContain("style-src 'self' 'unsafe-inline'")
expect(csp).toContain("object-src 'none'")
expect(csp).toContain("base-uri 'none'")
expect(csp).toContain("frame-ancestors 'none'")
await app.close()
})
it('stamps the headers even on an unmatched (404) response', async () => {
const app = await makeApp()
const res = await app.inject({ method: 'GET', url: '/definitely-not-a-route' })
expect(res.statusCode).toBe(404)
expect(res.headers['x-frame-options']).toBe('DENY')
expect(String(res.headers['content-security-policy'])).toContain("default-src 'self'")
await app.close()
})
})

View File

@@ -0,0 +1,48 @@
import { describe, it, expect } from 'vitest'
import { createSessionToken, verifySessionToken, SESSION_TTL_SEC } from '../src/security/session.js'
const SECRET = 'session-secret-value-1234567890'
describe('session token', () => {
it('round-trips: a freshly minted token verifies', () => {
const now = 1_000_000_000_000
const token = createSessionToken(SECRET, now)
expect(verifySessionToken(SECRET, token, now)).toBe(true)
})
it('rejects a token signed with a different secret', () => {
const now = Date.now()
const token = createSessionToken(SECRET, now)
expect(verifySessionToken('another-secret-value-000000000', token, now)).toBe(false)
})
it('rejects a tampered MAC', () => {
const now = Date.now()
const token = createSessionToken(SECRET, now)
const [payload] = token.split('.')
expect(verifySessionToken(SECRET, `${payload}.deadbeef`, now)).toBe(false)
})
it('rejects a tampered (extended) expiry', () => {
const now = Date.now()
const token = createSessionToken(SECRET, now)
const mac = token.split('.')[1]
const farFuture = Math.floor(now / 1000) + 999999
expect(verifySessionToken(SECRET, `${farFuture}.${mac}`, now)).toBe(false)
})
it('rejects an expired token', () => {
const now = 1_000_000_000_000
const token = createSessionToken(SECRET, now)
const afterExpiry = now + (SESSION_TTL_SEC + 1) * 1000
expect(verifySessionToken(SECRET, token, afterExpiry)).toBe(false)
})
it('rejects malformed tokens', () => {
const now = Date.now()
expect(verifySessionToken(SECRET, undefined, now)).toBe(false)
expect(verifySessionToken(SECRET, '', now)).toBe(false)
expect(verifySessionToken(SECRET, 'no-dot', now)).toBe(false)
expect(verifySessionToken(SECRET, 'notanumber.abcd', now)).toBe(false)
})
})