mirror of
https://github.com/tiennm99/ccs.git
synced 2026-10-12 22:14:22 +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 { defineConfig } from 'vite';
|
||||||
|
import type { ProxyOptions } from 'vite';
|
||||||
import react from '@vitejs/plugin-react';
|
import react from '@vitejs/plugin-react';
|
||||||
import tailwindcss from '@tailwindcss/vite';
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
const UI_ROOT = __dirname;
|
const UI_ROOT = __dirname;
|
||||||
const REPO_ROOT = path.resolve(__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/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
@@ -58,22 +81,23 @@ export default defineConfig({
|
|||||||
},
|
},
|
||||||
server: {
|
server: {
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
strictPort: true,
|
||||||
fs: {
|
fs: {
|
||||||
allow: [UI_ROOT, REPO_ROOT],
|
allow: [UI_ROOT, REPO_ROOT],
|
||||||
},
|
},
|
||||||
proxy: {
|
proxy: {
|
||||||
// The dashboard's CSRF guard rejects loopback origins on a different
|
// Translate only trusted local Vite origins for the dashboard's CSRF guard.
|
||||||
// port, so the dev proxy must present the API server's own origin.
|
// Preserve every other Origin so the backend can reject untrusted callers.
|
||||||
'/api': {
|
'/api': {
|
||||||
target: 'http://localhost:3000',
|
target: BACKEND_ORIGIN,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
headers: { origin: 'http://localhost:3000' },
|
configure: configureHttpProxy,
|
||||||
},
|
},
|
||||||
'/ws': {
|
'/ws': {
|
||||||
target: 'ws://localhost:3000',
|
target: 'ws://localhost:3000',
|
||||||
ws: true,
|
ws: true,
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
headers: { origin: 'http://localhost:3000' },
|
configure: configureWebSocketProxy,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
Reference in new issue
Block a user