From 703e8d066cac30f3ba52b65c3f9c851520f06034 Mon Sep 17 00:00:00 2001 From: toBerlinWay Date: Mon, 13 Jul 2026 16:55:54 +0800 Subject: [PATCH] feat: harden local server access policy and isolate remote preview permissions --- desktop/electron/main.security.test.ts | 42 +++ desktop/electron/main.ts | 6 + desktop/electron/services/preview.test.ts | 285 +++++++++++++++++- desktop/electron/services/preview.ts | 93 +++++- desktop/electron/services/previewSession.ts | 12 + docs/desktop/06-h5-access.md | 12 + src/server/__tests__/h5-access-policy.test.ts | 81 +++++ src/server/h5AccessPolicy.ts | 38 ++- 8 files changed, 550 insertions(+), 19 deletions(-) create mode 100644 desktop/electron/main.security.test.ts create mode 100644 desktop/electron/services/previewSession.ts diff --git a/desktop/electron/main.security.test.ts b/desktop/electron/main.security.test.ts new file mode 100644 index 00000000..38367610 --- /dev/null +++ b/desktop/electron/main.security.test.ts @@ -0,0 +1,42 @@ +import { existsSync, readFileSync } from 'node:fs' +import path from 'node:path' +import { describe, expect, it } from 'vitest' +import { + configurePreviewSessionPermissions, + PREVIEW_SESSION_PARTITION, +} from './services/previewSession' + +const desktopRoot = existsSync(path.resolve(process.cwd(), 'electron', 'main.ts')) + ? process.cwd() + : path.resolve(process.cwd(), 'desktop') +const mainSource = readFileSync(path.join(desktopRoot, 'electron', 'main.ts'), 'utf8') + +describe('Electron preview security boundary', () => { + it('uses a dedicated in-memory session partition for remote previews', () => { + expect(PREVIEW_SESSION_PARTITION).toBe('cc-haha-preview') + expect(PREVIEW_SESSION_PARTITION.startsWith('persist:')).toBe(false) + expect(mainSource).toContain('partition: PREVIEW_SESSION_PARTITION') + }) + + it('denies preview permission checks and requests by default', () => { + const handlers: { + check?: (...args: unknown[]) => boolean + request?: (...args: unknown[]) => void + } = {} + const session = { + setPermissionCheckHandler(handler: (...args: unknown[]) => boolean) { + handlers.check = handler + }, + setPermissionRequestHandler(handler: (...args: unknown[]) => void) { + handlers.request = handler + }, + } + + configurePreviewSessionPermissions(session as never) + + expect(handlers.check?.()).toBe(false) + const callback = (allowed: boolean) => expect(allowed).toBe(false) + handlers.request?.(null, 'media', callback) + expect(mainSource).toContain('configurePreviewSessionPermissions(view.webContents.session)') + }) +}) diff --git a/desktop/electron/main.ts b/desktop/electron/main.ts index 362b98e9..828274c0 100644 --- a/desktop/electron/main.ts +++ b/desktop/electron/main.ts @@ -18,6 +18,10 @@ import { ElectronUpdaterService, updaterSessionProxyConfig } from './services/up import { createUpdateSmokeUpdaterFromEnv } from './services/updateSmoke' import { ElectronTerminalService, type TerminalSpawnInput } from './services/terminal' import { ElectronPreviewService, type PreviewBounds } from './services/preview' +import { + configurePreviewSessionPermissions, + PREVIEW_SESSION_PARTITION, +} from './services/previewSession' import { applyStartupPortableMode, detectPortableDir, @@ -185,11 +189,13 @@ function getPreviewService() { const view = new WebContentsView({ webPreferences: { preload: previewPreloadPath(), + partition: PREVIEW_SESSION_PARTITION, contextIsolation: true, nodeIntegration: false, sandbox: true, }, }) + configurePreviewSessionPermissions(view.webContents.session) installPreviewNavigationGuards(view.webContents, { openExternal: openExternalUrl }) return view }, diff --git a/desktop/electron/services/preview.test.ts b/desktop/electron/services/preview.test.ts index 82c703fa..d7e1c7e8 100644 --- a/desktop/electron/services/preview.test.ts +++ b/desktop/electron/services/preview.test.ts @@ -20,8 +20,28 @@ class FakeWebContents implements PreviewWebContentsLike { scripts: string[] = [] zoomFactors: number[] = [] sent: Array<{ channel: string, payload: unknown }> = [] + documentSize = { width: 1280, height: 3200 } + debuggerAttached = false + debugger = { + isAttached: vi.fn(() => this.debuggerAttached), + attach: vi.fn(() => { + this.debuggerAttached = true + }), + detach: vi.fn(() => { + this.debuggerAttached = false + }), + sendCommand: vi.fn(async (method: string) => { + if (method === 'Page.getLayoutMetrics') { + return { cssContentSize: { x: 0, y: 0, ...this.documentSize } } + } + if (method === 'Page.captureScreenshot') return { data: 'FULL' } + throw new Error(`unexpected debugger command: ${method}`) + }), + } close = vi.fn() - capturePage = vi.fn(async () => ({ toDataURL: () => 'data:image/png;base64,NATIVE' })) + capturePage = vi.fn(async (_rect?: { x: number, y: number, width: number, height: number }) => ({ + toDataURL: () => 'data:image/png;base64,NATIVE', + })) private loadHandler: (() => void) | null = null async loadURL(url: string) { @@ -246,16 +266,269 @@ describe('Electron preview service', () => { await service.message({ v: 1, type: 'capture', kind: 'full' }, renderer) - expect(view.webContents.capturePage).toHaveBeenCalledTimes(1) - expect(view.webContents.scripts.at(-1)).toBe('window.__previewInjected = true') + expect(view.webContents.capturePage).not.toHaveBeenCalled() + expect(view.webContents.scripts).not.toContain(expect.stringContaining('html2canvas')) expect(renderer.sent).toEqual([ { channel: ELECTRON_EVENT_CHANNELS.previewEvent, - payload: { v: 1, type: 'screenshot', dataUrl: 'data:image/png;base64,NATIVE', kind: 'full' }, + payload: { v: 1, type: 'screenshot', dataUrl: 'data:image/png;base64,FULL', kind: 'full' }, }, ]) }) + it('captures the full document through CDP with bounded document dimensions', async () => { + const view = new FakeView() + const renderer = new FakeWebContents() + view.webContents.documentSize = { width: 1280, height: 3200 } + const service = new ElectronPreviewService({ + createView: () => view, + previewScriptPath: previewScript(), + }) + await service.open({ contentView: { addChildView: vi.fn(), removeChildView: vi.fn() } }, 'https://example.com', { + x: 0, + y: 0, + width: 800, + height: 600, + }) + + await service.message({ v: 1, type: 'capture', kind: 'full' }, renderer) + + expect(view.webContents.debugger.attach).toHaveBeenCalledWith('1.3') + expect(view.webContents.debugger.sendCommand.mock.calls).toEqual([ + ['Page.getLayoutMetrics'], + ['Page.captureScreenshot', { + format: 'png', + fromSurface: true, + captureBeyondViewport: true, + clip: { x: 0, y: 0, width: 1280, height: 3200, scale: 1 }, + }], + ]) + expect(view.webContents.debugger.detach).toHaveBeenCalledTimes(1) + expect(view.webContents.capturePage).not.toHaveBeenCalled() + }) + + it('keeps viewport capture limited to the visible page', async () => { + const view = new FakeView() + const renderer = new FakeWebContents() + view.webContents.documentSize = { width: 1280, height: 3200 } + const service = new ElectronPreviewService({ + createView: () => view, + previewScriptPath: previewScript(), + }) + await service.open({ contentView: { addChildView: vi.fn(), removeChildView: vi.fn() } }, 'https://example.com', { + x: 0, + y: 0, + width: 800, + height: 600, + }) + + await service.message({ v: 1, type: 'capture', kind: 'viewport' }, renderer) + + expect(view.webContents.capturePage).toHaveBeenCalledWith() + expect(view.webContents.debugger.sendCommand).not.toHaveBeenCalled() + }) + + it('detaches the debugger when a full capture fails', async () => { + const view = new FakeView() + const renderer = new FakeWebContents() + view.webContents.debugger.sendCommand.mockRejectedValueOnce(new Error('layout failed')) + const service = new ElectronPreviewService({ + createView: () => view, + previewScriptPath: previewScript(), + }) + await service.open({ contentView: { addChildView: vi.fn(), removeChildView: vi.fn() } }, 'https://example.com', { + x: 0, + y: 0, + width: 800, + height: 600, + }) + + await service.message({ v: 1, type: 'capture', kind: 'full' }, renderer) + + expect(view.webContents.debugger.detach).toHaveBeenCalledTimes(1) + expect(renderer.sent.at(-1)).toMatchObject({ + channel: ELECTRON_EVENT_CHANNELS.previewEvent, + payload: { v: 1, type: 'error', message: 'Error: layout failed' }, + }) + }) + + it('preserves a completed capture if debugger state lookup fails during cleanup', async () => { + const view = new FakeView() + const renderer = new FakeWebContents() + view.webContents.debugger.isAttached + .mockReturnValueOnce(false) + .mockImplementationOnce(() => { + throw new Error('view closed') + }) + const service = new ElectronPreviewService({ + createView: () => view, + previewScriptPath: previewScript(), + }) + await service.open({ contentView: { addChildView: vi.fn(), removeChildView: vi.fn() } }, 'https://example.com', { + x: 0, + y: 0, + width: 800, + height: 600, + }) + + await service.message({ v: 1, type: 'capture', kind: 'full' }, renderer) + + expect(renderer.sent.at(-1)).toMatchObject({ + payload: { type: 'screenshot', dataUrl: 'data:image/png;base64,FULL', kind: 'full' }, + }) + }) + + it('does not detach a debugger session it did not attach', async () => { + const view = new FakeView() + const renderer = new FakeWebContents() + view.webContents.debuggerAttached = true + const service = new ElectronPreviewService({ + createView: () => view, + previewScriptPath: previewScript(), + }) + await service.open({ contentView: { addChildView: vi.fn(), removeChildView: vi.fn() } }, 'https://example.com', { + x: 0, + y: 0, + width: 800, + height: 600, + }) + + await service.message({ v: 1, type: 'capture', kind: 'full' }, renderer) + + expect(view.webContents.debugger.attach).not.toHaveBeenCalled() + expect(view.webContents.debugger.detach).not.toHaveBeenCalled() + }) + + it('shares one debugger capture across concurrent full-page requests', async () => { + const view = new FakeView() + const firstRenderer = new FakeWebContents() + const secondRenderer = new FakeWebContents() + const service = new ElectronPreviewService({ + createView: () => view, + previewScriptPath: previewScript(), + }) + await service.open({ contentView: { addChildView: vi.fn(), removeChildView: vi.fn() } }, 'https://example.com', { + x: 0, + y: 0, + width: 800, + height: 600, + }) + + await Promise.all([ + service.message({ v: 1, type: 'capture', kind: 'full' }, firstRenderer), + service.message({ v: 1, type: 'capture', kind: 'full' }, secondRenderer), + ]) + + expect(view.webContents.debugger.attach).toHaveBeenCalledTimes(1) + expect(view.webContents.debugger.detach).toHaveBeenCalledTimes(1) + expect(view.webContents.debugger.sendCommand).toHaveBeenCalledTimes(2) + expect(firstRenderer.sent.at(-1)).toEqual(secondRenderer.sent.at(-1)) + }) + + it('starts a new full-page capture after the preview is closed and reopened', async () => { + const firstView = new FakeView() + const secondView = new FakeView() + const firstRenderer = new FakeWebContents() + const secondRenderer = new FakeWebContents() + const coalescedRenderer = new FakeWebContents() + let resolveFirstCapture!: (value: { data: string }) => void + let resolveSecondCapture!: (value: { data: string }) => void + const firstCapture = new Promise<{ data: string }>((resolve) => { + resolveFirstCapture = resolve + }) + const secondCapture = new Promise<{ data: string }>((resolve) => { + resolveSecondCapture = resolve + }) + + firstView.webContents.debugger.sendCommand.mockImplementation(async (method: string) => { + if (method === 'Page.getLayoutMetrics') { + return { cssContentSize: { x: 0, y: 0, width: 800, height: 1200 } } + } + if (method === 'Page.captureScreenshot') return await firstCapture + throw new Error(`unexpected debugger command: ${method}`) + }) + secondView.webContents.debugger.sendCommand.mockImplementation(async (method: string) => { + if (method === 'Page.getLayoutMetrics') { + return { cssContentSize: { x: 0, y: 0, width: 900, height: 1400 } } + } + if (method === 'Page.captureScreenshot') return await secondCapture + throw new Error(`unexpected debugger command: ${method}`) + }) + + const views = [firstView, secondView] + const parent = { contentView: { addChildView: vi.fn(), removeChildView: vi.fn() } } + const service = new ElectronPreviewService({ + createView: () => views.shift()!, + previewScriptPath: previewScript(), + }) + await service.open(parent, 'https://first.example.com', { x: 0, y: 0, width: 800, height: 600 }) + + const firstRequest = service.message({ v: 1, type: 'capture', kind: 'full' }, firstRenderer) + let secondRequest: Promise | null = null + let coalescedRequest: Promise | null = null + try { + for (let index = 0; index < 8; index += 1) await Promise.resolve() + expect(firstView.webContents.debugger.sendCommand).toHaveBeenCalledWith('Page.captureScreenshot', expect.anything()) + + service.close() + await service.open(parent, 'https://second.example.com', { x: 0, y: 0, width: 900, height: 700 }) + secondRequest = service.message({ v: 1, type: 'capture', kind: 'full' }, secondRenderer) + + for (let index = 0; index < 8; index += 1) await Promise.resolve() + expect(secondView.webContents.debugger.sendCommand).toHaveBeenCalledWith('Page.captureScreenshot', expect.anything()) + + resolveFirstCapture({ data: 'FIRST' }) + await firstRequest + + coalescedRequest = service.message({ v: 1, type: 'capture', kind: 'full' }, coalescedRenderer) + for (let index = 0; index < 8; index += 1) await Promise.resolve() + expect(secondView.webContents.debugger.sendCommand).toHaveBeenCalledTimes(2) + + resolveSecondCapture({ data: 'SECOND' }) + await Promise.all([secondRequest, coalescedRequest]) + + expect(secondRenderer.sent.at(-1)).toMatchObject({ + payload: { type: 'screenshot', dataUrl: 'data:image/png;base64,SECOND', kind: 'full' }, + }) + expect(coalescedRenderer.sent.at(-1)).toEqual(secondRenderer.sent.at(-1)) + } finally { + resolveFirstCapture({ data: 'FIRST' }) + resolveSecondCapture({ data: 'SECOND' }) + await Promise.allSettled([ + firstRequest, + ...(secondRequest ? [secondRequest] : []), + ...(coalescedRequest ? [coalescedRequest] : []), + ]) + } + }) + + it.each([ + ['edge', { width: 16_385, height: 100 }], + ['pixel count', { width: 8_001, height: 4_000 }], + ])('rejects full captures that exceed the %s safety limit', async (_limit, documentSize) => { + const view = new FakeView() + const renderer = new FakeWebContents() + view.webContents.documentSize = documentSize + const service = new ElectronPreviewService({ + createView: () => view, + previewScriptPath: previewScript(), + }) + await service.open({ contentView: { addChildView: vi.fn(), removeChildView: vi.fn() } }, 'https://example.com', { + x: 0, + y: 0, + width: 800, + height: 600, + }) + + await service.message({ v: 1, type: 'capture', kind: 'full' }, renderer) + + expect(view.webContents.debugger.sendCommand).toHaveBeenCalledTimes(1) + expect(view.webContents.debugger.detach).toHaveBeenCalledTimes(1) + expect(renderer.sent.at(-1)).toMatchObject({ + payload: { type: 'error', message: expect.stringContaining('exceeds safety limit') }, + }) + }) + it('applies preview zoom to the native WebContentsView before screenshot capture', async () => { const view = new FakeView() const renderer = new FakeWebContents() @@ -275,10 +548,10 @@ describe('Electron preview service', () => { expect(view.webContents.zoomFactors.at(-1)).toBe(0.8) expect(view.bounds).toHaveLength(1) - expect(view.webContents.capturePage).toHaveBeenCalledTimes(1) + expect(view.webContents.capturePage).not.toHaveBeenCalled() expect(renderer.sent.at(-1)).toEqual({ channel: ELECTRON_EVENT_CHANNELS.previewEvent, - payload: { v: 1, type: 'screenshot', dataUrl: 'data:image/png;base64,NATIVE', kind: 'full' }, + payload: { v: 1, type: 'screenshot', dataUrl: 'data:image/png;base64,FULL', kind: 'full' }, }) }) diff --git a/desktop/electron/services/preview.ts b/desktop/electron/services/preview.ts index cde4a17d..dcd3c007 100644 --- a/desktop/electron/services/preview.ts +++ b/desktop/electron/services/preview.ts @@ -11,13 +11,26 @@ export type PreviewBounds = { height: number } +type PreviewCaptureRect = PreviewBounds + +type PreviewDebuggerLike = { + isAttached(): boolean + attach(protocolVersion?: string): void + detach(): void + sendCommand(method: string, commandParams?: Record): Promise +} + +const FULL_CAPTURE_MAX_EDGE = 16_384 +const FULL_CAPTURE_MAX_PIXELS = 32_000_000 + export type PreviewWebContentsLike = { loadURL(url: string): Promise executeJavaScript(script: string): Promise on(event: 'did-finish-load', handler: () => void): unknown close?(): void isDestroyed?(): boolean - capturePage?(): Promise<{ toDataURL(): string }> + capturePage?(rect?: PreviewCaptureRect): Promise<{ toDataURL(): string }> + debugger?: PreviewDebuggerLike setZoomFactor?(factor: number): void send(channel: string, payload: unknown): void } @@ -121,6 +134,10 @@ export class ElectronPreviewService { private parent: PreviewParentWindowLike | null = null private requestedBounds: PreviewBounds | null = null private zoomFactor = 1 + private fullCapture: { + webContents: PreviewWebContentsLike + promise: Promise + } | null = null constructor(options: ElectronPreviewServiceOptions) { this.createView = options.createView @@ -217,13 +234,81 @@ export class ElectronPreviewService { await view.webContents.executeJavaScript(script) } - private async captureNativeDataUrl(): Promise { + private async captureNativeDataUrl(kind: PreviewHostCaptureMessage['kind'] = 'viewport'): Promise { const webContents = this.requireView().webContents + if (kind === 'full') return this.captureFullPageDataUrl(webContents) if (!webContents.capturePage) throw new Error('native preview capture unavailable') const image = await webContents.capturePage() return image.toDataURL() } + private async captureFullPageDataUrl(webContents: PreviewWebContentsLike): Promise { + if (this.fullCapture?.webContents === webContents) { + return await this.fullCapture.promise + } + + const promise = this.captureFullPageDataUrlOnce(webContents) + const capture = { webContents, promise } + this.fullCapture = capture + try { + return await promise + } finally { + if (this.fullCapture === capture) this.fullCapture = null + } + } + + private async captureFullPageDataUrlOnce(webContents: PreviewWebContentsLike): Promise { + const debuggerApi = webContents.debugger + if (!debuggerApi) throw new Error('full preview capture unavailable') + + let attachedHere = false + try { + if (!debuggerApi.isAttached()) { + debuggerApi.attach('1.3') + attachedHere = true + } + + const metrics = await debuggerApi.sendCommand('Page.getLayoutMetrics') + if (!isPlainRecord(metrics)) throw new Error('invalid full preview layout metrics') + const contentSize = isPlainRecord(metrics.cssContentSize) + ? metrics.cssContentSize + : metrics.contentSize + if (!isPlainRecord(contentSize)) throw new Error('invalid full preview layout metrics') + + const width = Math.ceil(Number(contentSize.width)) + const height = Math.ceil(Number(contentSize.height)) + if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) { + throw new Error('invalid full preview dimensions') + } + if ( + width > FULL_CAPTURE_MAX_EDGE || + height > FULL_CAPTURE_MAX_EDGE || + width * height > FULL_CAPTURE_MAX_PIXELS + ) { + throw new Error(`full preview capture exceeds safety limit: ${width}x${height}`) + } + + const screenshot = await debuggerApi.sendCommand('Page.captureScreenshot', { + format: 'png', + fromSurface: true, + captureBeyondViewport: true, + clip: { x: 0, y: 0, width, height, scale: 1 }, + }) + if (!isPlainRecord(screenshot) || typeof screenshot.data !== 'string' || !screenshot.data) { + throw new Error('invalid full preview screenshot data') + } + return `data:image/png;base64,${screenshot.data}` + } finally { + if (attachedHere) { + try { + if (debuggerApi.isAttached()) debuggerApi.detach() + } catch { + // The page may close while a full-page capture is in flight. + } + } + } + } + private applyZoomFactor(view: PreviewViewLike | null): void { view?.webContents.setZoomFactor?.(this.zoomFactor) } @@ -239,7 +324,7 @@ export class ElectronPreviewService { renderer.send(ELECTRON_EVENT_CHANNELS.previewEvent, { v: 1, type: 'screenshot', - dataUrl: await this.captureNativeDataUrl(), + dataUrl: await this.captureNativeDataUrl(kind), kind, }) } catch (error) { @@ -262,7 +347,7 @@ export class ElectronPreviewService { screenshot: { ...screenshot, kind: screenshot.kind ?? 'region', - dataUrl: await this.captureNativeDataUrl(), + dataUrl: await this.captureNativeDataUrl('viewport'), }, }, } diff --git a/desktop/electron/services/previewSession.ts b/desktop/electron/services/previewSession.ts new file mode 100644 index 00000000..2803f813 --- /dev/null +++ b/desktop/electron/services/previewSession.ts @@ -0,0 +1,12 @@ +import type { Session } from 'electron' + +export const PREVIEW_SESSION_PARTITION = 'cc-haha-preview' + +export function configurePreviewSessionPermissions( + session: Pick, +): void { + session.setPermissionCheckHandler(() => false) + session.setPermissionRequestHandler((_webContents, _permission, callback) => { + callback(false) + }) +} diff --git a/docs/desktop/06-h5-access.md b/docs/desktop/06-h5-access.md index 7388d824..001a711a 100644 --- a/docs/desktop/06-h5-access.md +++ b/docs/desktop/06-h5-access.md @@ -57,6 +57,18 @@ https://cc.example.com/?serverUrl=https%3A%2F%2Fcc-api.example.com 4. WebSocket 代理需要支持协议升级。 5. 只把域名分享给可信成员,并单独发送 Token。 +反向代理连接本机服务时,后端看到的来源地址通常也是 `127.0.0.1`。为避免把远程请求误认成本地直连,请保留公开 `Host`,或至少传递一个标准代理头:`Forwarded`、`X-Forwarded-For`、`X-Forwarded-Host`、`X-Forwarded-Proto`、`X-Real-IP`、`Via`。 + +Nginx 可以这样配置: + +```nginx +proxy_set_header Host $host; +proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; +proxy_set_header X-Forwarded-Proto $scheme; +``` + +Caddy 的 `reverse_proxy` 默认会保留原始 Host,并补充 `X-Forwarded-*` 头。如果你自定义了 `header_up`,请确保仍保留公开 Host 或上述任一代理头。不要同时把 Host 改成上游的 `127.0.0.1` 并删除全部代理头;缺少这些信息时,服务无法安全区分远程反代请求和本地直连请求。 + ## 手机体验范围 H5 的第一版优先保证聊天主流程: diff --git a/src/server/__tests__/h5-access-policy.test.ts b/src/server/__tests__/h5-access-policy.test.ts index f96dc954..8c8719ac 100644 --- a/src/server/__tests__/h5-access-policy.test.ts +++ b/src/server/__tests__/h5-access-policy.test.ts @@ -19,6 +19,7 @@ describe('h5AccessPolicy', () => { expect(isLoopbackHost('127.0.0.1')).toBe(true) expect(isLoopbackHost('127.0.1.1')).toBe(true) expect(isLoopbackHost('[::1]')).toBe(true) + expect(isLoopbackHost('::ffff:127.0.0.1')).toBe(true) expect(isLoopbackHost('127.example.com')).toBe(false) expect(isLoopbackHost('127.bad.0.1')).toBe(false) expect(isLoopbackHost('192.168.0.20')).toBe(false) @@ -123,6 +124,86 @@ describe('h5AccessPolicy', () => { } }) + test('does not trust a public request host just because a reverse proxy connects from loopback', () => { + for (const pathname of [ + '/api/status', + '/local-file/Users/alice/report.html', + '/preview-fs/session-1/index.html', + '/proxy/openai/v1/chat/completions', + '/ws/session-1', + ]) { + const request = req(`https://haha.example.com:8443${pathname}`) + const url = new URL(request.url) + + expect(classifyH5Request(request, url, localContext)).toBe('h5-browser') + expect(shouldRequireH5Token({ request, url, h5Enabled: true, context: localContext })).toBe(true) + expect(shouldBlockDisabledH5Access({ + request, + url, + h5Enabled: false, + explicitAuthRequired: false, + context: localContext, + })).toBe(true) + } + }) + + test('does not trust loopback requests carrying reverse proxy trace headers', () => { + for (const [header, value] of [ + ['Forwarded', 'for=203.0.113.9;proto=https;host=haha.example.com'], + ['X-Forwarded-For', '203.0.113.9'], + ['X-Forwarded-Host', 'haha.example.com'], + ['X-Forwarded-Proto', 'https'], + ['X-Real-IP', '203.0.113.9'], + ['Via', '1.1 proxy.example.com'], + ]) { + for (const pathname of [ + '/api/status', + '/local-file/Users/alice/report.html', + '/preview-fs/session-1/index.html', + '/proxy/openai/v1/chat/completions', + '/ws/session-1', + ]) { + const request = req(`http://127.0.0.1:3456${pathname}`, { + headers: { [header]: value }, + }) + const url = new URL(request.url) + + expect(classifyH5Request(request, url, localContext)).toBe('h5-browser') + expect(shouldRequireH5Token({ request, url, h5Enabled: true, context: localContext })).toBe(true) + expect(shouldBlockDisabledH5Access({ + request, + url, + h5Enabled: false, + explicitAuthRequired: false, + context: localContext, + })).toBe(true) + } + } + }) + + test('does not grant internal SDK trust to a request carrying proxy traces', () => { + const request = req('http://127.0.0.1:3456/sdk/session-1', { + headers: { 'X-Forwarded-For': '203.0.113.9' }, + }) + + expect(classifyH5Request(request, new URL(request.url), localContext)).toBe('h5-browser') + }) + + test('keeps no-Origin requests tokenless when both connection and target hosts are loopback', () => { + for (const { requestUrl, clientAddress } of [ + { requestUrl: 'http://localhost:3456/api/status', clientAddress: '127.0.0.1' }, + { requestUrl: 'https://127.0.1.1:8443/api/status', clientAddress: '::ffff:127.0.0.1' }, + { requestUrl: 'http://[::1]:3456/api/status', clientAddress: '::1' }, + ]) { + const request = req(requestUrl) + const url = new URL(request.url) + const context = { clientAddress } + + expect(classifyH5Request(request, url, context)).toBe('local-trusted') + expect(shouldRequireH5Token({ request, url, h5Enabled: true, context })).toBe(false) + } + }) + test('requires H5 token for LAN browser API, proxy, and chat websocket routes when enabled', () => { for (const pathname of [ '/api/status', diff --git a/src/server/h5AccessPolicy.ts b/src/server/h5AccessPolicy.ts index dd292d8f..ccef659b 100644 --- a/src/server/h5AccessPolicy.ts +++ b/src/server/h5AccessPolicy.ts @@ -4,6 +4,14 @@ export type H5RequestContext = { } const LOCAL_DESKTOP_ORIGINS = new Set(['file://']) +const PROXY_TRACE_HEADERS = [ + 'forwarded', + 'x-forwarded-for', + 'x-forwarded-host', + 'x-forwarded-proto', + 'x-real-ip', + 'via', +] as const export function normalizeHostname(hostname: string): string { return hostname.trim().replace(/^\[/, '').replace(/\]$/, '').toLowerCase() @@ -53,6 +61,25 @@ function isLocalDesktopOrNavigationOrigin(origin: string | null): boolean { return LOCAL_DESKTOP_ORIGINS.has(origin) || isLoopbackBrowserOrigin(origin) } +function hasProxyTraceHeaders(headers: Headers): boolean { + return PROXY_TRACE_HEADERS.some((header) => headers.has(header)) +} + +function isLocalTrustedRequest( + request: Request, + url: URL, + context: H5RequestContext, + origin: string | null, +): boolean { + const clientAddress = context.clientAddress + if (!clientAddress) return false + if (hasProxyTraceHeaders(request.headers)) return false + + return isLoopbackHost(clientAddress) && + isLoopbackHost(url.hostname) && + isLocalDesktopOrNavigationOrigin(origin) +} + function isFilesystemCapabilityPath(pathname: string): boolean { return pathname.startsWith('/local-file/') || pathname.startsWith('/preview-fs/') @@ -64,18 +91,11 @@ export function classifyH5Request( context: H5RequestContext, ): H5RequestKind { const origin = request.headers.get('Origin') + const localTrusted = isLocalTrustedRequest(request, url, context, origin) if (isFilesystemCapabilityPath(url.pathname)) { - const localFilesystemTrusted = Boolean(context.clientAddress) && - isLoopbackHost(context.clientAddress!) && - isLocalDesktopOrNavigationOrigin(origin) - - return localFilesystemTrusted ? 'local-trusted' : 'h5-browser' + return localTrusted ? 'local-trusted' : 'h5-browser' } - const localTrusted = Boolean(context.clientAddress) && - isLoopbackHost(context.clientAddress!) && - isLocalDesktopOrNavigationOrigin(origin) - if (url.pathname.startsWith('/sdk/') && localTrusted) { return 'internal-sdk' }