From 5424cd6ba91639c6f45cc86506a978d0731be5c7 Mon Sep 17 00:00:00 2001 From: syn Date: Wed, 25 Feb 2026 19:02:00 -0600 Subject: [PATCH 1/2] feat(kiloclaw): forward proxy auth token and enable enforcement default Derive and inject per-sandbox proxy tokens on all worker-to-Fly proxy requests so gateway auth can be safely enforced. Turn on REQUIRE_PROXY_TOKEN by default so new and restarted instances adopt enforcement immediately. --- kiloclaw/src/index.ts | 17 +++++++--- kiloclaw/src/utils/proxy-headers.test.ts | 42 ++++++++++++++++++++++++ kiloclaw/src/utils/proxy-headers.ts | 18 ++++++++++ kiloclaw/wrangler.jsonc | 1 + 4 files changed, 73 insertions(+), 5 deletions(-) create mode 100644 kiloclaw/src/utils/proxy-headers.test.ts create mode 100644 kiloclaw/src/utils/proxy-headers.ts diff --git a/kiloclaw/src/index.ts b/kiloclaw/src/index.ts index a68f9c0b5..914c92704 100644 --- a/kiloclaw/src/index.ts +++ b/kiloclaw/src/index.ts @@ -21,6 +21,7 @@ import { authMiddleware, internalApiMiddleware } from './auth'; import { sandboxIdFromUserId } from './auth/sandbox-id'; import { registerVersionIfNeeded } from './lib/image-version'; import { startingUpPage } from './pages/starting-up'; +import { buildForwardHeaders } from './utils/proxy-headers'; // Export DOs (match wrangler.jsonc class_name bindings) export { KiloClawInstance } from './durable-objects/kiloclaw-instance'; @@ -249,11 +250,17 @@ app.all('*', async c => { const isWebSocketRequest = request.headers.get('Upgrade')?.toLowerCase() === 'websocket'; - // Build headers to forward, adding the fly-force-instance-id header - const forwardHeaders = new Headers(request.headers); - forwardHeaders.set('fly-force-instance-id', machineId); - // Remove hop-by-hop headers that shouldn't be forwarded - forwardHeaders.delete('host'); + if (!c.env.GATEWAY_TOKEN_SECRET) { + console.error('[CONFIG] Missing required environment variables: GATEWAY_TOKEN_SECRET'); + return c.json({ error: 'Configuration error' }, 503); + } + + const forwardHeaders = await buildForwardHeaders({ + requestHeaders: request.headers, + machineId, + sandboxId, + gatewayTokenSecret: c.env.GATEWAY_TOKEN_SECRET, + }); // WebSocket proxy if (isWebSocketRequest) { diff --git a/kiloclaw/src/utils/proxy-headers.test.ts b/kiloclaw/src/utils/proxy-headers.test.ts new file mode 100644 index 000000000..f8fc02b49 --- /dev/null +++ b/kiloclaw/src/utils/proxy-headers.test.ts @@ -0,0 +1,42 @@ +import { describe, expect, it } from 'vitest'; +import { deriveGatewayToken } from '../auth/gateway-token'; +import { buildForwardHeaders } from './proxy-headers'; + +describe('buildForwardHeaders', () => { + it('adds routing and proxy token headers', async () => { + const headers = new Headers({ + host: 'example.com', + 'x-request-id': 'req-123', + }); + + const result = await buildForwardHeaders({ + requestHeaders: headers, + machineId: 'machine-123', + sandboxId: 'sandbox-abc', + gatewayTokenSecret: 'secret-123', + }); + + const expectedToken = await deriveGatewayToken('sandbox-abc', 'secret-123'); + + expect(result.get('fly-force-instance-id')).toBe('machine-123'); + expect(result.get('x-kiloclaw-proxy-token')).toBe(expectedToken); + expect(result.get('host')).toBeNull(); + expect(result.get('x-request-id')).toBe('req-123'); + }); + + it('overwrites inbound proxy token header', async () => { + const headers = new Headers({ + 'x-kiloclaw-proxy-token': 'old-token', + }); + + const result = await buildForwardHeaders({ + requestHeaders: headers, + machineId: 'machine-123', + sandboxId: 'sandbox-abc', + gatewayTokenSecret: 'secret-123', + }); + + const expectedToken = await deriveGatewayToken('sandbox-abc', 'secret-123'); + expect(result.get('x-kiloclaw-proxy-token')).toBe(expectedToken); + }); +}); diff --git a/kiloclaw/src/utils/proxy-headers.ts b/kiloclaw/src/utils/proxy-headers.ts new file mode 100644 index 000000000..c48a319a7 --- /dev/null +++ b/kiloclaw/src/utils/proxy-headers.ts @@ -0,0 +1,18 @@ +import { deriveGatewayToken } from '../auth/gateway-token'; + +export async function buildForwardHeaders(params: { + requestHeaders: Headers; + machineId: string; + sandboxId: string; + gatewayTokenSecret: string; +}): Promise { + const { requestHeaders, machineId, sandboxId, gatewayTokenSecret } = params; + const forwardHeaders = new Headers(requestHeaders); + + const gatewayToken = await deriveGatewayToken(sandboxId, gatewayTokenSecret); + forwardHeaders.set('x-kiloclaw-proxy-token', gatewayToken); + forwardHeaders.set('fly-force-instance-id', machineId); + forwardHeaders.delete('host'); + + return forwardHeaders; +} diff --git a/kiloclaw/wrangler.jsonc b/kiloclaw/wrangler.jsonc index a015e2f27..147de360c 100644 --- a/kiloclaw/wrangler.jsonc +++ b/kiloclaw/wrangler.jsonc @@ -72,6 +72,7 @@ "FLY_IMAGE_TAG": "latest", "FLY_REGION": "us,eu", "OPENCLAW_ALLOWED_ORIGINS": "https://claw.kilosessions.ai,https://kilo.ai,https://www.kilo.ai", + "REQUIRE_PROXY_TOKEN": "true", // Defaults to "production". For local dev, override to "development" in .dev.vars. "WORKER_ENV": "production", }, From 902b5493ec3fde714cda5726552559ae807ce16f Mon Sep 17 00:00:00 2001 From: syn Date: Wed, 25 Feb 2026 20:25:49 -0600 Subject: [PATCH 2/2] fix(kiloclaw): minimize public health endpoint payload Return a generic response for and to avoid exposing runtime metadata in unauthenticated probes while preserving Fly health-check compatibility. --- kiloclaw/controller/src/routes/health.test.ts | 50 ++++--------------- kiloclaw/controller/src/routes/health.ts | 19 ++----- 2 files changed, 13 insertions(+), 56 deletions(-) diff --git a/kiloclaw/controller/src/routes/health.test.ts b/kiloclaw/controller/src/routes/health.test.ts index 80380f9db..29f00714e 100644 --- a/kiloclaw/controller/src/routes/health.test.ts +++ b/kiloclaw/controller/src/routes/health.test.ts @@ -3,15 +3,15 @@ import { Hono } from 'hono'; import { registerHealthRoute } from './health'; import type { Supervisor } from '../supervisor'; -function createMockSupervisor(state: string): Supervisor { +function createMockSupervisor(): Supervisor { return { start: async () => true, stop: async () => true, restart: async () => true, shutdown: async () => undefined, - getState: () => state as ReturnType, + getState: () => 'running', getStats: () => ({ - state: state as ReturnType, + state: 'running', pid: 42, uptime: 123, restarts: 2, @@ -21,55 +21,23 @@ function createMockSupervisor(state: string): Supervisor { } describe('GET /_kilo/health', () => { - it('returns 200 with status fields when gateway is running', async () => { + it('returns 200 with minimal payload', async () => { const app = new Hono(); - registerHealthRoute(app, createMockSupervisor('running')); + registerHealthRoute(app, createMockSupervisor()); const resp = await app.request('/_kilo/health'); expect(resp.status).toBe(200); - expect(await resp.json()).toEqual({ - status: 'ok', - gateway: 'running', - uptime: 123, - restarts: 2, - }); - }); - - it('returns 503 when gateway is not running', async () => { - const app = new Hono(); - registerHealthRoute(app, createMockSupervisor('crashed')); - - const resp = await app.request('/_kilo/health'); - expect(resp.status).toBe(503); - const body = (await resp.json()) as { status: string; gateway: string }; - expect(body.status).toBe('starting'); - expect(body.gateway).toBe('crashed'); - }); - - it('returns 503 when gateway is starting', async () => { - const app = new Hono(); - registerHealthRoute(app, createMockSupervisor('starting')); - - const resp = await app.request('/_kilo/health'); - expect(resp.status).toBe(503); - const body = (await resp.json()) as { status: string; gateway: string }; - expect(body.status).toBe('starting'); - expect(body.gateway).toBe('starting'); + expect(await resp.json()).toEqual({ status: 'ok' }); }); }); describe('GET /health (compatibility alias)', () => { - it('returns 200 with status fields', async () => { + it('returns 200 with minimal payload', async () => { const app = new Hono(); - registerHealthRoute(app, createMockSupervisor('running')); + registerHealthRoute(app, createMockSupervisor()); const resp = await app.request('/health'); expect(resp.status).toBe(200); - expect(await resp.json()).toEqual({ - status: 'ok', - gateway: 'running', - uptime: 123, - restarts: 2, - }); + expect(await resp.json()).toEqual({ status: 'ok' }); }); }); diff --git a/kiloclaw/controller/src/routes/health.ts b/kiloclaw/controller/src/routes/health.ts index 9ef3e3f8b..f773fc594 100644 --- a/kiloclaw/controller/src/routes/health.ts +++ b/kiloclaw/controller/src/routes/health.ts @@ -1,22 +1,11 @@ import type { Context, Hono } from 'hono'; import type { Supervisor } from '../supervisor'; -export function registerHealthRoute(app: Hono, supervisor: Supervisor): void { - const handler = (c: Context) => { - const stats = supervisor.getStats(); - const ready = stats.state === 'running'; - return c.json( - { - status: ready ? 'ok' : 'starting', - gateway: stats.state, - uptime: stats.uptime, - restarts: stats.restarts, - }, - ready ? 200 : 503 - ); - }; +export function registerHealthRoute(app: Hono, _supervisor: Supervisor): void { + const handler = (c: Context) => c.json({ status: 'ok' }); + // Public Fly health probe endpoint. Keep response intentionally minimal. app.get('/_kilo/health', handler); - // Compatibility alias for machines still configured with legacy health path. + // Compatibility alias to match the same minimal, public health response. app.get('/health', handler); }