Files
freeway/packages/gateway/test/security.test.ts
T
Aryan SinghandClaude Opus 5 cb985dcf9c Freeway: self-hosted OpenAI-compatible gateway for free-tier providers
One endpoint in front of every free LLM tier, with automatic failover and no
provider key ever leaving the box.

Providers are data, not code: adding one is a JSON file plus an env var, never
a TypeScript change. Ships 12 providers pre-written; `verify-provider --apply`
derives the live model list from your own key.

- core: registry, bucketed quota meter (rolling + UTC-calendar windows),
  key-pool store, 6-step model router with diagnostic rejection reasons
- gateway: node:http, failover that commits to a stream only after 200 headers,
  SSE passthrough with usage sniffing, virtual keys with admin/inference roles
- context engine: sqlite sessions and a 5-rung refit ladder, so a 128k thread
  keeps working when it fails over to an 8k model
- cache: exact (sha256, safe-by-default) + opt-in semantic (flat Float32Array)
- guard: inbound limits, quota forecasting, limit calibration from upstream
  headers, key health, secret/PII/injection scanning
- dashboard: one self-contained HTML file, live SSE request log, token usage
- sdk: zero-dep typed client, drop-in for the OpenAI SDK

Zero runtime dependencies. 326 tests, 27-check smoke suite against a scripted
mock upstream so routing is testable without spending quota.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-16 22:41:49 -05:00

