feat: harden local server access policy and isolate remote preview permissions

This commit is contained in:
toBerlinWay 2026-07-13 16:55:54 +08:00
parent d1e684c419
commit 703e8d066c
8 changed files with 550 additions and 19 deletions

View File

@ -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)')
})
})

View File

@ -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
},

View File

@ -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<void> | null = null
let coalescedRequest: Promise<void> | 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' },
})
})

View File

@ -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<string, unknown>): Promise<unknown>
}
const FULL_CAPTURE_MAX_EDGE = 16_384
const FULL_CAPTURE_MAX_PIXELS = 32_000_000
export type PreviewWebContentsLike = {
loadURL(url: string): Promise<unknown>
executeJavaScript(script: string): Promise<unknown>
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<string>
} | 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<string> {
private async captureNativeDataUrl(kind: PreviewHostCaptureMessage['kind'] = 'viewport'): Promise<string> {
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<string> {
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<string> {
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'),
},
},
}

View File

@ -0,0 +1,12 @@
import type { Session } from 'electron'
export const PREVIEW_SESSION_PARTITION = 'cc-haha-preview'
export function configurePreviewSessionPermissions(
session: Pick<Session, 'setPermissionCheckHandler' | 'setPermissionRequestHandler'>,
): void {
session.setPermissionCheckHandler(() => false)
session.setPermissionRequestHandler((_webContents, _permission, callback) => {
callback(false)
})
}

View File

@ -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 的第一版优先保证聊天主流程:

View File

@ -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',

View File

@ -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'
}