mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Keep H5 control local while tightening remote CORS
Remote browser access now needs the H5 token once LAN mode is enabled, but changing that mode remains a local desktop control-plane operation even when deployment auth is explicitly enabled. CORS also no longer treats a non-local same-origin alias as trusted unless it is the configured H5 public origin or allowlisted origin. Constraint: H5 exposes desktop control surfaces on the LAN and must not let remote browsers enable their own access. Constraint: Desktop WebView, localhost WebUI, SDK websocket, and IM adapter loopback calls must remain tokenless. Rejected: Allow authenticated remote control-plane writes under SERVER_AUTH_REQUIRED | violates the local opt-in boundary for H5 exposure. Confidence: high Scope-risk: moderate Directive: Do not relax /api/h5-access mutating endpoints for browser origins without adding a reviewed desktop-only trust signal. Tested: bun test src/server/__tests__/h5-access-auth.test.ts Tested: bun test src/server/__tests__/h5-access-policy.test.ts src/server/middleware/cors.test.ts Tested: bun run check:server
This commit is contained in:
parent
e2f299dcb3
commit
4ef9729ad6
@ -18,6 +18,7 @@ let originalH5DistDir: string | undefined
|
||||
let originalClaudeAppRoot: string | undefined
|
||||
let originalServerAuthRequired: string | undefined
|
||||
let originalServerPort = 3456
|
||||
const PHONE_ORIGIN = 'https://phone.example'
|
||||
|
||||
async function waitForServer(url: string): Promise<void> {
|
||||
for (let attempt = 0; attempt < 50; attempt += 1) {
|
||||
@ -90,14 +91,21 @@ function makeUpgradeHeaders(origin?: string): HeadersInit {
|
||||
|
||||
async function enableH5Access(options: {
|
||||
allowedOrigins?: string[]
|
||||
publicBaseUrl?: string | null
|
||||
} = {}): Promise<string> {
|
||||
const service = new H5AccessService()
|
||||
if (options.allowedOrigins) {
|
||||
await service.updateSettings({ allowedOrigins: options.allowedOrigins })
|
||||
if (options.allowedOrigins || options.publicBaseUrl !== undefined) {
|
||||
await service.updateSettings({
|
||||
allowedOrigins: options.allowedOrigins,
|
||||
publicBaseUrl: options.publicBaseUrl,
|
||||
})
|
||||
}
|
||||
const { token } = await service.enable()
|
||||
if (options.allowedOrigins) {
|
||||
await service.updateSettings({ allowedOrigins: options.allowedOrigins })
|
||||
if (options.allowedOrigins || options.publicBaseUrl !== undefined) {
|
||||
await service.updateSettings({
|
||||
allowedOrigins: options.allowedOrigins,
|
||||
publicBaseUrl: options.publicBaseUrl,
|
||||
})
|
||||
}
|
||||
return token
|
||||
}
|
||||
@ -276,19 +284,78 @@ describe('remote H5 auth and CORS integration', () => {
|
||||
expect(response.status).toBe(403)
|
||||
})
|
||||
|
||||
test('allows same-origin H5 browser requests without a separate origin allowlist entry', async () => {
|
||||
const token = await enableH5Access()
|
||||
const requestBaseUrl = lanBaseUrl || baseUrl
|
||||
|
||||
const response = await fetch(`${requestBaseUrl}/api/status`, {
|
||||
test('blocks remote browsers from enabling H5 access before the local desktop opts in', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/h5-access/enable`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Origin: requestBaseUrl,
|
||||
Origin: PHONE_ORIGIN,
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
error: 'Forbidden',
|
||||
})
|
||||
})
|
||||
|
||||
test('blocks remote preflight requests to the local H5 access control plane', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/h5-access/enable`, {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
Origin: PHONE_ORIGIN,
|
||||
'Access-Control-Request-Method': 'POST',
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(response.headers.get('Access-Control-Allow-Origin')).toBeNull()
|
||||
})
|
||||
|
||||
test('blocks authenticated remote browsers from changing H5 access settings under explicit server auth', async () => {
|
||||
await restartRemoteServer({ authRequired: true })
|
||||
process.env.ANTHROPIC_API_KEY = 'test-server-key'
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/h5-access/enable`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Origin: PHONE_ORIGIN,
|
||||
Authorization: 'Bearer test-server-key',
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(response.headers.get('Access-Control-Allow-Origin')).toBeNull()
|
||||
})
|
||||
|
||||
test('allows local desktop H5 access settings under explicit server auth with a valid bearer', async () => {
|
||||
await restartRemoteServer({ authRequired: true })
|
||||
process.env.ANTHROPIC_API_KEY = 'test-server-key'
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/h5-access/enable`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer test-server-key',
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
await expect(response.json()).resolves.toHaveProperty('token')
|
||||
})
|
||||
|
||||
test('allows H5 browser requests from the configured public base URL origin', async () => {
|
||||
const token = await enableH5Access({
|
||||
publicBaseUrl: `${PHONE_ORIGIN}/h5`,
|
||||
})
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/status`, {
|
||||
headers: {
|
||||
Origin: PHONE_ORIGIN,
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('Access-Control-Allow-Origin')).toBe(requestBaseUrl)
|
||||
expect(response.headers.get('Access-Control-Allow-Origin')).toBe(PHONE_ORIGIN)
|
||||
})
|
||||
|
||||
test('allows configured CORS origins and includes Vary: Origin', async () => {
|
||||
@ -316,21 +383,21 @@ describe('remote H5 auth and CORS integration', () => {
|
||||
await expectWebSocketOpen(`${wsBaseUrl}/ws/h5-auth-test`)
|
||||
})
|
||||
|
||||
test('requires H5 token for LAN REST requests when H5 access is enabled', async () => {
|
||||
const token = await enableH5Access()
|
||||
test('requires H5 token for remote browser REST requests when H5 access is enabled', async () => {
|
||||
const token = await enableH5Access({
|
||||
allowedOrigins: [PHONE_ORIGIN],
|
||||
})
|
||||
|
||||
expect(lanBaseUrl).toBeTruthy()
|
||||
|
||||
const missingTokenResponse = await fetch(`${lanBaseUrl}/api/status`, {
|
||||
const missingTokenResponse = await fetch(`${baseUrl}/api/status`, {
|
||||
headers: {
|
||||
Origin: lanBaseUrl,
|
||||
Origin: PHONE_ORIGIN,
|
||||
},
|
||||
})
|
||||
expect(missingTokenResponse.status).toBe(401)
|
||||
|
||||
const validTokenResponse = await fetch(`${lanBaseUrl}/api/status`, {
|
||||
const validTokenResponse = await fetch(`${baseUrl}/api/status`, {
|
||||
headers: {
|
||||
Origin: lanBaseUrl,
|
||||
Origin: PHONE_ORIGIN,
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
@ -354,20 +421,37 @@ describe('remote H5 auth and CORS integration', () => {
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/adapters`)
|
||||
|
||||
expect(response.status).not.toBe(401)
|
||||
expect(response.status).toBe(200)
|
||||
await expect(response.json()).resolves.toEqual({})
|
||||
})
|
||||
|
||||
test('requires H5 token for LAN websocket requests when H5 access is enabled', async () => {
|
||||
const token = await enableH5Access()
|
||||
test('blocks adapter requests from non-local browser origins when H5 access is enabled', async () => {
|
||||
await enableH5Access()
|
||||
|
||||
expect(lanBaseUrl).toBeTruthy()
|
||||
const response = await fetch(`${baseUrl}/api/adapters`, {
|
||||
headers: {
|
||||
Origin: PHONE_ORIGIN,
|
||||
},
|
||||
})
|
||||
|
||||
const missingTokenResponse = await fetch(`${lanBaseUrl}/ws/h5-auth-test`, {
|
||||
headers: makeUpgradeHeaders(lanBaseUrl),
|
||||
expect(response.status).toBe(403)
|
||||
})
|
||||
|
||||
test('requires H5 token for remote browser websocket requests when H5 access is enabled', async () => {
|
||||
const token = await enableH5Access({
|
||||
allowedOrigins: [PHONE_ORIGIN],
|
||||
})
|
||||
|
||||
const missingTokenResponse = await fetch(`${baseUrl}/ws/h5-auth-test`, {
|
||||
headers: makeUpgradeHeaders(PHONE_ORIGIN),
|
||||
})
|
||||
expect(missingTokenResponse.status).toBe(401)
|
||||
|
||||
await expectWebSocketOpen(`${lanWsBaseUrl}/ws/h5-auth-test?token=${token}`)
|
||||
const validTokenResponse = await fetch(`${baseUrl}/ws/h5-auth-test?token=${token}`, {
|
||||
headers: makeUpgradeHeaders(PHONE_ORIGIN),
|
||||
})
|
||||
expect(validTokenResponse.status).toBe(400)
|
||||
await expect(validTokenResponse.text()).resolves.toBe('WebSocket upgrade failed')
|
||||
})
|
||||
|
||||
test('honors explicit auth opt-in for REST and websocket requests', async () => {
|
||||
|
||||
@ -20,7 +20,7 @@ import { enableConfigs } from '../utils/config.js'
|
||||
import { diagnosticsService } from './services/diagnosticsService.js'
|
||||
import { ensurePersistentStorageUpgraded } from './services/persistentStorageMigrations.js'
|
||||
import { handleStaticH5Request } from './staticH5.js'
|
||||
import { shouldRequireH5Token } from './h5AccessPolicy.js'
|
||||
import { classifyH5Request, shouldRequireH5Token } from './h5AccessPolicy.js'
|
||||
import { H5AccessService } from './services/h5AccessService.js'
|
||||
|
||||
function readArgValue(flag: string): string | undefined {
|
||||
@ -70,6 +70,40 @@ function corsRejectedResponse(cors: CorsResolution): Response {
|
||||
)
|
||||
}
|
||||
|
||||
function h5AccessControlRejectedResponse(): Response {
|
||||
return Response.json(
|
||||
{
|
||||
error: 'Forbidden',
|
||||
message: 'H5 access settings can only be changed from the local desktop app.',
|
||||
},
|
||||
{ status: 403 },
|
||||
)
|
||||
}
|
||||
|
||||
function isH5AccessControlRequest(req: Request, url: URL): boolean {
|
||||
if (!url.pathname.startsWith('/api/h5-access')) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (url.pathname === '/api/h5-access/verify') {
|
||||
return false
|
||||
}
|
||||
|
||||
return classifyH5Request(req, url) !== 'local-trusted'
|
||||
}
|
||||
|
||||
function originFromUrl(value: string | null): string | null {
|
||||
if (!value) {
|
||||
return null
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(value).origin
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function startServer(port = PORT, host = HOST) {
|
||||
enableConfigs()
|
||||
diagnosticsService.installConsoleCapture()
|
||||
@ -99,9 +133,12 @@ export function startServer(port = PORT, host = HOST) {
|
||||
const url = new URL(req.url)
|
||||
const origin = req.headers.get('Origin')
|
||||
const h5Settings = await h5AccessService.getSettings()
|
||||
const h5PublicOrigin = originFromUrl(h5Settings.publicBaseUrl)
|
||||
const cors = await resolveCors(origin, url.origin, {
|
||||
h5Enabled: h5Settings.enabled,
|
||||
isOriginAllowed: (candidateOrigin) => h5AccessService.isOriginAllowed(candidateOrigin),
|
||||
isOriginAllowed: async (candidateOrigin) =>
|
||||
candidateOrigin === h5PublicOrigin ||
|
||||
await h5AccessService.isOriginAllowed(candidateOrigin),
|
||||
})
|
||||
const authRequired = shouldRequireH5Token({
|
||||
request: req,
|
||||
@ -109,6 +146,11 @@ export function startServer(port = PORT, host = HOST) {
|
||||
h5Enabled: h5Settings.enabled,
|
||||
explicitAuthRequired: forceAuth,
|
||||
})
|
||||
const h5AccessControlBlocked = isH5AccessControlRequest(req, url)
|
||||
|
||||
if (h5AccessControlBlocked) {
|
||||
return h5AccessControlRejectedResponse()
|
||||
}
|
||||
|
||||
// Handle CORS preflight
|
||||
if (req.method === 'OPTIONS') {
|
||||
|
||||
@ -72,4 +72,26 @@ describe('resolveCors', () => {
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('does not trust non-local same-origin requests unless explicitly configured', async () => {
|
||||
const result = await resolveCors('http://192.168.0.20:3456', 'http://192.168.0.20:3456', {
|
||||
h5Enabled: true,
|
||||
isOriginAllowed: async () => false,
|
||||
})
|
||||
|
||||
expect(result.allowed).toBe(false)
|
||||
expect(result.rejected).toBe(true)
|
||||
expect(result.headers['Access-Control-Allow-Origin']).toBeUndefined()
|
||||
})
|
||||
|
||||
it('allows same-origin H5 browser requests only through the configured origin callback', async () => {
|
||||
const result = await resolveCors('http://192.168.0.20:3456', 'http://192.168.0.20:3456', {
|
||||
h5Enabled: true,
|
||||
isOriginAllowed: async (origin) => origin === 'http://192.168.0.20:3456',
|
||||
})
|
||||
|
||||
expect(result.allowed).toBe(true)
|
||||
expect(result.rejected).toBe(false)
|
||||
expect(result.headers['Access-Control-Allow-Origin']).toBe('http://192.168.0.20:3456')
|
||||
})
|
||||
})
|
||||
|
||||
@ -59,7 +59,7 @@ function isLocalOrigin(origin?: string | null): boolean {
|
||||
|
||||
export async function resolveCors(
|
||||
origin?: string | null,
|
||||
requestOrigin?: string | null,
|
||||
_requestOrigin?: string | null,
|
||||
options: CorsResolutionOptions = {},
|
||||
): Promise<CorsResolution> {
|
||||
if (!origin) {
|
||||
@ -70,7 +70,7 @@ export async function resolveCors(
|
||||
}
|
||||
}
|
||||
|
||||
if (!options.h5Enabled || isLocalOrigin(origin) || origin === requestOrigin) {
|
||||
if (!options.h5Enabled || isLocalOrigin(origin)) {
|
||||
return {
|
||||
allowed: true,
|
||||
rejected: false,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user