264 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Regression tests for the security sweep.
*
* Each of these encodes a vulnerability that was real, so a future refactor
* cannot quietly reintroduce it.
*/
import { strict as assert } from 'node:assert';
import { once } from 'node:events';
import { mkdtempSync, rmSync, statSync, writeFileSync } from 'node:fs';
import type { Server } from 'node:http';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { after, describe, it } from 'node:test';
import { ContextStore, ExactCache, Registry, Router, Store, Tokenizer, parseConfig, silentLogger, type FreewayConfig } from '@freeway/core';
import { createGateway } from '../src/server.ts';
import { createMockUpstream } from '../../../scripts/mock-upstream.js';
const cleanups: (() => unknown)[] = [];
after(async () => {
for (const c of cleanups.reverse()) await c();
});
interface Mock { url: string; close(): Promise<unknown> }
interface Harness {
base: string;
context: ContextStore;
dataDir: string;
}
async function harness(over: Partial<FreewayConfig> = {}): Promise<Harness> {
const upstream = (await createMockUpstream({ name: 'up' })) as Mock;
cleanups.push(() => upstream.close());
const dir = mkdtempSync(join(tmpdir(), 'freeway-sec-'));
const dataDir = mkdtempSync(join(tmpdir(), 'freeway-sec-data-'));
cleanups.push(() => rmSync(dir, { recursive: true, force: true }), () => rmSync(dataDir, { recursive: true, force: true }));
writeFileSync(join(dir, 'up.json'), JSON.stringify({
id: 'up', priority: 10, baseUrl: upstream.url,
auth: { type: 'bearer', envKeys: ['UP_KEY'] }, limits: { rpm: 1000 },
models: [{ id: 'up-chat', alias: ['fast'], context: 128000, caps: ['chat'] }],
}));
const registry = new Registry({ dir, env: { UP_KEY: 'uk-000000000001' }, logger: silentLogger });
const store = new Store({ file: null, logger: silentLogger });
const { config } = parseConfig({}, {});
Object.assign(config, over);
const router = new Router(registry, store, config);
const context = new ContextStore(join(dataDir, 'freeway.db'));
const cache = new ExactCache({ file: join(dataDir, 'cache.db'), mode: 'safe', ttlMs: 3_600_000, maxEntries: 50 });
cleanups.push(() => context.close(), () => cache.close());
const server: Server = createGateway({
registry, store, router, config, logger: silentLogger,
contextStore: context, tokenizer: new Tokenizer(), cache,
});
server.listen(0, '127.0.0.1');
await once(server, 'listening');
const addr = server.address();
const port = typeof addr === 'object' && addr !== null ? addr.port : 0;
cleanups.push(() => new Promise((r) => server.close(() => r(undefined))));
return { base: `http://127.0.0.1:${port}`, context, dataDir };
}
const APP = 'fw-app-key';
const ADMIN = 'fw-admin-key';
const authed = (key: string) => ({ 'content-type': 'application/json', authorization: `Bearer ${key}` });
// ---------------------------------------------------------------------------
describe('C1 — an inference key is not an admin key', () => {
const cfg = { keys: [APP], adminKey: ADMIN, host: '0.0.0.0' };
it('refuses an app key on every /api route', async () => {
const h = await harness(cfg);
const routes: [string, string][] = [
['GET', '/api/providers'], ['GET', '/api/logs'], ['GET', '/api/sessions'],
['GET', '/api/alerts'], ['GET', '/api/cache'], ['GET', '/api/stats'], ['GET', '/api/events'],
['POST', '/api/reload'], ['POST', '/api/providers/up/enabled'],
['POST', '/api/providers/up/key'], ['POST', '/api/providers/up/probe'],
['DELETE', '/api/cache'],
];
for (const [method, path] of routes) {
const res = await fetch(`${h.base}${path}`, { method, headers: authed(APP), ...(method === 'GET' ? {} : { body: '{}' }) });
assert.equal(res.status, 403, `${method} ${path} should be forbidden to an app key`);
assert.equal(((await res.json()) as { error: { code: string } }).error.code, 'admin_required');
}
});
it('still lets the app key do inference', async () => {
const h = await harness(cfg);
const res = await fetch(`${h.base}/v1/chat/completions`, {
method: 'POST', headers: authed(APP),
body: JSON.stringify({ model: 'fast', messages: [{ role: 'user', content: 'hi' }] }),
});
assert.equal(res.status, 200);
});
it('lets the admin key through', async () => {
const h = await harness(cfg);
assert.equal((await fetch(`${h.base}/api/stats`, { headers: authed(ADMIN) })).status, 200);
});
it('refuses /api entirely on a public bind with no admin key', async () => {
// Otherwise an exposed gateway with only inference keys hands them the box.
const h = await harness({ keys: [APP], adminKey: null, host: '0.0.0.0' });
const res = await fetch(`${h.base}/api/logs`, { headers: authed(APP) });
assert.equal(res.status, 403);
assert.match(((await res.json()) as { error: { message: string } }).error.message, /FREEWAY_ADMIN_KEY/);
});
it('stays convenient on loopback with no admin key', async () => {
const h = await harness({ keys: [APP], adminKey: null, host: '127.0.0.1' });
assert.equal((await fetch(`${h.base}/api/stats`, { headers: authed(APP) })).status, 200);
});
});
describe('C2 — CORS is an allowlist, never a wildcard', () => {
it('sends no CORS headers by default', async () => {
const h = await harness({ keys: [APP] });
const res = await fetch(`${h.base}/v1/models`, { headers: { ...authed(APP), origin: 'https://evil.example' } });
assert.equal(res.headers.get('access-control-allow-origin'), null);
});
it('refuses an origin that is not on the list', async () => {
const h = await harness({ keys: [APP], corsOrigins: ['https://app.example'] });
const res = await fetch(`${h.base}/v1/models`, { headers: { ...authed(APP), origin: 'https://evil.example' } });
assert.equal(res.headers.get('access-control-allow-origin'), null);
});
it('echoes only a listed origin, and varies on it', async () => {
const h = await harness({ keys: [APP], corsOrigins: ['https://app.example'] });
const res = await fetch(`${h.base}/v1/models`, { headers: { ...authed(APP), origin: 'https://app.example' } });
assert.equal(res.headers.get('access-control-allow-origin'), 'https://app.example');
assert.match(res.headers.get('vary') ?? '', /Origin/);
});
it('rejects "*" in configuration rather than honouring it', () => {
const { config, issues } = parseConfig({ corsOrigins: ['*', 'https://ok.example'] }, {});
assert.deepEqual(config.corsOrigins, ['https://ok.example']);
assert.ok(issues.some((i) => i.path === 'corsOrigins'));
});
});
describe('C3 — sessions belong to the key that made them', () => {
it('refuses to read another key’s session', async () => {
const h = await harness({ keys: [APP, 'fw-other-key'], adminKey: ADMIN });
await fetch(`${h.base}/v1/chat/completions`, {
method: 'POST', headers: authed(APP),
body: JSON.stringify({ model: 'fast', session: 'private', messages: [{ role: 'user', content: 'my bank pin is 1234' }] }),
});
const res = await fetch(`${h.base}/v1/chat/completions`, {
method: 'POST', headers: authed('fw-other-key'),
body: JSON.stringify({ model: 'fast', session: 'private', messages: [{ role: 'user', content: 'what did I say?' }] }),
});
assert.equal(res.status, 403);
assert.equal(((await res.json()) as { error: { code: string } }).error.code, 'session_forbidden');
});
it('lets the owner continue its own session', async () => {
const h = await harness({ keys: [APP], adminKey: ADMIN });
const body = (content: string) => JSON.stringify({ model: 'fast', session: 'mine', messages: [{ role: 'user', content }] });
assert.equal((await fetch(`${h.base}/v1/chat/completions`, { method: 'POST', headers: authed(APP), body: body('one') })).status, 200);
assert.equal((await fetch(`${h.base}/v1/chat/completions`, { method: 'POST', headers: authed(APP), body: body('two') })).status, 200);
assert.equal(h.context.messages('mine').length, 4);
});
it('records the owner, masked', async () => {
const h = await harness({ keys: [APP], adminKey: ADMIN });
await fetch(`${h.base}/v1/chat/completions`, {
method: 'POST', headers: authed(APP),
body: JSON.stringify({ model: 'fast', session: 'owned', messages: [{ role: 'user', content: 'hi' }] }),
});
const owner = h.context.info('owned')?.owner;
assert.ok(owner, 'session should be claimed');
assert.ok(!owner.includes('app-key'), 'the owner must be a mask, not the key');
});
});
describe('H2 — live log subscriptions are capped', () => {
it('refuses beyond the cap instead of accumulating listeners', async () => {
const h = await harness();
const controllers: AbortController[] = [];
let refused = 0;
for (let i = 0; i < 20; i++) {
const controller = new AbortController();
controllers.push(controller);
const res = await fetch(`${h.base}/api/events`, { signal: controller.signal });
if (res.status === 503) {
refused += 1;
break;
}
// Start reading so the subscription actually registers server-side.
void res.body?.getReader().read();
}
for (const c of controllers) c.abort();
assert.ok(refused > 0, 'an unbounded number of subscribers was accepted');
});
});
describe('H3 — response headers cannot be poisoned by input', () => {
it('survives a session id full of illegal header bytes', async () => {
// These used to reach writeHead raw and throw, turning a good response
// into a remote 500.
const h = await harness();
for (const id of ['sess\r\nX-Injected: yes', 'sess→arrow', 'sessnull']) {
const res = await fetch(`${h.base}/v1/chat/completions`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ model: 'fast', session: id, messages: [{ role: 'user', content: 'hi' }] }),
});
assert.equal(res.status, 200, `session id ${JSON.stringify(id)} should not break the response`);
assert.equal(res.headers.get('x-injected'), null, 'no header was injected');
}
});
});
describe('M1 — data at rest is not world-readable', () => {
it('creates the sqlite files 0600', async () => {
const h = await harness();
await fetch(`${h.base}/v1/chat/completions`, {
method: 'POST', headers: { 'content-type': 'application/json' },
body: JSON.stringify({ model: 'fast', session: 's', temperature: 0, messages: [{ role: 'user', content: 'hi' }] }),
});
for (const file of ['freeway.db', 'cache.db']) {
const mode = statSync(join(h.dataDir, file)).mode & 0o777;
assert.equal(mode, 0o600, `${file} is 0${mode.toString(8)}, expected 0600`);
}
});
});
describe('M2 — key comparison leaks neither content nor length', () => {
it('rejects keys of every length identically', async () => {
const h = await harness({ keys: ['fw-the-real-key-abcdefghijk'] });
for (const wrong of ['x', 'fw-', 'fw-the-real-key-abcdefghij', 'fw-the-real-key-abcdefghijkX', 'x'.repeat(500)]) {
const res = await fetch(`${h.base}/v1/models`, { headers: { authorization: `Bearer ${wrong}` } });
assert.equal(res.status, 401, `"${wrong.slice(0, 12)}…" should be rejected`);
}
assert.equal((await fetch(`${h.base}/v1/models`, { headers: authed('fw-the-real-key-abcdefghijk') })).status, 200);
});
});
describe('still no way to reach a raw provider key', () => {
it('keeps provider keys out of every admin response', async () => {
const h = await harness({ keys: [APP], adminKey: ADMIN });
for (const path of ['/api/providers', '/api/stats', '/api/logs', '/api/alerts']) {
const text = await (await fetch(`${h.base}${path}`, { headers: authed(ADMIN) })).text();
assert.ok(!text.includes('uk-000000000001'), `${path} leaked a provider key`);
}
});
});