fix(ui): preserve untrusted origins in dev proxy

This commit is contained in:
Tam Nhu Tran committed 2026-07-22 10:36:38 -04:00
1 parent 30b54a0966
commit c23b6bee19
2 files changed
+119 -5

No files matched your search

+90
View File
@@ -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<string, ProxyListener>();
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<string, string>();
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();
});
});
+29 -5
View File
@@ -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<ProxyOptions['configure']> = (proxy) => {
proxy.on('proxyReq', rewriteTrustedViteDevOrigin);
};
const configureWebSocketProxy: NonNullable<ProxyOptions['configure']> = (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,
},
},
},