feat(relay): Phase1 waves A2-E — server entry, shared-store data plane, agent runtime, deploy artifacts
RELAY-PHASE1 Wave A2/B/C/D/E (12-agent workflow, all tsc-clean, 314/314 tests pass): - A2: control-plane server.ts entry + boot/redis.ts revocation-bus wiring + start script. - B1: relay-run shared-store EnforceDeps (relay-auth ports over the SAME Postgres+Redis as P3). - B2: registry-backed MtlsVerifier (verifyAgentCert, fail-closed, INV14). - B3: store-backed RouteResolver (subdomain->hostId). - B4: Redis relay:revocations subscriber -> tunnel teardown (INV12). - B5: main-phase1.ts production entry (public bind, real TLS, async-mTLS prefetch bridge) + staging /auth/mint. - B6 (PARTIAL): relay-web operator login + browser DPoP; proof offered via term.dpop.<b64u> subprotocol. - C: agent dist/cli.js build (esbuild) + runTunnel run-loop + CliDeps. - D1: same-origin static serve of relay-web/public from the browser WSS. - E: systemd units + gen-ca/gen-capability-key/issue-tls-cert scripts + deploy/RUNBOOK.md. Adversarial review: all hard invariants PASS. Follow-ups (B7): close DPoP-subprotocol read on browser-server (blocks browser connect); rate-limit /auth/mint (F1); wire activeSessionCount (F2); scrub error logs (F5). Excludes unrelated public/style.css (concurrent iOS job).
This commit is contained in:
109
relay-run/tests/static-web.test.ts
Normal file
109
relay-run/tests/static-web.test.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
/**
|
||||
* Unit tests for the pure static-file resolver (D1). Covers index resolution, MIME mapping, query
|
||||
* stripping, and the STRICT path-traversal guard (raw `..`, percent-encoded `%2e%2e`, and NUL byte
|
||||
* all reject — even when the escaped target file genuinely exists on disk).
|
||||
*/
|
||||
import { describe, it, expect, beforeAll, afterAll } from 'vitest'
|
||||
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { serveStatic } from '../src/servers/static-web.js'
|
||||
|
||||
const INDEX_HTML = '<!doctype html><title>idx</title>'
|
||||
const PAIR_HTML = '<!doctype html><title>pair</title>'
|
||||
const INDEX_JS = 'export const x = 1\n'
|
||||
const XTERM_CSS = 'body{margin:0}'
|
||||
const SECRET = 'TOP SECRET — outside root'
|
||||
|
||||
let base: string // temp parent dir (holds the secret sibling)
|
||||
let root: string // the static root passed to serveStatic
|
||||
|
||||
beforeAll(() => {
|
||||
base = mkdtempSync(join(tmpdir(), 'relay-static-'))
|
||||
root = join(base, 'public')
|
||||
mkdirSync(join(root, 'build'), { recursive: true })
|
||||
writeFileSync(join(root, 'index.html'), INDEX_HTML)
|
||||
writeFileSync(join(root, 'pair.html'), PAIR_HTML)
|
||||
writeFileSync(join(root, 'build', 'index.js'), INDEX_JS)
|
||||
writeFileSync(join(root, 'build', 'xterm.css'), XTERM_CSS)
|
||||
// A real file OUTSIDE root — the traversal target the guard must never reach.
|
||||
writeFileSync(join(base, 'secret.txt'), SECRET)
|
||||
})
|
||||
|
||||
afterAll(() => {
|
||||
rmSync(base, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
describe('serveStatic — index resolution', () => {
|
||||
it('serves index.html at "/"', () => {
|
||||
const res = serveStatic(root, '/')
|
||||
expect(res).not.toBeNull()
|
||||
expect(res?.status).toBe(200)
|
||||
expect(res?.headers['content-type']).toBe('text/html; charset=utf-8')
|
||||
expect(res?.body.toString('utf8')).toBe(INDEX_HTML)
|
||||
expect(res?.headers['content-length']).toBe(String(Buffer.byteLength(INDEX_HTML)))
|
||||
})
|
||||
|
||||
it('serves an explicit .html page', () => {
|
||||
const res = serveStatic(root, '/pair.html')
|
||||
expect(res?.status).toBe(200)
|
||||
expect(res?.headers['content-type']).toBe('text/html; charset=utf-8')
|
||||
expect(res?.body.toString('utf8')).toBe(PAIR_HTML)
|
||||
})
|
||||
|
||||
it('strips the query string before resolving', () => {
|
||||
const res = serveStatic(root, '/index.html?join=abc123')
|
||||
expect(res?.status).toBe(200)
|
||||
expect(res?.body.toString('utf8')).toBe(INDEX_HTML)
|
||||
})
|
||||
|
||||
it('strips the hash fragment before resolving', () => {
|
||||
const res = serveStatic(root, '/#section')
|
||||
expect(res?.status).toBe(200)
|
||||
expect(res?.body.toString('utf8')).toBe(INDEX_HTML)
|
||||
})
|
||||
})
|
||||
|
||||
describe('serveStatic — MIME types', () => {
|
||||
it('maps /build/*.js to text/javascript', () => {
|
||||
const res = serveStatic(root, '/build/index.js')
|
||||
expect(res?.status).toBe(200)
|
||||
expect(res?.headers['content-type']).toBe('text/javascript; charset=utf-8')
|
||||
expect(res?.body.toString('utf8')).toBe(INDEX_JS)
|
||||
})
|
||||
|
||||
it('maps .css to text/css', () => {
|
||||
const res = serveStatic(root, '/build/xterm.css')
|
||||
expect(res?.status).toBe(200)
|
||||
expect(res?.headers['content-type']).toBe('text/css; charset=utf-8')
|
||||
})
|
||||
})
|
||||
|
||||
describe('serveStatic — traversal guard (returns null)', () => {
|
||||
it('blocks a raw ".." escape even though the target exists', () => {
|
||||
// Sanity: the target file really is readable outside root, so a null result proves the guard.
|
||||
expect(serveStatic(root, '/../secret.txt')).toBeNull()
|
||||
})
|
||||
|
||||
it('blocks a deep ".." escape', () => {
|
||||
expect(serveStatic(root, '/build/../../secret.txt')).toBeNull()
|
||||
})
|
||||
|
||||
it('blocks percent-encoded ".." (%2e%2e)', () => {
|
||||
expect(serveStatic(root, '/%2e%2e/secret.txt')).toBeNull()
|
||||
})
|
||||
|
||||
it('rejects a NUL byte in the path', () => {
|
||||
expect(serveStatic(root, '/index.html%00.js')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('serveStatic — misses (returns null)', () => {
|
||||
it('returns null for a nonexistent file', () => {
|
||||
expect(serveStatic(root, '/nope.html')).toBeNull()
|
||||
})
|
||||
|
||||
it('returns null for a directory (no listing)', () => {
|
||||
expect(serveStatic(root, '/build')).toBeNull()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user