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:
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()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user