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:
99
control-panel/test/api-routes.test.ts
Normal file
99
control-panel/test/api-routes.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
102
control-panel/test/auth-routes.test.ts
Normal file
102
control-panel/test/auth-routes.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
31
control-panel/test/compare.test.ts
Normal file
31
control-panel/test/compare.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
74
control-panel/test/config.test.ts
Normal file
74
control-panel/test/config.test.ts
Normal 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()
|
||||
}
|
||||
})
|
||||
})
|
||||
94
control-panel/test/cp-client.test.ts
Normal file
94
control-panel/test/cp-client.test.ts
Normal 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 })
|
||||
})
|
||||
})
|
||||
91
control-panel/test/helpers.ts
Normal file
91
control-panel/test/helpers.ts
Normal 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 }
|
||||
}
|
||||
59
control-panel/test/manage-token.test.ts
Normal file
59
control-panel/test/manage-token.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
27
control-panel/test/pairing.test.ts
Normal file
27
control-panel/test/pairing.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
32
control-panel/test/rate-limit.test.ts
Normal file
32
control-panel/test/rate-limit.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
40
control-panel/test/security-headers.test.ts
Normal file
40
control-panel/test/security-headers.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
48
control-panel/test/session.test.ts
Normal file
48
control-panel/test/session.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user