mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-31 16:33:34 +08:00
Merge H5 loopback access fix into local main
This commit is contained in:
commit
0dac038249
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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) ||
|
||||
|
||||
@ -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'
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user