import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'; import { createServer, request as httpRequest, type Server } from 'node:http'; import { connect } from 'node:net'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, describe, expect, it } from 'vitest'; import { CANARY_DEFAULT_HOST, CANARY_DEFAULT_PORT, createCanaryGateway, type CanaryGateway, } from './canary-gateway.js'; const gateways: CanaryGateway[] = []; const servers: Server[] = []; const directories: string[] = []; async function listen(server: Server): Promise { await new Promise((resolve, reject) => { server.once('error', reject); server.listen(0, '127.0.0.1', resolve); }); const address = server.address(); if (!address || typeof address === 'string') throw new Error('Expected TCP server address'); return address.port; } async function fixtureDist(): Promise { const directory = await mkdtemp(join(tmpdir(), 'canary-gateway-')); directories.push(directory); await mkdir(join(directory, 'assets')); await writeFile(join(directory, 'index.html'), '
canary
'); await writeFile(join(directory, 'assets', 'app.a1b2c3d4.js'), 'export const canary = true;'); await writeFile(join(directory, 'plain.css'), 'body { color: green; }'); return directory; } async function rawRequest( origin: string, path: string, headers: Record = {}, ): Promise<{ status: number; headers: import('node:http').IncomingHttpHeaders; body: string }> { const target = new URL(origin); return await new Promise((resolve, reject) => { const request = httpRequest( { host: target.hostname, port: target.port, path, headers }, (response) => { const chunks: Buffer[] = []; response.on('data', (chunk: Buffer) => chunks.push(chunk)); response.on('end', () => resolve({ status: response.statusCode ?? 0, headers: response.headers, body: Buffer.concat(chunks).toString(), }), ); }, ); request.once('error', reject); request.end(); }); } async function rawSocketRequest(origin: string, request: string): Promise { const target = new URL(origin); return await new Promise((resolve, reject) => { const socket = connect(Number(target.port), target.hostname); const chunks: Buffer[] = []; socket.once('connect', () => socket.write(request)); socket.on('data', (chunk: Buffer) => chunks.push(chunk)); socket.once('end', () => resolve(Buffer.concat(chunks).toString())); socket.once('error', reject); }); } async function startGateway(distDir: string, upstreamPort: number): Promise { const gateway = createCanaryGateway({ distDir, port: 0, upstreamPort }); gateways.push(gateway); await gateway.start(); return gateway; } afterEach(async () => { await Promise.all(gateways.splice(0).map((gateway) => gateway.stop())); await Promise.all( servers .splice(0) .map((server) => new Promise((resolve) => server.close(() => resolve()))), ); await Promise.all(directories.splice(0).map((directory) => rm(directory, { recursive: true }))); }); describe('same-origin canary gateway', () => { it('uses the isolated loopback canary defaults and rejects reserved ports', () => { expect(CANARY_DEFAULT_HOST).toBe('127.0.0.1'); expect(CANARY_DEFAULT_PORT).toBe(8789); expect(() => createCanaryGateway({ distDir: '/tmp/dist', port: 8788 })).toThrow(/reserved/i); expect(() => createCanaryGateway({ distDir: '/tmp/dist', port: 8790 })).toThrow(/reserved/i); }); it('accepts only numeric loopback bind and upstream hosts', () => { for (const host of ['127.0.0.1', '127.1.2.3', '::1']) { expect(() => createCanaryGateway({ distDir: '/tmp/dist', host })).not.toThrow(); expect(() => createCanaryGateway({ distDir: '/tmp/dist', upstreamHost: host })).not.toThrow(); } for (const host of [ 'localhost', 'example.test', '0.0.0.0', '10.0.0.1', '::', '::ffff:127.0.0.1', ]) { expect(() => createCanaryGateway({ distDir: '/tmp/dist', host })).toThrow(/loopback/i); expect(() => createCanaryGateway({ distDir: '/tmp/dist', upstreamHost: host })).toThrow( /loopback/i, ); } expect(() => createCanaryGateway({ distDir: '/tmp/dist', host: '0.0.0.0', port: 8788, ownsProductionPort: true, allowedHosts: ['0.0.0.0'], }), ).toThrow(/invalid allowed gateway host/i); expect(() => createCanaryGateway({ distDir: '/tmp/dist', host: '0.0.0.0', port: 8788, ownsProductionPort: true, allowedHosts: ['00.00.00.00'], }), ).toThrow(/invalid allowed gateway host/i); }); it('allows an explicit production LAN bind with an exact Host allowlist', async () => { const upstream = createServer((_request, response) => response.end('upstream reached')); servers.push(upstream); const gateway = createCanaryGateway({ distDir: await fixtureDist(), host: '0.0.0.0', port: 0, upstreamPort: await listen(upstream), ownsProductionPort: true, allowedHosts: ['192.168.2.69'], }); gateways.push(gateway); await gateway.start(); const allowed = await rawRequest(gateway.origin, '/', { host: '192.168.2.69:0' }); expect(allowed.status).toBe(200); const denied = await rawRequest(gateway.origin, '/', { host: 'evil.test:0' }); expect(denied.status).toBe(400); const wildcardHost = await rawRequest(gateway.origin, '/', { host: '0.0.0.0:0' }); expect(wildcardHost.status).toBe(400); const wildcardOrigin = await rawRequest(gateway.origin, '/api/v1/instances', { host: '192.168.2.69:0', origin: 'http://0.0.0.0:0', }); expect(wildcardOrigin.status).toBe(403); const foreignOrigin = await rawRequest(gateway.origin, '/api/v1/instances', { host: '192.168.2.69:0', origin: 'https://evil.example', }); expect(foreignOrigin.status).toBe(403); const allowedOrigin = await rawRequest(gateway.origin, '/api/v1/instances', { host: '192.168.2.69:0', origin: 'http://192.168.2.69:0', }); expect(allowedOrigin.status).toBe(200); }); it('rejects missing, malformed, and foreign authority before serving or proxying', async () => { const upstream = createServer((_request, response) => response.end('upstream reached')); servers.push(upstream); const gateway = await startGateway(await fixtureDist(), await listen(upstream)); const authority = new URL(gateway.origin).host; const valid = await rawSocketRequest( gateway.origin, `GET / HTTP/1.1\r\nHost: ${authority}\r\nConnection: close\r\n\r\n`, ); expect(valid).toMatch(/^HTTP\/1\.1 200 /); for (const request of [ 'GET / HTTP/1.0\r\nConnection: close\r\n\r\n', 'GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n', 'GET /api/v1/instances HTTP/1.1\r\nHost: 127.0.0.1:1\r\nConnection: close\r\n\r\n', `GET / HTTP/1.1\r\nHost: ${authority}\r\nHost: foreign.test\r\nConnection: close\r\n\r\n`, ]) { const response = await rawSocketRequest(gateway.origin, request); expect(response).toMatch(/^HTTP\/1\.1 400 /); expect(response).not.toContain('canary'); expect(response).not.toContain('upstream reached'); } }); it('rejects absolute-form and network-path request targets', async () => { const gateway = await startGateway(await fixtureDist(), 1); const authority = new URL(gateway.origin).host; for (const target of [ `http://${authority}/`, '//foreign.test/', `http://${authority}/healthz`, ]) { const response = await rawSocketRequest( gateway.origin, `GET ${target} HTTP/1.1\r\nHost: ${authority}\r\nConnection: close\r\n\r\n`, ); expect(response).toMatch(/^HTTP\/1\.1 400 /); expect(response).not.toContain('canary'); } }); it('serves assets with MIME/cache policy and falls back only for browser routes', async () => { const gateway = await startGateway(await fixtureDist(), 1); const origin = gateway.origin; const asset = await fetch(`${origin}/assets/app.a1b2c3d4.js`); expect(asset.status).toBe(200); expect(asset.headers.get('content-type')).toMatch(/^text\/javascript/); expect(asset.headers.get('cache-control')).toBe('public, max-age=31536000, immutable'); const plain = await fetch(`${origin}/plain.css`); expect(plain.headers.get('content-type')).toMatch(/^text\/css/); expect(plain.headers.get('cache-control')).toBe('no-cache'); const route = await fetch(`${origin}/instances/device-1`); expect(route.status).toBe(200); expect(route.headers.get('content-type')).toMatch(/^text\/html/); expect(await route.text()).toContain('canary'); const favicon = await fetch(`${origin}/favicon.ico`); expect(favicon.status).toBe(204); expect(await favicon.text()).toBe(''); const unknownFile = await fetch(`${origin}/missing.ico`); expect(unknownFile.status).toBe(404); expect(await unknownFile.text()).not.toContain('canary'); const unknownApi = await fetch(`${origin}/api/not-real`); expect(unknownApi.status).toBe(404); expect(await unknownApi.text()).not.toContain('canary'); }); it('blocks encoded and plain path traversal rather than serving files outside dist', async () => { const dist = await fixtureDist(); await writeFile(join(dist, '..', 'secret.txt'), 'secret'); const gateway = await startGateway(dist, 1); for (const path of [ '/../secret.txt', '/%2e%2e/secret.txt', '/%2e%2e%2fsecret.txt', '/assets/%2e%2e%2findex.html', ]) { const response = await rawRequest(gateway.origin, path); expect([400, 404]).toContain(response.status); expect(response.body).not.toContain('secret'); } }); it('proxies only approved API and health paths while preserving browser origin semantics', async () => { const seen: Array<{ url?: string; host?: string; origin?: string; connection?: string; custom?: string; }> = []; const upstream = createServer((request, response) => { const observation: (typeof seen)[number] = {}; if (request.url !== undefined) observation.url = request.url; if (request.headers.host !== undefined) observation.host = request.headers.host; if (request.headers.origin !== undefined) observation.origin = request.headers.origin; if (request.headers.connection !== undefined) observation.connection = request.headers.connection; const custom = request.headers['x-remove-me']; if (typeof custom === 'string') observation.custom = custom; seen.push(observation); response.setHeader('connection', 'x-upstream-hop'); response.setHeader('x-upstream-hop', 'removed'); response.setHeader('x-upstream', 'yes'); response.end(JSON.stringify({ path: request.url })); }); servers.push(upstream); const upstreamPort = await listen(upstream); const gateway = await startGateway(await fixtureDist(), upstreamPort); const browserOrigin = gateway.origin; for (const path of ['/api/v1/instances?active=true', '/healthz', '/readyz']) { const response = await rawRequest(browserOrigin, path, { origin: browserOrigin, connection: 'x-remove-me', 'x-remove-me': 'private', }); expect(response.status).toBe(200); expect(response.headers['x-upstream']).toBe('yes'); expect(response.headers['x-upstream-hop']).toBeUndefined(); } expect(seen.map(({ url }) => url)).toEqual([ '/api/v1/instances?active=true', '/healthz', '/readyz', ]); const upstreamOrigin = `http://127.0.0.1:${upstreamPort}`; expect( seen.every( ({ host, origin }) => host === `127.0.0.1:${upstreamPort}` && origin === upstreamOrigin, ), ).toBe(true); expect(seen.every(({ custom }) => custom === undefined)).toBe(true); }); it('streams SSE chunks without waiting for the upstream response to finish', async () => { const upstream = createServer((_request, response) => { response.writeHead(200, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' }); response.write('data: first\n\n'); setTimeout(() => response.end('data: second\n\n'), 150); }); servers.push(upstream); const gateway = await startGateway(await fixtureDist(), await listen(upstream)); const response = await fetch(`${gateway.origin}/api/v1/events`); const reader = response.body?.getReader(); const first = await reader?.read(); expect(new TextDecoder().decode(first?.value)).toContain('data: first'); expect(first?.done).toBe(false); await reader?.cancel(); }); it('has reversible, idempotent start/stop lifecycle', async () => { const gateway = createCanaryGateway({ distDir: await fixtureDist(), port: 0, upstreamPort: 1 }); gateways.push(gateway); expect(gateway.running).toBe(false); await gateway.start(); expect(gateway.running).toBe(true); const origin = gateway.origin; await gateway.stop(); await gateway.stop(); expect(gateway.running).toBe(false); await gateway.start(); expect(gateway.origin).not.toBe(origin); }); });