diff --git a/ui/tests/unit/vite-proxy-origin.test.ts b/ui/tests/unit/vite-proxy-origin.test.ts new file mode 100644 index 00000000..2db01199 --- /dev/null +++ b/ui/tests/unit/vite-proxy-origin.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it, vi } from 'vitest'; +import type { UserConfig } from 'vite'; + +vi.mock('vite', () => ({ defineConfig: (config: UserConfig) => config })); +vi.mock('@vitejs/plugin-react', () => ({ default: () => ({ name: 'react' }) })); +vi.mock('@tailwindcss/vite', () => ({ default: () => ({ name: 'tailwindcss' }) })); + +const { default: viteConfig } = await import('../../vite.config'); + +type ProxyRequest = { + getHeader(name: string): string | undefined; + setHeader(name: string, value: string): void; +}; + +type IncomingRequest = { + headers: { origin?: string }; +}; + +type ProxyListener = (proxyRequest: ProxyRequest, request: IncomingRequest) => void; + +function getProxyListener(route: '/api' | '/ws', event: 'proxyReq' | 'proxyReqWs'): ProxyListener { + const proxyOptions = (viteConfig as UserConfig).server?.proxy?.[route]; + if (!proxyOptions || typeof proxyOptions === 'string' || !proxyOptions.configure) { + throw new Error(`Missing configure hook for ${route}`); + } + + const listeners = new Map(); + const proxy = { + on(registeredEvent: string, listener: ProxyListener) { + listeners.set(registeredEvent, listener); + }, + }; + + proxyOptions.configure(proxy as never, proxyOptions); + + const listener = listeners.get(event); + if (!listener) { + throw new Error(`Missing ${event} listener for ${route}`); + } + + return listener; +} + +function applyOriginRewrite(listener: ProxyListener, origin?: string): string | undefined { + const headers = new Map(); + if (origin !== undefined) { + headers.set('origin', origin); + } + + listener( + { + getHeader: (name) => headers.get(name), + setHeader: (name, value) => headers.set(name, value), + }, + { headers: { origin } } + ); + + return headers.get('origin'); +} + +it('requires the trusted Vite development port', () => { + expect((viteConfig as UserConfig).server?.strictPort).toBe(true); +}); + +describe.each([ + ['/api', 'proxyReq'], + ['/ws', 'proxyReqWs'], +] as const)('%s Vite dev proxy origin handling', (route, event) => { + const listener = getProxyListener(route, event); + + it.each(['http://localhost:5173', 'http://127.0.0.1:5173', 'http://[::1]:5173'])( + 'rewrites trusted Vite origin %s to the backend origin', + (origin) => { + expect(applyOriginRewrite(listener, origin)).toBe('http://localhost:3000'); + } + ); + + it.each([ + 'http://attacker.example.test', + 'http://localhost.evil:5173', + 'http://localhost:5174', + 'https://localhost:5173', + ])('preserves untrusted origin %s', (origin) => { + expect(applyOriginRewrite(listener, origin)).toBe(origin); + }); + + it('preserves a missing origin', () => { + expect(applyOriginRewrite(listener)).toBeUndefined(); + }); +}); diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 3ba84058..96e5459a 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -1,10 +1,33 @@ +import type { ClientRequest, IncomingMessage } from 'node:http'; import { defineConfig } from 'vite'; +import type { ProxyOptions } from 'vite'; import react from '@vitejs/plugin-react'; import tailwindcss from '@tailwindcss/vite'; import path from 'path'; const UI_ROOT = __dirname; const REPO_ROOT = path.resolve(__dirname, '..'); +const BACKEND_ORIGIN = 'http://localhost:3000'; +const TRUSTED_VITE_DEV_ORIGINS = new Set([ + 'http://localhost:5173', + 'http://127.0.0.1:5173', + 'http://[::1]:5173', +]); + +function rewriteTrustedViteDevOrigin(proxyRequest: ClientRequest, request: IncomingMessage): void { + const origin = request.headers.origin; + if (origin && TRUSTED_VITE_DEV_ORIGINS.has(origin)) { + proxyRequest.setHeader('origin', BACKEND_ORIGIN); + } +} + +const configureHttpProxy: NonNullable = (proxy) => { + proxy.on('proxyReq', rewriteTrustedViteDevOrigin); +}; + +const configureWebSocketProxy: NonNullable = (proxy) => { + proxy.on('proxyReqWs', rewriteTrustedViteDevOrigin); +}; // https://vite.dev/config/ export default defineConfig({ @@ -58,22 +81,23 @@ export default defineConfig({ }, server: { port: 5173, + strictPort: true, fs: { allow: [UI_ROOT, REPO_ROOT], }, proxy: { - // The dashboard's CSRF guard rejects loopback origins on a different - // port, so the dev proxy must present the API server's own origin. + // Translate only trusted local Vite origins for the dashboard's CSRF guard. + // Preserve every other Origin so the backend can reject untrusted callers. '/api': { - target: 'http://localhost:3000', + target: BACKEND_ORIGIN, changeOrigin: true, - headers: { origin: 'http://localhost:3000' }, + configure: configureHttpProxy, }, '/ws': { target: 'ws://localhost:3000', ws: true, changeOrigin: true, - headers: { origin: 'http://localhost:3000' }, + configure: configureWebSocketProxy, }, }, },