Merge H5 loopback access fix into local main

This commit is contained in:
程序员阿江(Relakkes) 2026-07-26 01:08:23 +08:00
commit 0dac038249
6 changed files with 234 additions and 29 deletions

View File

@ -156,6 +156,33 @@ describe('desktopRuntime browser H5 bootstrap', () => {
expect(globalThis.fetch).toHaveBeenCalledTimes(1)
})
it('still starts when the desktop host cannot resolve the local access token', async () => {
// The token only raises what the shell may do — loopback is trusted without
// it — so losing it must degrade rather than block startup.
const serverUrl = 'http://127.0.0.1:59232'
const consoleWarn = vi.spyOn(console, 'warn').mockImplementation(() => {})
window.desktopHost = {
...browserHost,
kind: 'electron',
isDesktop: true,
runtime: {
getServerUrl: vi.fn().mockResolvedValue(serverUrl),
getLocalAccessToken: vi.fn().mockRejectedValue(new Error('ipc channel missing')),
},
}
globalThis.fetch = vi.fn().mockResolvedValue(
healthOkResponse(),
) as typeof fetch
await expect(initializeDesktopServerUrl()).resolves.toBe(serverUrl)
expect(clientMocks.setBaseUrl).toHaveBeenLastCalledWith(serverUrl)
expect(clientMocks.setAuthToken).toHaveBeenLastCalledWith(null)
expect(consoleWarn).toHaveBeenCalled()
consoleWarn.mockRestore()
})
it('classifies browser H5 runtime using the desktop host boundary', () => {
expect(isBrowserH5Runtime()).toBe(true)
expect(isDesktopRuntime()).toBe(false)

View File

@ -165,7 +165,12 @@ export async function initializeDesktopServerUrl() {
try {
const [serverUrl, localAccessToken] = await Promise.all([
host.runtime.getServerUrl(),
host.runtime.getLocalAccessToken(),
// The process token only *raises* what the shell may do; loopback is
// trusted without it. Losing it must not take the whole app down with it.
host.runtime.getLocalAccessToken().catch((error) => {
console.warn('[desktop] local access token unavailable, continuing on loopback trust', error)
return null
}),
])
setBaseUrl(serverUrl)
setAuthToken(localAccessToken)

View File

@ -332,12 +332,20 @@ describe('remote H5 auth and CORS integration', () => {
}
})
test('rejects a tokenless loopback proxy hop when desktop local auth is configured', async () => {
test('keeps tokenless loopback capabilities working when desktop local auth is configured', async () => {
// The desktop shell injects a process token, but the browser windows it
// opens (OAuth success pages, `/preview-fs` links) and local scripts cannot
// carry it. Loopback must stay trusted on its own.
process.env.CC_HAHA_LOCAL_ACCESS_TOKEN = 'desktop-local-secret'
await restartRemoteServer()
const proxyShapedResponse = await fetch(`${baseUrl}/api/status`)
expect(proxyShapedResponse.status).toBe(403)
const tokenlessResponse = await fetch(`${baseUrl}/api/status`)
expect(tokenlessResponse.status).toBe(200)
await expect(tokenlessResponse.json()).resolves.toMatchObject({ status: 'ok' })
const oauthSuccessResponse = await fetch(`${baseUrl}/api/haha-grok-oauth/success`)
expect(oauthSuccessResponse.status).toBe(200)
expect(oauthSuccessResponse.headers.get('Content-Type')).toContain('text/html')
const desktopResponse = await fetch(`${baseUrl}/api/status`, {
headers: { Authorization: 'Bearer desktop-local-secret' },
@ -346,6 +354,51 @@ describe('remote H5 auth and CORS integration', () => {
await expect(desktopResponse.json()).resolves.toMatchObject({ status: 'ok' })
})
test('still requires the desktop process token for the H5 control plane', async () => {
process.env.CC_HAHA_LOCAL_ACCESS_TOKEN = 'desktop-local-secret'
await restartRemoteServer()
// Another browser or script on the same machine must not be able to publish
// the user's sessions to the network.
const tokenlessRead = await fetch(`${baseUrl}/api/h5-access`)
expect(tokenlessRead.status).toBe(403)
const tokenlessEnable = await fetch(`${baseUrl}/api/h5-access/enable`, { method: 'POST' })
expect(tokenlessEnable.status).toBe(403)
const desktopEnable = await fetch(`${baseUrl}/api/h5-access/enable`, {
method: 'POST',
headers: { Authorization: 'Bearer desktop-local-secret' },
})
expect(desktopEnable.status).toBe(200)
await expect(desktopEnable.json()).resolves.toHaveProperty('token')
})
test('does not extend tokenless loopback trust to cross-site subresource loads', async () => {
process.env.CC_HAHA_LOCAL_ACCESS_TOKEN = 'desktop-local-secret'
await restartRemoteServer()
// A malicious page embedding `<img src="http://127.0.0.1:<port>/api/...">`
// sends no Origin, so Fetch Metadata is what marks it as not-a-navigation.
const subresourceResponse = await fetch(`${baseUrl}/api/status`, {
headers: {
'Sec-Fetch-Site': 'cross-site',
'Sec-Fetch-Mode': 'no-cors',
'Sec-Fetch-Dest': 'image',
},
})
expect(subresourceResponse.status).toBe(403)
const navigationResponse = await fetch(`${baseUrl}/api/status`, {
headers: {
'Sec-Fetch-Site': 'cross-site',
'Sec-Fetch-Mode': 'navigate',
'Sec-Fetch-Dest': 'document',
},
})
expect(navigationResponse.status).toBe(200)
})
test('enforces the pet bearer capability allowlist before API routing', async () => {
process.env.CC_HAHA_LOCAL_ACCESS_TOKEN = 'desktop-local-secret'
process.env.CC_HAHA_PET_ACCESS_TOKEN = 'pet-capability-secret'
@ -478,8 +531,10 @@ describe('remote H5 auth and CORS integration', () => {
body: JSON.stringify({ model: 'test', max_tokens: 8, messages: [] }),
})
// Loopback reaches the proxy with or without the process token; only the
// provider configuration decides the outcome.
const disabledTokenlessResponse = await requestProxy(false)
expect(disabledTokenlessResponse.status).toBe(403)
expect(disabledTokenlessResponse.status).toBe(400)
const disabledAuthorizedResponse = await requestProxy(true)
expect(disabledAuthorizedResponse.status).toBe(400)
@ -489,8 +544,11 @@ describe('remote H5 auth and CORS integration', () => {
await new H5AccessService().enable()
// Remote browsers on this same route still need the H5 token — covered by
// 'requires H5 token for remote browser proxy requests when H5 access is
// enabled'.
const enabledTokenlessResponse = await requestProxy(false)
expect(enabledTokenlessResponse.status).toBe(401)
expect(enabledTokenlessResponse.status).toBe(400)
const enabledAuthorizedResponse = await requestProxy(true)
expect(enabledAuthorizedResponse.status).toBe(400)

View File

@ -2,6 +2,7 @@ import { describe, expect, test } from 'bun:test'
import {
classifyH5Request,
isLoopbackHost,
requiresLocalAccessCredential,
shouldBlockDisabledH5Access,
shouldRequireH5Token,
} from '../h5AccessPolicy.js'
@ -193,9 +194,7 @@ describe('h5AccessPolicy', () => {
}
})
test('requires the configured local credential even when a proxy perfectly mimics loopback', () => {
const request = req('http://127.0.0.1:3456/api/h5-access')
const url = new URL(request.url)
test('requires the configured local credential to reach the H5 control plane', () => {
const unauthorizedContext = {
clientAddress: '127.0.0.1',
localAccessTokenConfigured: true,
@ -206,15 +205,72 @@ describe('h5AccessPolicy', () => {
localAccessAuthorized: true,
}
expect(classifyH5Request(request, url, unauthorizedContext)).toBe('h5-browser')
expect(shouldBlockDisabledH5Access({
request,
url,
h5Enabled: false,
explicitAuthRequired: false,
context: unauthorizedContext,
})).toBe(true)
expect(classifyH5Request(request, url, authorizedContext)).toBe('local-trusted')
for (const pathname of ['/api/h5-access', '/api/h5-access/enable']) {
expect(requiresLocalAccessCredential(pathname, unauthorizedContext)).toBe(true)
expect(requiresLocalAccessCredential(pathname, authorizedContext)).toBe(false)
}
// Verifying a token the phone already holds is not a control-plane change,
// and an unmanaged (tokenless) server must not lock itself out either.
expect(requiresLocalAccessCredential('/api/h5-access/verify', unauthorizedContext)).toBe(false)
expect(requiresLocalAccessCredential('/api/h5-access', { clientAddress: '127.0.0.1' })).toBe(false)
})
test('keeps ordinary loopback capabilities usable without the desktop process token', () => {
// The desktop shell injects a process token, but the OAuth success page the
// system browser opens, a `/preview-fs` link and plain `curl` can never
// carry it. Gating loopback behind that token 401'd all of them.
const desktopContext = {
clientAddress: '127.0.0.1',
localAccessTokenConfigured: true,
localAccessAuthorized: false,
}
for (const pathname of [
'/api/haha-grok-oauth/success',
'/api/sessions',
'/preview-fs/session-1/index.html',
'/ws/session-1',
]) {
const request = req(`http://127.0.0.1:3456${pathname}`)
const url = new URL(request.url)
expect(classifyH5Request(request, url, desktopContext)).toBe('local-trusted')
expect(shouldRequireH5Token({ request, url, h5Enabled: true, context: desktopContext })).toBe(false)
expect(shouldBlockDisabledH5Access({
request,
url,
h5Enabled: false,
explicitAuthRequired: false,
context: desktopContext,
})).toBe(false)
}
})
test('does not extend loopback trust to cross-site subresource loads', () => {
// `<img src="http://127.0.0.1:3456/api/...">` from a malicious page reaches
// us without an Origin header; only Fetch Metadata separates it from a real
// local navigation.
const request = req('http://127.0.0.1:3456/api/sessions', {
headers: {
'Sec-Fetch-Site': 'cross-site',
'Sec-Fetch-Mode': 'no-cors',
'Sec-Fetch-Dest': 'image',
},
})
expect(classifyH5Request(request, new URL(request.url), localContext)).toBe('h5-browser')
for (const headers of [
// The OAuth provider redirecting the browser back to us.
{ 'Sec-Fetch-Site': 'cross-site', 'Sec-Fetch-Mode': 'navigate', 'Sec-Fetch-Dest': 'document' },
// The user opening the URL from the address bar or `shell.open`.
{ 'Sec-Fetch-Site': 'none', 'Sec-Fetch-Mode': 'navigate', 'Sec-Fetch-Dest': 'document' },
// The local H5 shell calling its own API.
{ 'Sec-Fetch-Site': 'same-origin', 'Sec-Fetch-Mode': 'cors', 'Sec-Fetch-Dest': 'empty' },
]) {
const allowed = req('http://127.0.0.1:3456/api/sessions', { headers })
expect(classifyH5Request(allowed, new URL(allowed.url), localContext)).toBe('local-trusted')
}
})
test('does not grant internal SDK trust to a request carrying proxy traces', () => {

View File

@ -59,8 +59,29 @@ function isLoopbackBrowserOrigin(origin: string): boolean {
return isLoopbackHost(parsed.hostname)
}
function isLocalDesktopOrNavigationOrigin(origin: string | null): boolean {
if (!origin) return true
/**
* A cross-site subresource load (`<img>`, `<script>`, `no-cors` fetch) reaches
* us without an `Origin` header, so it would otherwise be indistinguishable
* from a genuine local navigation. Fetch Metadata is what tells them apart:
* a top-level navigation carries `Sec-Fetch-Mode: navigate`, a subresource
* does not. Clients that send no Fetch Metadata at all (curl, adapters, the
* CLI subprocess) stay trusted they are not a browser CSRF vector.
*/
function isCrossSiteSubresource(headers: Headers): boolean {
const site = headers.get('Sec-Fetch-Site')
if (site !== 'cross-site' && site !== 'same-site') {
return false
}
const mode = headers.get('Sec-Fetch-Mode')
return mode !== null && mode !== 'navigate'
}
function isLocalDesktopOrNavigationOrigin(
request: Request,
origin: string | null,
): boolean {
if (!origin) return !isCrossSiteSubresource(request.headers)
return LOCAL_DESKTOP_ORIGINS.has(origin) || isLoopbackBrowserOrigin(origin)
}
@ -74,17 +95,25 @@ function isLocalTrustedRequest(
context: H5RequestContext,
origin: string | null,
): boolean {
if (context.localAccessTokenConfigured) {
return context.localAccessAuthorized === true
}
// The process token the desktop shell injects is the strongest credential we
// have: it identifies the app's own components (renderer, adapters, the CLI
// subprocess) regardless of how they reach us.
if (context.localAccessAuthorized === true) return true
// Its *absence* must not demote loopback, though. Plenty of legitimate local
// traffic can never carry that token — the OAuth success page the system
// browser opens, `/preview-fs` links, a `curl` against the local API. Gating
// loopback behind the token turned all of those into 401/403 (issue: "Missing
// H5 access token" on /api/haha-grok-oauth/success). Loopback stays trusted
// on its own; the Host, proxy-trace and Origin checks below are what keep a
// remote client from claiming it.
const clientAddress = context.clientAddress
if (!clientAddress) return false
if (hasProxyTraceHeaders(request.headers)) return false
return isLoopbackHost(clientAddress) &&
isLoopbackHost(url.hostname) &&
isLocalDesktopOrNavigationOrigin(origin)
isLocalDesktopOrNavigationOrigin(request, origin)
}
function isFilesystemCapabilityPath(pathname: string): boolean {
@ -168,6 +197,29 @@ function isH5ProtectedCapabilityPath(pathname: string): boolean {
pathname.startsWith('/sdk/')
}
export function isH5AccessControlPath(pathname: string): boolean {
return pathname.startsWith('/api/h5-access') &&
pathname !== '/api/h5-access/verify'
}
/**
* The control plane enabling remote access, minting and revoking H5 tokens
* is the one surface where loopback alone is deliberately not enough. Once the
* desktop shell has injected its process token, only components holding that
* token may change who can reach this machine; another browser or script on the
* same box must not be able to publish the user's sessions to the network.
* This is the boundary `harden desktop request isolation` set out to protect,
* and it is kept here instead of being applied to every local request.
*/
export function requiresLocalAccessCredential(
pathname: string,
context: H5RequestContext,
): boolean {
if (!context.localAccessTokenConfigured) return false
if (!isH5AccessControlPath(pathname)) return false
return context.localAccessAuthorized !== true
}
function isH5BrowserCapabilityPath(pathname: string): boolean {
return pathname.startsWith('/api/') ||
isFilesystemCapabilityPath(pathname) ||

View File

@ -27,7 +27,14 @@ import { enableConfigs } from '../utils/config.js'
import { diagnosticsService } from './services/diagnosticsService.js'
import { ensurePersistentStorageUpgraded } from './services/persistentStorageMigrations.js'
import { handleStaticH5Request } from './staticH5.js'
import { classifyH5Request, shouldBlockDisabledH5Access, shouldRequireH5Token } from './h5AccessPolicy.js'
import {
classifyH5Request,
isH5AccessControlPath,
requiresLocalAccessCredential,
shouldBlockDisabledH5Access,
shouldRequireH5Token,
type H5RequestContext,
} from './h5AccessPolicy.js'
import { H5AccessService } from './services/h5AccessService.js'
import { refreshDisconnectGraceMs } from './ws/disconnectGraceConfig.js'
import {
@ -170,14 +177,14 @@ function h5AccessDisabledResponse(): Response {
function isH5AccessControlRequest(
req: Request,
url: URL,
context: { clientAddress: string | null },
context: H5RequestContext,
): boolean {
if (!url.pathname.startsWith('/api/h5-access')) {
if (!isH5AccessControlPath(url.pathname)) {
return false
}
if (url.pathname === '/api/h5-access/verify') {
return false
if (requiresLocalAccessCredential(url.pathname, context)) {
return true
}
return classifyH5Request(req, url, context) !== 'local-trusted'