mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-16 13:03:31 +08:00
Protect remote H5 capability endpoints without breaking local desktop flows
Wire request-scoped H5 auth and CORS decisions through the server so LAN browser API, proxy, and websocket access require the H5 token while loopback desktop, Tauri WebView, local adapter, SDK, and static shell paths keep their existing bootstrap behavior. Constraint: Task 2 scope is limited to server auth/CORS wiring and the four requested files Rejected: Global auth toggle for all requests | would incorrectly block loopback desktop and bootstrap asset flows Directive: Keep static H5 shell and assets tokenless unless the browser-side bootstrap model changes Confidence: high Scope-risk: narrow Tested: bun test src/server/__tests__/h5-access-policy.test.ts src/server/middleware/cors.test.ts Tested: bun test src/server/__tests__/h5-access-policy.test.ts src/server/__tests__/h5-access-auth.test.ts src/server/middleware/cors.test.ts | h5-access-auth blocked by missing zod dependency in src/server/api/haha-oauth.ts Tested: LSP diagnostics clean for src/server/index.ts, src/server/middleware/cors.ts, src/server/__tests__/h5-access-auth.test.ts, src/server/middleware/cors.test.ts Not-tested: h5-access-auth runtime assertions because this checkout is missing zod for haha-oauth imports
This commit is contained in:
parent
a5cc7ff0c1
commit
e2f299dcb3
@ -9,6 +9,8 @@ import { ProviderService } from '../services/providerService.js'
|
||||
let server: ReturnType<typeof Bun.serve> | undefined
|
||||
let baseUrl = ''
|
||||
let wsBaseUrl = ''
|
||||
let lanBaseUrl = ''
|
||||
let lanWsBaseUrl = ''
|
||||
let tmpDir = ''
|
||||
let originalConfigDir: string | undefined
|
||||
let originalAnthropicApiKey: string | undefined
|
||||
@ -36,6 +38,26 @@ function randomPort(): number {
|
||||
return 18000 + Math.floor(Math.random() * 10000)
|
||||
}
|
||||
|
||||
function resolvePrivateLanBaseUrl(port: number): string | null {
|
||||
for (const entries of Object.values(os.networkInterfaces())) {
|
||||
for (const entry of entries ?? []) {
|
||||
if (entry.family !== 'IPv4' || entry.internal) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (
|
||||
entry.address.startsWith('10.') ||
|
||||
entry.address.startsWith('192.168.') ||
|
||||
/^172\.(1[6-9]|2\d|3[0-1])\./.test(entry.address)
|
||||
) {
|
||||
return `http://${entry.address}:${port}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
async function startRemoteServer(options: { authRequired?: boolean } = {}): Promise<void> {
|
||||
if (options.authRequired) {
|
||||
process.env.SERVER_AUTH_REQUIRED = '1'
|
||||
@ -47,6 +69,8 @@ async function startRemoteServer(options: { authRequired?: boolean } = {}): Prom
|
||||
server = startServer(port, '0.0.0.0')
|
||||
baseUrl = `http://127.0.0.1:${port}`
|
||||
wsBaseUrl = `ws://127.0.0.1:${port}`
|
||||
lanBaseUrl = resolvePrivateLanBaseUrl(port) ?? ''
|
||||
lanWsBaseUrl = lanBaseUrl.replace(/^http/, 'ws')
|
||||
await waitForServer(`${baseUrl}/health`)
|
||||
}
|
||||
|
||||
@ -236,8 +260,8 @@ describe('remote H5 auth and CORS integration', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('allows arbitrary CORS origins while default H5 access is open', async () => {
|
||||
const token = await enableH5Access({
|
||||
test('rejects arbitrary CORS origins when H5 access is enabled', async () => {
|
||||
await enableH5Access({
|
||||
allowedOrigins: ['https://allowed.example.com'],
|
||||
})
|
||||
|
||||
@ -245,27 +269,26 @@ describe('remote H5 auth and CORS integration', () => {
|
||||
method: 'OPTIONS',
|
||||
headers: {
|
||||
...makeUpgradeHeaders('https://blocked.example.com'),
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Access-Control-Request-Method': 'GET',
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.status).toBe(204)
|
||||
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('https://blocked.example.com')
|
||||
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(`${baseUrl}/api/status`, {
|
||||
const response = await fetch(`${requestBaseUrl}/api/status`, {
|
||||
headers: {
|
||||
Origin: baseUrl,
|
||||
Origin: requestBaseUrl,
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
expect(response.headers.get('Access-Control-Allow-Origin')).toBe(baseUrl)
|
||||
expect(response.headers.get('Access-Control-Allow-Origin')).toBe(requestBaseUrl)
|
||||
})
|
||||
|
||||
test('allows configured CORS origins and includes Vary: Origin', async () => {
|
||||
@ -293,6 +316,60 @@ 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()
|
||||
|
||||
expect(lanBaseUrl).toBeTruthy()
|
||||
|
||||
const missingTokenResponse = await fetch(`${lanBaseUrl}/api/status`, {
|
||||
headers: {
|
||||
Origin: lanBaseUrl,
|
||||
},
|
||||
})
|
||||
expect(missingTokenResponse.status).toBe(401)
|
||||
|
||||
const validTokenResponse = await fetch(`${lanBaseUrl}/api/status`, {
|
||||
headers: {
|
||||
Origin: lanBaseUrl,
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
expect(validTokenResponse.status).toBe(200)
|
||||
})
|
||||
|
||||
test('keeps Tauri loopback REST requests tokenless when H5 access is enabled', async () => {
|
||||
await enableH5Access()
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/status`, {
|
||||
headers: {
|
||||
Origin: 'http://tauri.localhost',
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.status).toBe(200)
|
||||
})
|
||||
|
||||
test('keeps local loopback adapter requests tokenless when H5 access is enabled', async () => {
|
||||
await enableH5Access()
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/adapters`)
|
||||
|
||||
expect(response.status).not.toBe(401)
|
||||
})
|
||||
|
||||
test('requires H5 token for LAN websocket requests when H5 access is enabled', async () => {
|
||||
const token = await enableH5Access()
|
||||
|
||||
expect(lanBaseUrl).toBeTruthy()
|
||||
|
||||
const missingTokenResponse = await fetch(`${lanBaseUrl}/ws/h5-auth-test`, {
|
||||
headers: makeUpgradeHeaders(lanBaseUrl),
|
||||
})
|
||||
expect(missingTokenResponse.status).toBe(401)
|
||||
|
||||
await expectWebSocketOpen(`${lanWsBaseUrl}/ws/h5-auth-test?token=${token}`)
|
||||
})
|
||||
|
||||
test('honors explicit auth opt-in for REST and websocket requests', async () => {
|
||||
await restartRemoteServer({ authRequired: true })
|
||||
const token = await enableH5Access()
|
||||
|
||||
@ -20,6 +20,8 @@ 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 { H5AccessService } from './services/h5AccessService.js'
|
||||
|
||||
function readArgValue(flag: string): string | undefined {
|
||||
const args = process.argv.slice(2)
|
||||
@ -79,13 +81,13 @@ export function startServer(port = PORT, host = HOST) {
|
||||
: host
|
||||
|
||||
/**
|
||||
* Auth is opt-in only. H5/LAN access is currently left open by default so
|
||||
* desktop and browser clients do not get blocked by missing token state.
|
||||
* Explicit opt-in remains available for private deployments.
|
||||
* Explicit deployment auth remains a stronger override than H5-scoped
|
||||
* request gating.
|
||||
*/
|
||||
const forceAuth =
|
||||
SERVER_OPTIONS.authRequired ||
|
||||
process.env.SERVER_AUTH_REQUIRED === '1'
|
||||
const h5AccessService = new H5AccessService()
|
||||
|
||||
const server = Bun.serve<WebSocketData>({
|
||||
port,
|
||||
@ -96,8 +98,17 @@ export function startServer(port = PORT, host = HOST) {
|
||||
await ensurePersistentStorageUpgraded()
|
||||
const url = new URL(req.url)
|
||||
const origin = req.headers.get('Origin')
|
||||
const cors = await resolveCors(origin, url.origin)
|
||||
const authRequired = forceAuth
|
||||
const h5Settings = await h5AccessService.getSettings()
|
||||
const cors = await resolveCors(origin, url.origin, {
|
||||
h5Enabled: h5Settings.enabled,
|
||||
isOriginAllowed: (candidateOrigin) => h5AccessService.isOriginAllowed(candidateOrigin),
|
||||
})
|
||||
const authRequired = shouldRequireH5Token({
|
||||
request: req,
|
||||
url,
|
||||
h5Enabled: h5Settings.enabled,
|
||||
explicitAuthRequired: forceAuth,
|
||||
})
|
||||
|
||||
// Handle CORS preflight
|
||||
if (req.method === 'OPTIONS') {
|
||||
@ -242,6 +253,8 @@ export function startServer(port = PORT, host = HOST) {
|
||||
)
|
||||
}
|
||||
|
||||
// Static H5 shell/assets are non-secret bootstrap content and must load
|
||||
// before the browser can read the QR token; API/proxy/ws stay protected above.
|
||||
const staticResponse = await handleStaticH5Request(req, url)
|
||||
if (staticResponse) {
|
||||
return staticResponse
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { describe, expect, it } from 'bun:test'
|
||||
import { corsHeaders } from './cors'
|
||||
import { corsHeaders, resolveCors } from './cors'
|
||||
|
||||
describe('corsHeaders', () => {
|
||||
it('allows localhost browser origins', () => {
|
||||
@ -18,3 +18,58 @@ describe('corsHeaders', () => {
|
||||
expect(corsHeaders(null)['Access-Control-Allow-Origin']).toBe('http://localhost:3000')
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveCors', () => {
|
||||
it('allows arbitrary origins when H5 token mode is inactive', async () => {
|
||||
const result = await resolveCors('https://example.com', 'http://127.0.0.1:3456')
|
||||
|
||||
expect(result).toEqual({
|
||||
allowed: true,
|
||||
rejected: false,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': 'https://example.com',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
Vary: 'Origin',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects blocked browser origins when H5 token mode is active', async () => {
|
||||
const result = await resolveCors('https://blocked.example.com', 'http://192.168.0.20:3456', {
|
||||
h5Enabled: true,
|
||||
isOriginAllowed: async () => false,
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
allowed: false,
|
||||
rejected: true,
|
||||
headers: {
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
Vary: 'Origin',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it('allows configured origins when H5 token mode is active', async () => {
|
||||
const result = await resolveCors('https://allowed.example.com', 'http://192.168.0.20:3456', {
|
||||
h5Enabled: true,
|
||||
isOriginAllowed: async (origin) => origin === 'https://allowed.example.com',
|
||||
})
|
||||
|
||||
expect(result).toEqual({
|
||||
allowed: true,
|
||||
rejected: false,
|
||||
headers: {
|
||||
'Access-Control-Allow-Origin': 'https://allowed.example.com',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
'Access-Control-Max-Age': '86400',
|
||||
Vary: 'Origin',
|
||||
},
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -2,6 +2,8 @@
|
||||
* CORS middleware for desktop and temporary open H5 access.
|
||||
*/
|
||||
|
||||
import { isLoopbackHost } from '../h5AccessPolicy.js'
|
||||
|
||||
export function corsHeaders(origin?: string | null): Record<string, string> {
|
||||
const allowedOrigin = origin || 'http://localhost:3000'
|
||||
return {
|
||||
@ -28,9 +30,37 @@ export type CorsResolution = {
|
||||
headers: Record<string, string>
|
||||
}
|
||||
|
||||
export type CorsResolutionOptions = {
|
||||
h5Enabled?: boolean
|
||||
isOriginAllowed?: (origin: string) => Promise<boolean>
|
||||
}
|
||||
|
||||
const LOCAL_ORIGINS = new Set([
|
||||
'http://tauri.localhost',
|
||||
'https://tauri.localhost',
|
||||
'tauri://localhost',
|
||||
])
|
||||
|
||||
function isLocalOrigin(origin?: string | null): boolean {
|
||||
if (!origin) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (LOCAL_ORIGINS.has(origin)) {
|
||||
return true
|
||||
}
|
||||
|
||||
try {
|
||||
return isLoopbackHost(new URL(origin).hostname)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export async function resolveCors(
|
||||
origin?: string | null,
|
||||
_requestOrigin?: string | null,
|
||||
requestOrigin?: string | null,
|
||||
options: CorsResolutionOptions = {},
|
||||
): Promise<CorsResolution> {
|
||||
if (!origin) {
|
||||
return {
|
||||
@ -40,12 +70,31 @@ export async function resolveCors(
|
||||
}
|
||||
}
|
||||
|
||||
if (!options.h5Enabled || isLocalOrigin(origin) || origin === requestOrigin) {
|
||||
return {
|
||||
allowed: true,
|
||||
rejected: false,
|
||||
headers: {
|
||||
...baseCorsHeaders(),
|
||||
'Access-Control-Allow-Origin': origin,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
if (options.isOriginAllowed && await options.isOriginAllowed(origin)) {
|
||||
return {
|
||||
allowed: true,
|
||||
rejected: false,
|
||||
headers: {
|
||||
...baseCorsHeaders(),
|
||||
'Access-Control-Allow-Origin': origin,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
rejected: false,
|
||||
headers: {
|
||||
...baseCorsHeaders(),
|
||||
'Access-Control-Allow-Origin': origin,
|
||||
},
|
||||
allowed: false,
|
||||
rejected: true,
|
||||
headers: baseCorsHeaders(),
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user