From e357c978fec6ccb2a4cb97e34d65e58067b83ec4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Sat, 9 May 2026 23:22:55 +0800 Subject: [PATCH] Add a durable H5 access control surface for remote browser setup Persist H5 access state in cc-haha managed settings, expose a narrow server API for enable/disable/regenerate/verify flows, and keep token responses sanitized so the raw secret is only returned at generation time. Constraint: H5 config must live in ~/.claude/cc-haha/settings.json and preserve unknown fields Rejected: Store raw token for later display | violates hash-only persistence requirement Confidence: high Scope-risk: narrow Directive: Do not move H5 settings into ~/.claude/settings.json or expose tokenHash through the API Tested: bun test src/server/__tests__/h5-access-service.test.ts src/server/__tests__/h5-access-api.test.ts Not-tested: Full repo typecheck via tsc is blocked locally by missing bun-types and a TS 6 baseUrl deprecation in the current config --- src/server/__tests__/h5-access-api.test.ts | 170 +++++++++ .../__tests__/h5-access-service.test.ts | 157 ++++++++ src/server/api/h5-access.ts | 96 +++++ src/server/router.ts | 4 + src/server/services/h5AccessService.ts | 344 ++++++++++++++++++ 5 files changed, 771 insertions(+) create mode 100644 src/server/__tests__/h5-access-api.test.ts create mode 100644 src/server/__tests__/h5-access-service.test.ts create mode 100644 src/server/api/h5-access.ts create mode 100644 src/server/services/h5AccessService.ts diff --git a/src/server/__tests__/h5-access-api.test.ts b/src/server/__tests__/h5-access-api.test.ts new file mode 100644 index 00000000..681255fb --- /dev/null +++ b/src/server/__tests__/h5-access-api.test.ts @@ -0,0 +1,170 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import * as fs from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' +import { handleApiRequest } from '../router.js' + +let tmpDir: string +let originalConfigDir: string | undefined + +async function api( + method: string, + pathname: string, + options: { + body?: Record + bearerToken?: string + } = {}, +): Promise { + const url = new URL(pathname, 'http://localhost:3456') + const headers: Record = {} + + if (options.body !== undefined) { + headers['Content-Type'] = 'application/json' + } + if (options.bearerToken) { + headers.Authorization = `Bearer ${options.bearerToken}` + } + + return handleApiRequest( + new Request(url.toString(), { + method, + headers, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + }), + url, + ) +} + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'h5-access-api-test-')) + originalConfigDir = process.env.CLAUDE_CONFIG_DIR + process.env.CLAUDE_CONFIG_DIR = tmpDir +}) + +afterEach(async () => { + if (originalConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR + else process.env.CLAUDE_CONFIG_DIR = originalConfigDir + await fs.rm(tmpDir, { recursive: true, force: true }) +}) + +describe('/api/h5-access', () => { + test('GET returns sanitized disabled status by default', async () => { + const response = await api('GET', '/api/h5-access') + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + settings: { + enabled: false, + tokenPreview: null, + allowedOrigins: [], + publicBaseUrl: null, + }, + }) + }) + + test('enable returns raw token once and status stays sanitized', async () => { + const enableResponse = await api('POST', '/api/h5-access/enable') + expect(enableResponse.status).toBe(200) + + const enablePayload = await enableResponse.json() as { + settings: { + enabled: boolean + tokenPreview: string + } + token: string + } + + expect(enablePayload.settings.enabled).toBe(true) + expect(enablePayload.token).toMatch(/^h5_/) + + const statusResponse = await api('GET', '/api/h5-access') + expect(statusResponse.status).toBe(200) + const statusPayload = await statusResponse.json() as { + settings: { + enabled: boolean + tokenPreview: string | null + } + token?: string + } + + expect(statusPayload.settings.enabled).toBe(true) + expect(statusPayload.settings.tokenPreview).toBe(enablePayload.settings.tokenPreview) + expect(statusPayload.token).toBeUndefined() + }) + + test('verify accepts a good bearer token and rejects missing or bad tokens', async () => { + const enableResponse = await api('POST', '/api/h5-access/enable') + const enablePayload = await enableResponse.json() as { token: string } + + expect( + await api('POST', '/api/h5-access/verify', { bearerToken: enablePayload.token }), + ).toMatchObject({ status: 200 }) + expect(await api('POST', '/api/h5-access/verify')).toMatchObject({ status: 401 }) + expect( + await api('POST', '/api/h5-access/verify', { bearerToken: 'bad-token' }), + ).toMatchObject({ status: 401 }) + }) + + test('regenerate returns a new token and invalidates the previous one', async () => { + const enableResponse = await api('POST', '/api/h5-access/enable') + const enablePayload = await enableResponse.json() as { token: string } + + const regenerateResponse = await api('POST', '/api/h5-access/regenerate') + expect(regenerateResponse.status).toBe(200) + const regeneratePayload = await regenerateResponse.json() as { + settings: { + enabled: boolean + } + token: string + } + + expect(regeneratePayload.settings.enabled).toBe(true) + expect(regeneratePayload.token).toMatch(/^h5_/) + expect(regeneratePayload.token).not.toBe(enablePayload.token) + expect( + await api('POST', '/api/h5-access/verify', { bearerToken: enablePayload.token }), + ).toMatchObject({ status: 401 }) + expect( + await api('POST', '/api/h5-access/verify', { bearerToken: regeneratePayload.token }), + ).toMatchObject({ status: 200 }) + }) + + test('disable clears access and causes the old token to fail verification', async () => { + const enableResponse = await api('POST', '/api/h5-access/enable') + const enablePayload = await enableResponse.json() as { token: string } + + const disableResponse = await api('POST', '/api/h5-access/disable') + expect(disableResponse.status).toBe(200) + await expect(disableResponse.json()).resolves.toEqual({ + settings: { + enabled: false, + tokenPreview: null, + allowedOrigins: [], + publicBaseUrl: null, + }, + }) + + expect( + await api('POST', '/api/h5-access/verify', { bearerToken: enablePayload.token }), + ).toMatchObject({ status: 401 }) + }) + + test('PUT updates sanitized settings', async () => { + const response = await api('PUT', '/api/h5-access', { + body: { + allowedOrigins: ['https://example.com/path'], + publicBaseUrl: 'https://public.example.com/app/', + }, + }) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ + settings: { + enabled: false, + tokenPreview: null, + allowedOrigins: ['https://example.com'], + publicBaseUrl: 'https://public.example.com/app', + }, + }) + }) +}) diff --git a/src/server/__tests__/h5-access-service.test.ts b/src/server/__tests__/h5-access-service.test.ts new file mode 100644 index 00000000..c5867860 --- /dev/null +++ b/src/server/__tests__/h5-access-service.test.ts @@ -0,0 +1,157 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import * as fs from 'node:fs/promises' +import * as os from 'node:os' +import * as path from 'node:path' +import { H5AccessService } from '../services/h5AccessService.js' + +let tmpDir: string +let originalConfigDir: string | undefined + +function getManagedSettingsPath(): string { + return path.join(tmpDir, 'cc-haha', 'settings.json') +} + +beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'h5-access-service-test-')) + originalConfigDir = process.env.CLAUDE_CONFIG_DIR + process.env.CLAUDE_CONFIG_DIR = tmpDir +}) + +afterEach(async () => { + if (originalConfigDir === undefined) delete process.env.CLAUDE_CONFIG_DIR + else process.env.CLAUDE_CONFIG_DIR = originalConfigDir + await fs.rm(tmpDir, { recursive: true, force: true }) +}) + +describe('H5AccessService', () => { + test('defaults to disabled state with sanitized settings', async () => { + const service = new H5AccessService() + + await expect(service.getSettings()).resolves.toEqual({ + enabled: false, + tokenPreview: null, + allowedOrigins: [], + publicBaseUrl: null, + }) + + await expect(service.validateToken('missing-token')).resolves.toBe(false) + }) + + test('enable generates a token and persists only hash plus preview', async () => { + const service = new H5AccessService() + + const result = await service.enable() + const raw = await fs.readFile(getManagedSettingsPath(), 'utf-8') + const saved = JSON.parse(raw) as { + h5Access: { + enabled: boolean + tokenHash: string + tokenPreview: string + } + } + + expect(result.token).toMatch(/^h5_[A-Za-z0-9_-]{43}$/) + expect(result.settings).toEqual({ + enabled: true, + tokenPreview: saved.h5Access.tokenPreview, + allowedOrigins: [], + publicBaseUrl: null, + }) + expect(saved.h5Access.enabled).toBe(true) + expect(saved.h5Access.tokenHash).toHaveLength(64) + expect(saved.h5Access.tokenPreview).toBe( + `${result.token.slice(0, 7)}...${result.token.slice(-4)}`, + ) + expect(raw).not.toContain(result.token) + expect(await service.validateToken(result.token)).toBe(true) + }) + + test('regenerateToken invalidates the previous token', async () => { + const service = new H5AccessService() + + const first = await service.enable() + const second = await service.regenerateToken() + + expect(second.token).toMatch(/^h5_/) + expect(second.token).not.toBe(first.token) + expect(await service.validateToken(first.token)).toBe(false) + expect(await service.validateToken(second.token)).toBe(true) + }) + + test('preserves unknown managed settings fields when updating h5Access', async () => { + await fs.mkdir(path.dirname(getManagedSettingsPath()), { recursive: true }) + await fs.writeFile( + getManagedSettingsPath(), + JSON.stringify( + { + env: { + ANTHROPIC_MODEL: 'keep-me', + }, + futureField: { + keep: true, + }, + }, + null, + 2, + ), + 'utf-8', + ) + + const service = new H5AccessService() + await service.enable() + + const saved = JSON.parse(await fs.readFile(getManagedSettingsPath(), 'utf-8')) as { + env: { + ANTHROPIC_MODEL: string + } + futureField: { + keep: boolean + } + h5Access: unknown + } + + expect(saved.env.ANTHROPIC_MODEL).toBe('keep-me') + expect(saved.futureField).toEqual({ keep: true }) + expect(saved.h5Access).toBeDefined() + }) + + test('updateSettings normalizes origins and rejects invalid ones', async () => { + const service = new H5AccessService() + + await expect( + service.updateSettings({ + allowedOrigins: ['https://example.com/path', 'http://localhost:3000/foo'], + publicBaseUrl: 'https://public.example.com/app/', + }), + ).resolves.toEqual({ + enabled: false, + tokenPreview: null, + allowedOrigins: ['https://example.com', 'http://localhost:3000'], + publicBaseUrl: 'https://public.example.com/app', + }) + + await expect( + service.updateSettings({ + allowedOrigins: ['https://*.example.com'], + }), + ).rejects.toMatchObject({ + statusCode: 400, + }) + }) + + test('isOriginAllowed requires enabled state and matches normalized origins', async () => { + const service = new H5AccessService() + + await service.updateSettings({ + allowedOrigins: ['https://example.com/path'], + }) + + await expect(service.isOriginAllowed('https://example.com')).resolves.toBe(false) + + await service.enable() + + await expect(service.isOriginAllowed('https://example.com')).resolves.toBe(true) + await expect(service.isOriginAllowed('https://other.example.com')).resolves.toBe(false) + await expect(service.isOriginAllowed('notaurl')).resolves.toBe(false) + }) +}) diff --git a/src/server/api/h5-access.ts b/src/server/api/h5-access.ts new file mode 100644 index 00000000..8311b916 --- /dev/null +++ b/src/server/api/h5-access.ts @@ -0,0 +1,96 @@ +import { ApiError, errorResponse } from '../middleware/errorHandler.js' +import { H5AccessService } from '../services/h5AccessService.js' + +const h5AccessService = new H5AccessService() + +function methodNotAllowed(method: string, route: string): ApiError { + return new ApiError(405, `Method ${method} not allowed on ${route}`, 'METHOD_NOT_ALLOWED') +} + +function getBearerToken(req: Request): string | null { + const authorization = req.headers.get('authorization') + if (!authorization) { + return null + } + + const match = authorization.match(/^Bearer\s+(.+)$/i) + return match?.[1] ?? null +} + +async function parseJsonBody(req: Request): Promise> { + try { + const body = await req.json() + if (!body || typeof body !== 'object' || Array.isArray(body)) { + throw ApiError.badRequest('Invalid JSON body') + } + return body as Record + } catch (error) { + if (error instanceof ApiError) { + throw error + } + throw ApiError.badRequest('Invalid JSON body') + } +} + +export async function handleH5AccessApi( + req: Request, + _url: URL, + segments: string[], +): Promise { + try { + const sub = segments[2] + + switch (sub) { + case undefined: + if (req.method === 'GET') { + return Response.json({ settings: await h5AccessService.getSettings() }) + } + if (req.method === 'PUT') { + const body = await parseJsonBody(req) + const settings = await h5AccessService.updateSettings({ + allowedOrigins: body.allowedOrigins as string[] | undefined, + publicBaseUrl: body.publicBaseUrl as string | null | undefined, + }) + return Response.json({ settings }) + } + throw methodNotAllowed(req.method, '/api/h5-access') + + case 'enable': + if (req.method !== 'POST') { + throw methodNotAllowed(req.method, '/api/h5-access/enable') + } + return Response.json(await h5AccessService.enable()) + + case 'disable': + if (req.method !== 'POST') { + throw methodNotAllowed(req.method, '/api/h5-access/disable') + } + return Response.json({ settings: await h5AccessService.disable() }) + + case 'regenerate': + if (req.method !== 'POST') { + throw methodNotAllowed(req.method, '/api/h5-access/regenerate') + } + return Response.json(await h5AccessService.regenerateToken()) + + case 'verify': { + if (req.method !== 'POST') { + throw methodNotAllowed(req.method, '/api/h5-access/verify') + } + + const token = getBearerToken(req) + const isValid = await h5AccessService.validateToken(token) + if (!isValid) { + throw new ApiError(401, 'Invalid or missing H5 access token', 'UNAUTHORIZED') + } + + return Response.json({ ok: true }) + } + + default: + throw ApiError.notFound(`Unknown h5-access endpoint: ${sub}`) + } + } catch (error) { + return errorResponse(error) + } +} diff --git a/src/server/router.ts b/src/server/router.ts index a66e778a..614a8880 100644 --- a/src/server/router.ts +++ b/src/server/router.ts @@ -22,6 +22,7 @@ import { handleHahaOpenAIOAuthApi } from './api/haha-openai-oauth.js' import { handleMcpApi } from './api/mcp.js' import { handleDiagnosticsApi } from './api/diagnostics.js' import { handleDoctorApi } from './api/doctor.js' +import { handleH5AccessApi } from './api/h5-access.js' export async function handleApiRequest(req: Request, url: URL): Promise { const path = url.pathname @@ -99,6 +100,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise { + return !!value && typeof value === 'object' && !Array.isArray(value) +} + +function toPublicSettings(settings: StoredH5AccessSettings): H5AccessSettings { + return { + enabled: settings.enabled, + tokenPreview: settings.tokenPreview, + allowedOrigins: settings.allowedOrigins, + publicBaseUrl: settings.publicBaseUrl, + } +} + +function hashToken(token: string): string { + return createHash('sha256').update(token).digest('hex') +} + +function createToken(): string { + return `h5_${randomBytes(32).toString('base64url')}` +} + +function createTokenPreview(token: string): string { + return `${token.slice(0, 7)}...${token.slice(-4)}` +} + +function normalizeOriginInput(origin: string, fieldName = 'allowedOrigins'): string { + if (origin.includes('*')) { + throw ApiError.badRequest(`${fieldName} must not contain wildcard origins`) + } + + let parsed: URL + try { + parsed = new URL(origin) + } catch { + throw ApiError.badRequest(`Invalid origin: ${origin}`) + } + + if (!['http:', 'https:'].includes(parsed.protocol)) { + throw ApiError.badRequest(`Invalid origin protocol: ${origin}`) + } + + if (parsed.username || parsed.password) { + throw ApiError.badRequest(`Invalid origin credentials: ${origin}`) + } + + return parsed.origin +} + +function normalizeAllowedOrigins(input: unknown): string[] { + if (!Array.isArray(input)) { + throw ApiError.badRequest('allowedOrigins must be an array of strings') + } + + const normalized = input.map((origin) => { + if (typeof origin !== 'string') { + throw ApiError.badRequest('allowedOrigins must be an array of strings') + } + return normalizeOriginInput(origin) + }) + + return [...new Set(normalized)] +} + +function normalizePublicBaseUrl(input: unknown): string | null { + if (input === null || input === undefined || input === '') { + return null + } + + if (typeof input !== 'string') { + throw ApiError.badRequest('publicBaseUrl must be a string or null') + } + + let parsed: URL + try { + parsed = new URL(input) + } catch { + throw ApiError.badRequest(`Invalid publicBaseUrl: ${input}`) + } + + if (!['http:', 'https:'].includes(parsed.protocol)) { + throw ApiError.badRequest(`Invalid publicBaseUrl protocol: ${input}`) + } + + if (parsed.username || parsed.password) { + throw ApiError.badRequest(`Invalid publicBaseUrl credentials: ${input}`) + } + + const normalizedPath = parsed.pathname.replace(/\/+$/, '') + return `${parsed.origin}${normalizedPath === '/' ? '' : normalizedPath}` +} + +function normalizeStoredSettings(value: unknown): StoredH5AccessSettings { + if (!isRecord(value)) { + return { ...DEFAULT_STORED_SETTINGS } + } + + const allowedOrigins = Array.isArray(value.allowedOrigins) + ? [...new Set(value.allowedOrigins.flatMap((origin) => { + if (typeof origin !== 'string') { + return [] + } + + try { + return [normalizeOriginInput(origin)] + } catch { + return [] + } + }))] + : [] + + let publicBaseUrl: string | null = null + if (typeof value.publicBaseUrl === 'string') { + try { + publicBaseUrl = normalizePublicBaseUrl(value.publicBaseUrl) + } catch { + publicBaseUrl = null + } + } + + return { + enabled: value.enabled === true, + tokenHash: typeof value.tokenHash === 'string' ? value.tokenHash : null, + tokenPreview: typeof value.tokenPreview === 'string' ? value.tokenPreview : null, + allowedOrigins, + publicBaseUrl, + } +} + +export class H5AccessService { + private static writeLocks = new Map>() + + private getConfigDir(): string { + return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude') + } + + private getSettingsPath(): string { + return path.join(this.getConfigDir(), 'cc-haha', 'settings.json') + } + + private async withWriteLock( + filePath: string, + task: () => Promise, + ): Promise { + const previousWrite = H5AccessService.writeLocks.get(filePath) ?? Promise.resolve() + const nextWrite = previousWrite.catch(() => {}).then(task) + const trackedWrite = nextWrite.then(() => {}, () => {}) + + H5AccessService.writeLocks.set(filePath, trackedWrite) + + try { + return await nextWrite + } finally { + if (H5AccessService.writeLocks.get(filePath) === trackedWrite) { + H5AccessService.writeLocks.delete(filePath) + } + } + } + + private async readManagedSettings(): Promise> { + await ensurePersistentStorageUpgraded() + return readRecoverableJsonFile({ + filePath: this.getSettingsPath(), + label: 'cc-haha managed settings', + defaultValue: {}, + normalize: normalizeJsonObject, + }) + } + + private async writeManagedSettings(settings: Record): Promise { + const filePath = this.getSettingsPath() + const dir = path.dirname(filePath) + const contents = JSON.stringify(settings, null, 2) + '\n' + const tmpFile = `${filePath}.tmp.${process.pid}.${Date.now()}.${randomBytes(6).toString('hex')}` + + await fs.mkdir(dir, { recursive: true }) + + try { + await fs.writeFile(tmpFile, contents, 'utf-8') + await fs.rename(tmpFile, filePath) + } catch (error) { + await fs.unlink(tmpFile).catch(() => {}) + throw ApiError.internal(`Failed to write settings.json: ${error}`) + } + } + + private async readStoredSettings(): Promise<{ + managedSettings: Record + h5Access: StoredH5AccessSettings + }> { + const managedSettings = await this.readManagedSettings() + return { + managedSettings, + h5Access: normalizeStoredSettings(managedSettings.h5Access), + } + } + + private async saveStoredSettings( + managedSettings: Record, + h5Access: StoredH5AccessSettings, + ): Promise { + await this.writeManagedSettings({ + ...managedSettings, + h5Access, + }) + } + + private async setToken( + managedSettings: Record, + current: StoredH5AccessSettings, + ): Promise { + const token = createToken() + const nextSettings: StoredH5AccessSettings = { + ...current, + enabled: true, + tokenHash: hashToken(token), + tokenPreview: createTokenPreview(token), + } + + await this.saveStoredSettings(managedSettings, nextSettings) + + return { + settings: toPublicSettings(nextSettings), + token, + } + } + + async getSettings(): Promise { + const { h5Access } = await this.readStoredSettings() + return toPublicSettings(h5Access) + } + + async enable(): Promise { + return this.withWriteLock(this.getSettingsPath(), async () => { + const { managedSettings, h5Access } = await this.readStoredSettings() + return this.setToken(managedSettings, h5Access) + }) + } + + async disable(): Promise { + return this.withWriteLock(this.getSettingsPath(), async () => { + const { managedSettings, h5Access } = await this.readStoredSettings() + const nextSettings: StoredH5AccessSettings = { + ...h5Access, + enabled: false, + tokenHash: null, + tokenPreview: null, + } + + await this.saveStoredSettings(managedSettings, nextSettings) + return toPublicSettings(nextSettings) + }) + } + + async regenerateToken(): Promise { + return this.withWriteLock(this.getSettingsPath(), async () => { + const { managedSettings, h5Access } = await this.readStoredSettings() + return this.setToken(managedSettings, h5Access) + }) + } + + async updateSettings(input: { + allowedOrigins?: string[] + publicBaseUrl?: string | null + }): Promise { + return this.withWriteLock(this.getSettingsPath(), async () => { + const { managedSettings, h5Access } = await this.readStoredSettings() + const nextSettings: StoredH5AccessSettings = { + ...h5Access, + allowedOrigins: input.allowedOrigins === undefined + ? h5Access.allowedOrigins + : normalizeAllowedOrigins(input.allowedOrigins), + publicBaseUrl: input.publicBaseUrl === undefined + ? h5Access.publicBaseUrl + : normalizePublicBaseUrl(input.publicBaseUrl), + } + + await this.saveStoredSettings(managedSettings, nextSettings) + return toPublicSettings(nextSettings) + }) + } + + async validateToken(token: string | null | undefined): Promise { + if (!token) { + return false + } + + const { h5Access } = await this.readStoredSettings() + if (!h5Access.enabled || !h5Access.tokenHash) { + return false + } + + return hashToken(token) === h5Access.tokenHash + } + + async isOriginAllowed(origin: string | null | undefined): Promise { + if (!origin) { + return false + } + + const { h5Access } = await this.readStoredSettings() + if (!h5Access.enabled) { + return false + } + + try { + const normalizedOrigin = normalizeOriginInput(origin, 'origin') + return h5Access.allowedOrigins.includes(normalizedOrigin) + } catch { + return false + } + } +}