fix: adapt trusted development requests in the Vite proxy

This commit is contained in:
洋子
2026-10-06 12:05:06 +08:00
parent 29583a6128
commit 703822f46c
8 changed files with 186 additions and 25 deletions
+5 -2
View File
@@ -59,8 +59,11 @@ exact origin to `server.trusted-origins` in the config file, for example:
`"server.trusted-origins": ["https://notes.example"]`. Preserve the browser-facing
Host in the proxy; forwarding headers are not used as a trust source. Do not add
origins that host untrusted content. Authentication and role checks still apply.
Unpackaged development also allows the Vite frontend at `http://localhost:8066`
and `http://127.0.0.1:8066`; packaged builds do not implicitly trust those ports.
The Vite development proxy validates the original browser source against its
actual listening address/port before adapting Host, Origin and Referer to the
backend. This covers HTTP, Socket.IO polling and WebSocket upgrades; it never
rewrites untrusted origins into trusted ones. The backend does not special-case
Vite ports or Electron packaging mode.
Settings responses never include `server.jwt-secret`, including the JavaScript
bootstrap response. Other admin settings and existing guest filtering are
+24
View File
@@ -0,0 +1,24 @@
import type { ProxyOptions } from 'vite'
import { createRequestOriginGuard } from '../src/main/server/request-origin'
export function createDevProxy (target: string): ProxyOptions {
const backend = new URL(target)
return {
target,
changeOrigin: true,
// Vite runs bypass before both HTTP proxying and WebSocket upgrades. Validate
// first: blindly rewriting Origin would turn Vite into a cross-site gateway.
bypass (req) {
const { localAddress, localPort } = req.socket
if (!localAddress || !localPort) return false
const checkOrigin = createRequestOriginGuard(localPort, localAddress)
if (!checkOrigin(req.headers, false, req.headers.upgrade?.toLowerCase() === 'websocket')) return false
if (req.headers.origin?.startsWith('http')) req.headers.origin = backend.origin
if (req.headers.referer?.startsWith('http')) {
const referer = new URL(req.headers.referer)
req.headers.referer = backend.origin + referer.pathname + referer.search
}
}
}
}
+1 -14
View File
@@ -418,26 +418,13 @@ describe('main app entry', () => {
expect(electron.app.on).not.toHaveBeenCalledWith('ready', expect.any(Function))
})
test('does not trust development origins in a packaged app', async () => {
const electron = await import('electron')
const old = electron.app.isPackaged
Object.defineProperty(electron.app, 'isPackaged', { value: true, configurable: true })
try {
await loadApp()
mocks.appEvents.ready()
expect(mocks.httpServer).toHaveBeenCalledWith(4555, [])
} finally {
Object.defineProperty(electron.app, 'isPackaged', { value: old, configurable: true })
}
})
test('ready event starts services, creates a window, tray, menus, and shortcuts', async () => {
await loadApp()
mocks.appEvents.ready()
const win = mocks.browserWindowInstances[0]
expect(mocks.startup).toHaveBeenCalled()
expect(mocks.httpServer).toHaveBeenCalledWith(4555, ['http://localhost:8066', 'http://127.0.0.1:8066'])
expect(mocks.httpServer).toHaveBeenCalledWith(4555)
expect(mocks.protocolHandlers['yank-note']).toEqual(expect.any(Function))
expect(win.options).toMatchObject({
show: false,
+1 -1
View File
@@ -421,7 +421,7 @@ const toggleFullscreen = () => {
const serve = () => {
try {
const { callback: handler, server } = httpServer(backendPort, app.isPackaged ? [] : [`http://localhost:${devFrontendPort}`, `http://127.0.0.1:${devFrontendPort}`])
const { callback: handler, server } = httpServer(backendPort)
if (server) {
server.on('error', (e: Error) => {
@@ -0,0 +1,82 @@
// @vitest-environment node
import { createServer, request } from 'http'
import type { IncomingHttpHeaders } from 'http'
import type { AddressInfo } from 'net'
import { createServer as createViteServer } from 'vite'
import { createDevProxy } from '../../../../scripts/dev-proxy'
import { createRequestOriginGuard } from '../request-origin'
test('the real Vite proxy validates HTTP, polling and WebSocket requests before forwarding', async () => {
let forwarded = 0
let checkBackend: ReturnType<typeof createRequestOriginGuard>
const backend = createServer((req, res) => {
forwarded++
res.statusCode = checkBackend(req.headers) ? 200 : 403
res.end(JSON.stringify(req.headers))
})
backend.on('upgrade', (req, socket) => {
forwarded++
if (!checkBackend(req.headers, false, true)) {
socket.end('HTTP/1.1 403 Forbidden\r\n\r\n')
} else {
socket.end('HTTP/1.1 101 Switching Protocols\r\nConnection: Upgrade\r\nUpgrade: websocket\r\n\r\n')
}
})
await new Promise<void>(resolve => backend.listen(0, '127.0.0.1', resolve))
const backendPort = (backend.address() as AddressInfo).port
checkBackend = createRequestOriginGuard(backendPort, '127.0.0.1')
const target = `http://127.0.0.1:${backendPort}`
const vite = await createViteServer({
configFile: false,
appType: 'custom',
logLevel: 'silent',
server: {
host: '127.0.0.1', port: 0, hmr: false, watch: null,
proxy: { '/api': createDevProxy(target), '/ws': { ...createDevProxy(target), ws: true } }
}
})
try {
await vite.listen()
const port = (vite.httpServer!.address() as AddressInfo).port
const origin = `http://localhost:${port}`
const send = (path: string, headers: IncomingHttpHeaders) => new Promise<{ status: number, body: string }>((resolve, reject) => {
const req = request({ hostname: '127.0.0.1', port, path, headers: { host: `localhost:${port}`, ...headers } })
req.on('response', res => {
let body = ''
res.on('data', chunk => { body += chunk })
res.on('end', () => resolve({ status: res.statusCode!, body }))
})
req.on('upgrade', (res, socket) => {
socket.destroy()
resolve({ status: res.statusCode!, body: '' })
})
req.on('error', reject)
req.end()
})
const api = await send('/api/settings', { origin, referer: origin + '/', 'sec-fetch-site': 'same-origin' })
expect(api.status).toBe(200)
expect(JSON.parse(api.body)).toMatchObject({ host: `127.0.0.1:${backendPort}`, origin: target, referer: target + '/' })
expect((await send('/ws?transport=polling', { referer: origin + '/', 'sec-fetch-site': 'same-origin' })).status).toBe(200)
expect((await send('/ws?transport=websocket', { origin, connection: 'Upgrade', upgrade: 'websocket' })).status).toBe(101)
const accepted = forwarded
for (const headers of [
{},
{ origin: 'null' },
{ origin: 'https://attacker.example' },
{ origin, 'sec-fetch-site': 'cross-site' },
{ host: `rebound.example:${port}`, origin: `http://rebound.example:${port}`, 'sec-fetch-site': 'same-origin' }
]) {
expect([403, 404]).toContain((await send('/api/settings', headers)).status)
expect([403, 404]).toContain((await send('/ws?transport=polling', headers)).status)
expect((await send('/ws?transport=websocket', { ...headers, connection: 'Upgrade', upgrade: 'websocket' })).status).toBe(404)
}
expect((await send('/ws?transport=websocket', { 'sec-fetch-site': 'same-origin', connection: 'Upgrade', upgrade: 'websocket' })).status).toBe(404)
expect(forwarded).toBe(accepted)
} finally {
await vite.close()
await new Promise<void>((resolve, reject) => backend.close(error => error ? reject(error) : resolve()))
}
}, 15000)
+64
View File
@@ -0,0 +1,64 @@
import type { IncomingMessage } from 'http'
import { createDevProxy } from '../../../../scripts/dev-proxy'
import { createRequestOriginGuard } from '../request-origin'
describe('Vite development proxy origin adaptation', () => {
const proxy = createDevProxy('http://127.0.0.1:3044')
const backend = createRequestOriginGuard(3044, '127.0.0.1')
const request = (headers: Record<string, string | undefined>, port = 8066) => ({
headers: { host: `localhost:${port}`, ...headers },
socket: { localAddress: '127.0.0.1', localPort: port }
}) as IncomingMessage
test.each([
{ origin: 'http://localhost:8066' },
{ referer: 'http://localhost:8066/embed/?a=1' },
{ 'sec-fetch-site': 'same-origin' },
{ origin: 'http://localhost:8066', upgrade: 'websocket' }
])('validates then adapts a trusted request: %j', headers => {
const req = request(headers)
expect(proxy.bypass!(req, undefined, proxy)).toBeUndefined()
// http-proxy applies changeOrigin to the forwarded Host after bypass.
expect(proxy.changeOrigin).toBe(true)
req.headers.host = '127.0.0.1:3044'
expect(backend(req.headers, false, !!headers.upgrade)).toBe(true)
if (headers.origin) expect(req.headers.origin).toBe('http://127.0.0.1:3044')
if (headers.referer) expect(req.headers.referer).toBe('http://127.0.0.1:3044/embed/?a=1')
})
test.each([
{},
{ origin: 'https://attacker.example' },
{ origin: 'null', 'sec-fetch-site': 'same-origin' },
{ origin: 'http://localhost:8067' },
{ origin: 'http://localhost:8066', 'sec-fetch-site': 'cross-site' },
{ referer: 'http://localhost:8066/', 'sec-fetch-site': 'same-site' },
{ origin: 'http://localhost:8066', referer: 'https://attacker.example/' },
{ host: 'rebound.example:8066', origin: 'http://rebound.example:8066', 'sec-fetch-site': 'same-origin' },
{ upgrade: 'websocket', 'sec-fetch-site': 'same-origin' },
{ upgrade: 'websocket', origin: 'null' },
{ upgrade: 'websocket', origin: 'https://attacker.example' }
])('rejects without rewriting attacker-controlled evidence: %j', headers => {
const req = request(headers)
const original = { ...req.headers }
expect(proxy.bypass!(req, undefined, proxy)).toBe(false)
expect(req.headers).toEqual(original)
})
test('preserves the trusted Electron scheme when adapting the target Host', () => {
const req = request({ origin: 'yank-note://localhost', referer: 'yank-note://localhost/', 'sec-fetch-site': 'cross-site' })
expect(proxy.bypass!(req, undefined, proxy)).toBeUndefined()
req.headers.host = '127.0.0.1:3044'
expect(backend(req.headers)).toBe(true)
expect(req.headers.origin).toBe('yank-note://localhost')
})
test('uses the actual listening port, never an arbitrary Host-derived port', () => {
expect(proxy.bypass!(request({ origin: 'http://localhost:8070' }, 8070), undefined, proxy)).toBeUndefined()
expect(proxy.bypass!(request({ host: 'localhost:8070', origin: 'http://localhost:8070' }), undefined, proxy)).toBe(false)
})
test('does not add a Vite-origin exception to the backend', () => {
expect(backend({ host: '127.0.0.1:3044', origin: 'http://localhost:8066' })).toBe(false)
})
})
+2 -2
View File
@@ -832,11 +832,11 @@ export async function killPtyProcesses (pty?: NodePty.IPty) {
}
}
const server = (port = 3000, additionalOrigins: string[] = []) => {
const server = (port = 3000) => {
const app = new Koa()
const host = config.get('server.host', '127.0.0.1')
const trustedOrigins = config.get('server.trusted-origins', [])
const checkOrigin = createRequestOriginGuard(port, host, [...additionalOrigins, ...(Array.isArray(trustedOrigins) ? trustedOrigins : [])])
const checkOrigin = createRequestOriginGuard(port, host, Array.isArray(trustedOrigins) ? trustedOrigins : [])
app.use(async (ctx: any, next: any) => {
if (!checkOrigin(ctx.headers, ctx.req._protocol === true)) {
+7 -6
View File
@@ -3,6 +3,7 @@ import vue from '@vitejs/plugin-vue'
import vueJsx from '@vitejs/plugin-vue-jsx'
import path from 'path'
import fs from 'fs-extra'
import { createDevProxy } from './scripts/dev-proxy'
// copy vs
const vsDist = path.resolve(__dirname, 'src/renderer/public/vs')
@@ -24,22 +25,22 @@ export default defineConfig({
port: 8066,
proxy: {
'/static': {
target: 'http://127.0.0.1:3044'
...createDevProxy('http://127.0.0.1:3044')
},
'/custom-css': {
target: 'http://127.0.0.1:3044'
...createDevProxy('http://127.0.0.1:3044')
},
'/extension': {
target: 'http://127.0.0.1:3044'
...createDevProxy('http://127.0.0.1:3044')
},
'/github.css': {
target: 'http://127.0.0.1:3044'
...createDevProxy('http://127.0.0.1:3044')
},
'/api': {
target: 'http://127.0.0.1:3044'
...createDevProxy('http://127.0.0.1:3044')
},
'/ws': {
target: 'http://127.0.0.1:3044',
...createDevProxy('http://127.0.0.1:3044'),
ws: true
}
}