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
This commit is contained in:
程序员阿江(Relakkes) 2026-05-09 23:22:55 +08:00
parent 5f6547450f
commit e357c978fe
5 changed files with 771 additions and 0 deletions

View File

@ -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<string, unknown>
bearerToken?: string
} = {},
): Promise<Response> {
const url = new URL(pathname, 'http://localhost:3456')
const headers: Record<string, string> = {}
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',
},
})
})
})

View File

@ -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)
})
})

View File

@ -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<Record<string, unknown>> {
try {
const body = await req.json()
if (!body || typeof body !== 'object' || Array.isArray(body)) {
throw ApiError.badRequest('Invalid JSON body')
}
return body as Record<string, unknown>
} 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<Response> {
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)
}
}

View File

@ -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<Response> {
const path = url.pathname
@ -99,6 +100,9 @@ export async function handleApiRequest(req: Request, url: URL): Promise<Response
case 'doctor':
return handleDoctorApi(req, url, segments)
case 'h5-access':
return handleH5AccessApi(req, url, segments)
case 'filesystem':
return handleFilesystemRoute(url.pathname, url)

View File

@ -0,0 +1,344 @@
import * as fs from 'node:fs/promises'
import * as os from 'node:os'
import * as path from 'node:path'
import { createHash, randomBytes } from 'node:crypto'
import { ApiError } from '../middleware/errorHandler.js'
import { normalizeJsonObject, readRecoverableJsonFile } from './recoverableJsonFile.js'
import { ensurePersistentStorageUpgraded } from './persistentStorageMigrations.js'
export type H5AccessSettings = {
enabled: boolean
tokenPreview: string | null
allowedOrigins: string[]
publicBaseUrl: string | null
}
export type H5AccessEnableResult = {
settings: H5AccessSettings
token: string
}
type StoredH5AccessSettings = H5AccessSettings & {
tokenHash: string | null
}
const DEFAULT_STORED_SETTINGS: StoredH5AccessSettings = {
enabled: false,
tokenHash: null,
tokenPreview: null,
allowedOrigins: [],
publicBaseUrl: null,
}
function isRecord(value: unknown): value is Record<string, unknown> {
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<string, Promise<void>>()
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<T>(
filePath: string,
task: () => Promise<T>,
): Promise<T> {
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<Record<string, unknown>> {
await ensurePersistentStorageUpgraded()
return readRecoverableJsonFile({
filePath: this.getSettingsPath(),
label: 'cc-haha managed settings',
defaultValue: {},
normalize: normalizeJsonObject,
})
}
private async writeManagedSettings(settings: Record<string, unknown>): Promise<void> {
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<string, unknown>
h5Access: StoredH5AccessSettings
}> {
const managedSettings = await this.readManagedSettings()
return {
managedSettings,
h5Access: normalizeStoredSettings(managedSettings.h5Access),
}
}
private async saveStoredSettings(
managedSettings: Record<string, unknown>,
h5Access: StoredH5AccessSettings,
): Promise<void> {
await this.writeManagedSettings({
...managedSettings,
h5Access,
})
}
private async setToken(
managedSettings: Record<string, unknown>,
current: StoredH5AccessSettings,
): Promise<H5AccessEnableResult> {
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<H5AccessSettings> {
const { h5Access } = await this.readStoredSettings()
return toPublicSettings(h5Access)
}
async enable(): Promise<H5AccessEnableResult> {
return this.withWriteLock(this.getSettingsPath(), async () => {
const { managedSettings, h5Access } = await this.readStoredSettings()
return this.setToken(managedSettings, h5Access)
})
}
async disable(): Promise<H5AccessSettings> {
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<H5AccessEnableResult> {
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<H5AccessSettings> {
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<boolean> {
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<boolean> {
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
}
}
}