mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-03 20:13:02 +00:00
fix(ui): preserve untrusted origins in dev proxy
This commit is contained in:
1 parent
30b54a0966
commit
c23b6bee19
2 files changed
+119
-5
No files matched your search
@@ -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
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
Reference in new issue
Block a user