mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-15 12:53:31 +08:00
feat: harden local server access policy and isolate remote preview permissions
This commit is contained in:
parent
d1e684c419
commit
703e8d066c
42
desktop/electron/main.security.test.ts
Normal file
42
desktop/electron/main.security.test.ts
Normal 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)')
|
||||
})
|
||||
})
|
||||
@ -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
|
||||
},
|
||||
|
||||
@ -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' },
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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'),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
12
desktop/electron/services/previewSession.ts
Normal file
12
desktop/electron/services/previewSession.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
@ -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 的第一版优先保证聊天主流程:
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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'
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user