diff --git a/apps/daemon/src/integrations/vela-wallet.ts b/apps/daemon/src/integrations/vela-wallet.ts index 427830afd5..e1bec2e565 100644 --- a/apps/daemon/src/integrations/vela-wallet.ts +++ b/apps/daemon/src/integrations/vela-wallet.ts @@ -37,6 +37,10 @@ interface VelaWalletBalanceResponse { updatedAt?: unknown; } +function isValidUsdBalance(value: unknown): value is string { + return typeof value === 'string' && /^\d+(?:\.\d+)?$/.test(value); +} + function publicUser(user: VelaUser | null): AmrWalletSnapshot['user'] { if (!user) return null; return { @@ -164,16 +168,26 @@ export function createVelaWalletSnapshotReader(options: VelaWalletReaderOptions }); } const body = (await response.json()) as VelaWalletBalanceResponse; - const balanceUsd = typeof body.balanceUsd === 'string' ? body.balanceUsd : null; - if (balanceUsd === null) { + if (!isValidUsdBalance(body.balanceUsd)) { + const cached = cache.get(key); + if (cached) { + return { + ...withCacheSource(cached.snapshot, true), + error: { + code: 'upstream', + message: 'AMR wallet balance response contained an invalid balanceUsd.', + }, + }; + } return unavailableSnapshot({ code: 'upstream', fetchedAt, - message: 'AMR wallet balance response was missing balanceUsd.', + message: 'AMR wallet balance response contained an invalid balanceUsd.', profile: input.profile, user: input.user, }); } + const balanceUsd = body.balanceUsd; const snapshot: AmrWalletSnapshot = { status: 'available', profile: input.profile, diff --git a/apps/daemon/src/routes/vela.ts b/apps/daemon/src/routes/vela.ts index 37f147aa17..6734ea993a 100644 --- a/apps/daemon/src/routes/vela.ts +++ b/apps/daemon/src/routes/vela.ts @@ -58,6 +58,18 @@ const AMR_API_PROXY_PREFIX = '/api/integrations/vela/api-proxy'; const VELA_MESSAGE_CENTER_PREFIX = '/api/integrations/vela/message-center'; const VELA_PUBLIC_MESSAGE_CENTER_PREFIX = '/api/integrations/vela/message-center-public'; const AMR_API_UPSTREAM_ORIGIN = 'https://amr-api.open-design.ai'; +const PROXY_HOP_BY_HOP_HEADERS = new Set([ + 'connection', + 'keep-alive', + 'proxy-authenticate', + 'proxy-authorization', + 'proxy-connection', + 'te', + 'trailer', + 'transfer-encoding', + 'upgrade', +]); +const VELA_WORKSPACE_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9_-]{0,127}$/; /** * Upper bound, in ms, on how long a cold-cache `/status` read waits for the @@ -198,6 +210,21 @@ function shouldStreamVelaProxyRequest(req: Request, body: Buffer | null): boolea return req.method !== 'GET' && req.method !== 'HEAD' && body == null; } +function connectionHeaderTokens(value: string | string[] | undefined): Set { + const values = Array.isArray(value) ? value : value === undefined ? [] : [value]; + return new Set( + values + .flatMap((entry) => entry.split(',')) + .map((entry) => entry.trim().toLowerCase()) + .filter(Boolean), + ); +} + +function isProxyHopByHopHeader(name: string, connectionTokens: Set): boolean { + const lower = name.toLowerCase(); + return PROXY_HOP_BY_HOP_HEADERS.has(lower) || connectionTokens.has(lower); +} + /** * Pipe one leg of the AMR proxy with an explicit source-error guard. * @@ -226,16 +253,29 @@ function proxyAmrApiRequest(req: Request, res: Response): void { return; } const target = new URL(suffix, AMR_API_UPSTREAM_ORIGIN); + if (!target.pathname.startsWith('/api/v1/')) { + res.status(404).json({ error: 'unknown_amr_api_proxy_path' }); + return; + } + const workspaceId = req.headers['x-vela-workspace-id']; + if ( + workspaceId !== undefined + && (Array.isArray(workspaceId) || !VELA_WORKSPACE_ID_PATTERN.test(workspaceId)) + ) { + res.status(400).json({ error: 'invalid_workspace_id' }); + return; + } + const requestConnectionTokens = connectionHeaderTokens(req.headers.connection); + if (workspaceId !== undefined && requestConnectionTokens.has('x-vela-workspace-id')) { + res.status(400).json({ error: 'invalid_workspace_id' }); + return; + } const body = velaProxyRequestBody(req); const streamBody = shouldStreamVelaProxyRequest(req, body); const headers: Record = {}; for (const [key, value] of Object.entries(req.headers)) { const lower = key.toLowerCase(); - if ( - lower === 'host' || - lower === 'connection' || - lower === 'transfer-encoding' - ) { + if (lower === 'host' || isProxyHopByHopHeader(lower, requestConnectionTokens)) { continue; } if (lower === 'content-length' && body) continue; @@ -254,8 +294,14 @@ function proxyAmrApiRequest(req: Request, res: Response): void { }, (upstreamRes) => { res.status(upstreamRes.statusCode ?? 502); + const responseConnectionTokens = connectionHeaderTokens(upstreamRes.headers.connection); for (const [key, value] of Object.entries(upstreamRes.headers)) { - if (value !== undefined) res.setHeader(key, value); + if ( + value !== undefined + && !isProxyHopByHopHeader(key, responseConnectionTokens) + ) { + res.setHeader(key, value); + } } pipeProxyStreamWithGuard(upstreamRes, res, (err) => { if (!res.headersSent) { @@ -274,6 +320,11 @@ function proxyAmrApiRequest(req: Request, res: Response): void { res.end(); } }); + const abortUpstream = () => { + if (!res.writableEnded && !upstream.destroyed) upstream.destroy(); + }; + req.once('aborted', abortUpstream); + res.once('close', abortUpstream); if (body) upstream.write(body); if (streamBody) { pipeProxyStreamWithGuard(req, upstream, () => upstream.destroy()); diff --git a/apps/daemon/tests/integrations/vela-wallet.test.ts b/apps/daemon/tests/integrations/vela-wallet.test.ts new file mode 100644 index 0000000000..6400f1cc23 --- /dev/null +++ b/apps/daemon/tests/integrations/vela-wallet.test.ts @@ -0,0 +1,108 @@ +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { createVelaWalletSnapshotReader } from '../../src/integrations/vela-wallet.js'; + +let originalHome: string | undefined; +let originalProfile: string | undefined; +let testHome: string; + +function seedWalletLogin(): void { + const configFile = path.join(testHome, '.amr', 'config.json'); + mkdirSync(path.dirname(configFile), { recursive: true }); + writeFileSync( + configFile, + JSON.stringify({ + profiles: { + local: { + apiUrl: 'https://wallet.example.test', + controlKey: 'ck-wallet-unit', + runtimeKey: 'rt-wallet-unit', + user: { + id: 'wallet-unit-user', + email: 'wallet-unit@example.com', + plan: 'plus', + }, + }, + }, + }), + 'utf8', + ); +} + +beforeEach(() => { + originalHome = process.env.HOME; + originalProfile = process.env.OPEN_DESIGN_AMR_PROFILE; + testHome = mkdtempSync(path.join(tmpdir(), 'od-vela-wallet-')); + process.env.HOME = testHome; + process.env.OPEN_DESIGN_AMR_PROFILE = 'local'; + seedWalletLogin(); +}); + +afterEach(() => { + if (originalHome === undefined) delete process.env.HOME; + else process.env.HOME = originalHome; + if (originalProfile === undefined) delete process.env.OPEN_DESIGN_AMR_PROFILE; + else process.env.OPEN_DESIGN_AMR_PROFILE = originalProfile; + rmSync(testHome, { recursive: true, force: true }); +}); + +describe('createVelaWalletSnapshotReader balance validation', () => { + it.each([ + { label: 'missing', balanceUsd: undefined }, + { label: 'numeric', balanceUsd: 20 }, + { label: 'negative', balanceUsd: '-1.00' }, + { label: 'NaN', balanceUsd: 'NaN' }, + { label: 'infinite', balanceUsd: 'Infinity' }, + { label: 'exponent', balanceUsd: '1e2' }, + ])('rejects a $label balance when there is no valid cached snapshot', async ({ balanceUsd }) => { + const fetchMock = vi.fn(async () => + new Response(JSON.stringify(balanceUsd === undefined ? {} : { balanceUsd }), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + const reader = createVelaWalletSnapshotReader({ fetch: fetchMock as typeof fetch }); + + await expect(reader.read()).resolves.toMatchObject({ + status: 'unavailable', + balanceUsd: null, + source: 'unavailable', + stale: false, + error: { code: 'upstream' }, + }); + }); + + it('serves the last valid snapshot as stale when a refresh returns an invalid balance', async () => { + let responseBody: unknown = { balanceUsd: '20.00' }; + const fetchMock = vi.fn(async () => + new Response(JSON.stringify(responseBody), { + status: 200, + headers: { 'content-type': 'application/json' }, + }), + ); + const reader = createVelaWalletSnapshotReader({ + fetch: fetchMock as typeof fetch, + ttlMs: 60_000, + }); + + await expect(reader.read()).resolves.toMatchObject({ + status: 'available', + balanceUsd: '20.00', + source: 'vela_api', + stale: false, + }); + responseBody = { balanceUsd: '-1.00' }; + + await expect(reader.read({ refresh: true })).resolves.toMatchObject({ + status: 'available', + balanceUsd: '20.00', + source: 'daemon_cache', + stale: true, + error: { code: 'upstream' }, + }); + }); +}); diff --git a/apps/daemon/tests/integrations/vela.routes.test.ts b/apps/daemon/tests/integrations/vela.routes.test.ts index 332f300f79..0c115c2643 100644 --- a/apps/daemon/tests/integrations/vela.routes.test.ts +++ b/apps/daemon/tests/integrations/vela.routes.test.ts @@ -1987,6 +1987,298 @@ describe('ALL /api/integrations/vela/api-proxy/*', () => { requestSpy.mockRestore(); } }); + + it('preserves a valid Workspace scope while stripping request hop-by-hop headers', async () => { + let forwardedHeaders: Record | undefined; + const requestSpy = vi.spyOn(https, 'request').mockImplementation(((_target, options, callback) => { + const upstream = new PassThrough() as any; + upstream.on('finish', () => { + forwardedHeaders = options?.headers as Record; + const upstreamRes = new PassThrough() as any; + upstreamRes.statusCode = 200; + upstreamRes.headers = { 'content-type': 'application/json' }; + callback?.(upstreamRes); + upstreamRes.end(JSON.stringify({ ok: true })); + }); + upstream.setTimeout = () => upstream; + return upstream; + }) as typeof https.request); + const daemonUrl = new URL(baseUrl); + + try { + const status = await new Promise((resolve, reject) => { + const request = http.request( + { + hostname: daemonUrl.hostname, + port: daemonUrl.port, + method: 'GET', + path: '/api/integrations/vela/api-proxy/api/v1/workspaces/workspace_team-1/billing', + headers: { + 'x-vela-workspace-id': 'workspace_team-1', + connection: 'x-test-hop', + 'keep-alive': 'timeout=5', + 'proxy-authorization': 'Basic test-only', + te: 'trailers', + trailer: 'x-test-checksum', + 'transfer-encoding': 'chunked', + 'x-test-hop': 'drop-me', + }, + }, + (response) => { + response.resume(); + response.once('end', () => resolve(response.statusCode ?? 0)); + }, + ); + request.on('error', reject); + request.end(); + }); + + expect(status).toBe(200); + expect(forwardedHeaders?.['x-vela-workspace-id']).toBe('workspace_team-1'); + expect(forwardedHeaders).not.toHaveProperty('connection'); + expect(forwardedHeaders).not.toHaveProperty('keep-alive'); + expect(forwardedHeaders).not.toHaveProperty('proxy-authorization'); + expect(forwardedHeaders).not.toHaveProperty('te'); + expect(forwardedHeaders).not.toHaveProperty('trailer'); + expect(forwardedHeaders).not.toHaveProperty('transfer-encoding'); + expect(forwardedHeaders).not.toHaveProperty('upgrade'); + expect(forwardedHeaders).not.toHaveProperty('x-test-hop'); + } finally { + requestSpy.mockRestore(); + } + }); + + it('rejects normalized path escapes and malformed Workspace scope before proxying', async () => { + let upstreamRequestCount = 0; + const requestSpy = vi.spyOn(https, 'request').mockImplementation(((_target, _options, callback) => { + upstreamRequestCount += 1; + const upstream = new PassThrough() as any; + upstream.on('finish', () => { + const upstreamRes = new PassThrough() as any; + upstreamRes.statusCode = 200; + upstreamRes.headers = { 'content-type': 'application/json' }; + callback?.(upstreamRes); + upstreamRes.end(JSON.stringify({ unexpectedlyProxied: true })); + }); + upstream.setTimeout = () => upstream; + return upstream; + }) as typeof https.request); + const daemonUrl = new URL(baseUrl); + const rawGet = (pathName: string, headers?: http.OutgoingHttpHeaders) => + new Promise<{ status: number; body: unknown }>((resolve, reject) => { + const request = http.request( + { + hostname: daemonUrl.hostname, + port: daemonUrl.port, + method: 'GET', + path: pathName, + headers, + }, + (response) => { + const chunks: Buffer[] = []; + response.on('data', (chunk: Buffer | string) => { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + }); + response.on('end', () => { + resolve({ + status: response.statusCode ?? 0, + body: JSON.parse(Buffer.concat(chunks).toString('utf8')), + }); + }); + }, + ); + request.on('error', reject); + request.end(); + }); + + try { + const escaped = await rawGet( + '/api/integrations/vela/api-proxy/api/v1/%2e%2e/private', + ); + const invalid = await rawGet( + '/api/integrations/vela/api-proxy/api/v1/wallet/balance', + { 'x-vela-workspace-id': ['workspace/escape'] }, + ); + const duplicate = await rawGet( + '/api/integrations/vela/api-proxy/api/v1/wallet/balance', + { 'x-vela-workspace-id': ['workspace-a', 'workspace-b'] }, + ); + const connectionNominated = await rawGet( + '/api/integrations/vela/api-proxy/api/v1/wallet/balance', + { + connection: 'x-vela-workspace-id', + 'x-vela-workspace-id': 'workspace-team', + }, + ); + + expect(escaped).toEqual({ status: 404, body: { error: 'unknown_amr_api_proxy_path' } }); + expect(invalid).toEqual({ status: 400, body: { error: 'invalid_workspace_id' } }); + expect(duplicate).toEqual({ status: 400, body: { error: 'invalid_workspace_id' } }); + expect(connectionNominated).toEqual({ + status: 400, + body: { error: 'invalid_workspace_id' }, + }); + expect(upstreamRequestCount).toBe(0); + } finally { + requestSpy.mockRestore(); + } + }); + + it('strips upstream hop-by-hop response headers while preserving billing metadata', async () => { + const requestSpy = vi.spyOn(https, 'request').mockImplementation(((_target, _options, callback) => { + const upstream = new PassThrough() as any; + upstream.on('finish', () => { + const upstreamRes = new PassThrough() as any; + upstreamRes.statusCode = 200; + upstreamRes.headers = { + connection: 'x-upstream-hop', + 'x-upstream-hop': 'drop-me', + 'keep-alive': 'timeout=5', + 'proxy-authenticate': 'Basic', + 'proxy-authorization': 'Basic test-only', + te: 'trailers', + trailer: 'x-test-checksum', + 'transfer-encoding': 'chunked', + upgrade: 'websocket', + 'x-request-id': 'billing-request-1', + 'content-type': 'application/json', + }; + callback?.(upstreamRes); + upstreamRes.end(JSON.stringify({ balanceUsd: '120.00' })); + }); + upstream.setTimeout = () => upstream; + return upstream; + }) as typeof https.request); + + try { + const response = await fetch( + `${baseUrl}/api/integrations/vela/api-proxy/api/v1/wallet/balance`, + { headers: { 'x-vela-workspace-id': 'workspace-team' } }, + ); + + expect(response.status).toBe(200); + expect(response.headers.get('x-request-id')).toBe('billing-request-1'); + expect(response.headers.get('connection')).not.toBe('x-upstream-hop'); + expect(response.headers.get('x-upstream-hop')).toBeNull(); + expect(response.headers.get('keep-alive')).not.toBe('timeout=5'); + for (const name of [ + 'proxy-authenticate', + 'proxy-authorization', + 'te', + 'trailer', + 'upgrade', + ]) { + expect(response.headers.get(name), name).toBeNull(); + } + } finally { + requestSpy.mockRestore(); + } + }); + + it('destroys the upstream request when a streaming Workspace upload is aborted', async () => { + let upstreamRequest: PassThrough | undefined; + let markUpstreamCreated: (() => void) | undefined; + let markUpstreamDestroyed: (() => void) | undefined; + const upstreamCreated = new Promise((resolve) => { + markUpstreamCreated = resolve; + }); + const upstreamDestroyed = new Promise((resolve) => { + markUpstreamDestroyed = resolve; + }); + const requestSpy = vi.spyOn(https, 'request').mockImplementation((() => { + upstreamRequest = new PassThrough(); + upstreamRequest.once('close', () => markUpstreamDestroyed?.()); + (upstreamRequest as any).setTimeout = () => upstreamRequest; + markUpstreamCreated?.(); + return upstreamRequest as any; + }) as typeof https.request); + const daemonUrl = new URL(baseUrl); + + try { + const upload = http.request({ + hostname: daemonUrl.hostname, + port: daemonUrl.port, + method: 'POST', + path: '/api/integrations/vela/api-proxy/api/v1/workspaces/import', + headers: { + 'content-type': 'application/octet-stream', + 'content-length': '1024', + 'x-vela-workspace-id': 'workspace-upload', + }, + }); + upload.on('error', () => {}); + const uploadClosed = new Promise((resolve) => upload.once('close', resolve)); + upload.write(Buffer.alloc(64, 1)); + await upstreamCreated; + upload.destroy(); + await uploadClosed; + await upstreamDestroyed; + + expect(upstreamRequest?.destroyed).toBe(true); + } finally { + upstreamRequest?.destroy(); + requestSpy.mockRestore(); + } + }); + + it('destroys the upstream request when the downstream response closes', async () => { + let upstreamRequest: PassThrough | undefined; + let upstreamResponse: PassThrough | undefined; + let downstreamRequest: http.ClientRequest | undefined; + let markUpstreamDestroyed: (() => void) | undefined; + const upstreamDestroyed = new Promise((resolve) => { + markUpstreamDestroyed = resolve; + }); + let markResponseStarted: (() => void) | undefined; + const responseStarted = new Promise((resolve) => { + markResponseStarted = resolve; + }); + const requestSpy = vi.spyOn(https, 'request').mockImplementation(((_target, _options, callback) => { + upstreamRequest = new PassThrough(); + upstreamRequest.once('close', () => markUpstreamDestroyed?.()); + upstreamRequest.on('finish', () => { + upstreamResponse = new PassThrough(); + (upstreamResponse as any).statusCode = 200; + (upstreamResponse as any).headers = { 'content-type': 'application/json' }; + callback?.(upstreamResponse as any); + upstreamResponse.write('{"balanceUsd":'); + markResponseStarted?.(); + }); + (upstreamRequest as any).setTimeout = () => upstreamRequest; + return upstreamRequest as any; + }) as typeof https.request); + const daemonUrl = new URL(baseUrl); + + try { + const downstreamClosed = new Promise((resolve, reject) => { + downstreamRequest = http.request( + { + hostname: daemonUrl.hostname, + port: daemonUrl.port, + method: 'GET', + path: '/api/integrations/vela/api-proxy/api/v1/wallet/balance', + headers: { 'x-vela-workspace-id': 'workspace-close' }, + }, + (response) => { + response.once('data', () => response.destroy()); + response.once('close', resolve); + }, + ); + downstreamRequest.on('error', reject); + downstreamRequest.end(); + }); + await responseStarted; + await downstreamClosed; + await upstreamDestroyed; + + expect(upstreamRequest?.destroyed).toBe(true); + } finally { + downstreamRequest?.destroy(); + upstreamRequest?.destroy(); + upstreamResponse?.destroy(); + requestSpy.mockRestore(); + } + }); }); describe('ALL /api/integrations/vela/message-center/*', () => {