feat(runtime): add isolated same-origin canary gateway
This commit is contained in:
@@ -0,0 +1,286 @@
|
||||
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<number> {
|
||||
await new Promise<void>((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<string> {
|
||||
const directory = await mkdtemp(join(tmpdir(), 'canary-gateway-'));
|
||||
directories.push(directory);
|
||||
await mkdir(join(directory, 'assets'));
|
||||
await writeFile(join(directory, 'index.html'), '<!doctype html><main>canary</main>');
|
||||
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<string, string> = {},
|
||||
): 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<string> {
|
||||
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<CanaryGateway> {
|
||||
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<void>((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,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
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 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',
|
||||
]);
|
||||
expect(
|
||||
seen.every(
|
||||
({ host, origin }) => host === new URL(browserOrigin).host && origin === browserOrigin,
|
||||
),
|
||||
).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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user