mirror of
https://github.com/purocean/yn.git
synced 2026-10-16 15:41:46 +08:00
fix: adapt trusted development requests in the Vite proxy
This commit is contained in:
+5
-2
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user