From 1597685d37d7a2d4340e034a0667c5ac1b0f2348 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: Mon, 11 May 2026 17:59:03 +0800 Subject: [PATCH] Keep H5 access open while token auth is paused The H5 token gate still caused chat startup and runtime failures after the desktop startup hotfix, so the default server path now stays open for browser and LAN access. Explicit auth remains available through SERVER_AUTH_REQUIRED=1 or --auth-required for deployments that intentionally need it. Constraint: Current H5 token state is blocking active users from normal chat usage Rejected: Keep automatic remote-host auth with broader client-side token handling | still leaves existing upgraded clients vulnerable to stale or missing token state Confidence: high Scope-risk: moderate Directive: Do not re-enable default H5 token auth without a migration and end-to-end browser chat verification Tested: bun test src/server/__tests__/h5-access-auth.test.ts src/server/middleware/cors.test.ts Tested: cd desktop && bun run test src/lib/desktopRuntime.test.ts src/__tests__/generalSettings.test.tsx Tested: bun run check:server Tested: bun run check:desktop --- .../src/__tests__/generalSettings.test.tsx | 130 ++------------- desktop/src/i18n/locales/en.ts | 5 +- desktop/src/i18n/locales/zh.ts | 5 +- desktop/src/lib/desktopRuntime.test.ts | 27 +++- desktop/src/lib/desktopRuntime.ts | 43 +---- desktop/src/pages/Settings.tsx | 152 +----------------- src/server/__tests__/h5-access-auth.test.ts | 79 ++++++--- src/server/index.ts | 76 +-------- src/server/middleware/cors.test.ts | 4 +- src/server/middleware/cors.ts | 53 ++---- 10 files changed, 112 insertions(+), 462 deletions(-) diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index cfb4100a..00025e98 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -291,14 +291,17 @@ describe('Settings > General tab', () => { }) }) - it('renders the H5 section in a disabled state by default', () => { + it('renders the H5 section without token controls while access is open', () => { render() fireEvent.click(screen.getByText('General')) const section = screen.getByRole('region', { name: 'H5 Access' }) - expect(within(section).getByLabelText('Enable H5 access')).not.toBeChecked() - expect(within(section).getByText('Disabled')).toBeInTheDocument() + expect(within(section).getByText('Token access is temporarily disabled; H5 requests are allowed without an Authorization header.')).toBeInTheDocument() + expect(within(section).queryByLabelText('Enable H5 access')).not.toBeInTheDocument() + expect(within(section).queryByText('Token preview')).not.toBeInTheDocument() + expect(within(section).queryByRole('button', { name: 'Regenerate token' })).not.toBeInTheDocument() + expect(within(section).queryByLabelText('Allowed origins')).not.toBeInTheDocument() }) it('places H5 access after the common General settings sections', () => { @@ -311,88 +314,10 @@ describe('Settings > General tab', () => { expect((webSearchTitle.compareDocumentPosition(h5Title) & Node.DOCUMENT_POSITION_FOLLOWING) !== 0).toBe(true) }) - it('enables H5 access from the General settings section', async () => { - render() - - fireEvent.click(screen.getByText('General')) - - const section = screen.getByRole('region', { name: 'H5 Access' }) - await act(async () => { - fireEvent.click(within(section).getByLabelText('Enable H5 access')) - }) - - expect(useSettingsStore.getState().enableH5Access).toHaveBeenCalledTimes(1) - }) - - it('regenerates the H5 token from General settings', async () => { - useSettingsStore.setState({ - h5Access: { - enabled: true, - tokenPreview: 'h5a1b2c3', - allowedOrigins: ['https://phone.example'], - publicBaseUrl: 'https://phone.example/app', - }, - regenerateH5AccessToken: vi.fn().mockImplementation(async () => { - useSettingsStore.setState({ - h5Access: { - enabled: true, - tokenPreview: 'h5d4e5f6', - allowedOrigins: ['https://phone.example'], - publicBaseUrl: 'https://phone.example/app', - }, - }) - return 'h5_regenerated_secret_token' - }), - }) - render() - - fireEvent.click(screen.getByText('General')) - - const section = screen.getByRole('region', { name: 'H5 Access' }) - await act(async () => { - fireEvent.click(within(section).getByRole('button', { name: 'Regenerate token' })) - }) - - expect(useSettingsStore.getState().regenerateH5AccessToken).toHaveBeenCalledTimes(1) - }) - - it('copies the generated H5 token and clears it after a successful copy', async () => { - useSettingsStore.setState({ - enableH5Access: vi.fn().mockImplementation(async () => { - useSettingsStore.setState({ - h5Access: { - enabled: true, - tokenPreview: 'h5z1y2x3', - allowedOrigins: [], - publicBaseUrl: null, - }, - }) - return 'h5_secret_token' - }), - }) - render() - - fireEvent.click(screen.getByText('General')) - const section = screen.getByRole('region', { name: 'H5 Access' }) - - await act(async () => { - fireEvent.click(within(section).getByLabelText('Enable H5 access')) - }) - - expect(await within(section).findByText('h5_secret_token')).toBeInTheDocument() - - await act(async () => { - fireEvent.click(within(section).getByRole('button', { name: 'Copy' })) - }) - - expect(clipboardMock.copyTextToClipboard).toHaveBeenCalledWith('h5_secret_token') - expect(within(section).queryByText('h5_secret_token')).not.toBeInTheDocument() - }) - it('copies the H5 URL when available', async () => { useSettingsStore.setState({ h5Access: { - enabled: true, + enabled: false, tokenPreview: 'h5url123', allowedOrigins: ['https://phone.example'], publicBaseUrl: 'https://phone.example/app', @@ -410,39 +335,6 @@ describe('Settings > General tab', () => { expect(clipboardMock.copyTextToClipboard).toHaveBeenCalledWith('https://phone.example/app') }) - it('clears the generated token after the visibility timeout', async () => { - vi.useFakeTimers() - useSettingsStore.setState({ - enableH5Access: vi.fn().mockImplementation(async () => { - useSettingsStore.setState({ - h5Access: { - enabled: true, - tokenPreview: 'h5timeout', - allowedOrigins: [], - publicBaseUrl: null, - }, - }) - return 'h5_timeout_token' - }), - }) - render() - - fireEvent.click(screen.getByText('General')) - const section = screen.getByRole('region', { name: 'H5 Access' }) - - await act(async () => { - fireEvent.click(within(section).getByLabelText('Enable H5 access')) - }) - - expect(within(section).getByText('h5_timeout_token')).toBeInTheDocument() - - await act(async () => { - vi.advanceTimersByTime(30_000) - }) - - expect(within(section).queryByText('h5_timeout_token')).not.toBeInTheDocument() - }) - it('shows the H5-specific store error when the H5 settings load failed', () => { useSettingsStore.setState({ h5AccessError: 'H5 unavailable' }) render() @@ -453,10 +345,10 @@ describe('Settings > General tab', () => { expect(within(section).getByText('H5 unavailable')).toBeInTheDocument() }) - it('updates H5 public URL and allowed origins from General settings', async () => { + it('updates H5 public URL from General settings', async () => { useSettingsStore.setState({ h5Access: { - enabled: true, + enabled: false, tokenPreview: 'h5a1b2c3', allowedOrigins: ['https://old.example'], publicBaseUrl: null, @@ -470,9 +362,6 @@ describe('Settings > General tab', () => { fireEvent.change(within(section).getByLabelText('Public URL'), { target: { value: 'https://phone.example/app' }, }) - fireEvent.change(within(section).getByLabelText('Allowed origins'), { - target: { value: 'https://phone.example, https://tablet.example' }, - }) await act(async () => { fireEvent.click(within(section).getByRole('button', { name: 'Save H5 settings' })) @@ -480,7 +369,6 @@ describe('Settings > General tab', () => { expect(useSettingsStore.getState().updateH5AccessSettings).toHaveBeenCalledWith({ publicBaseUrl: 'https://phone.example/app', - allowedOrigins: ['https://phone.example', 'https://tablet.example'], }) }) diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index d7fc56f0..9bbc982b 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -697,7 +697,7 @@ export const en = { 'settings.general.notificationsTestTitle': 'Claude Code Haha notifications are enabled', 'settings.general.notificationsTestBody': 'Permission prompts and completed agent replies will now use system notifications.', 'settings.general.h5AccessTitle': 'H5 Access', - 'settings.general.h5AccessDescription': 'Opt in to browser access from your phone or another device using a generated token and an allowlisted origin set.', + 'settings.general.h5AccessDescription': 'Browser access is open while the token gate is paused. Configure the public URL only when you expose the local service through a stable address.', 'settings.general.h5AccessEnabled': 'Enable H5 access', 'settings.general.h5AccessEnabledHint': 'Turn this on only for networks and browser origins you control.', 'settings.general.h5AccessTokenPreview': 'Token preview', @@ -713,9 +713,10 @@ export const en = { 'settings.general.h5AccessAllowedOrigins': 'Allowed origins', 'settings.general.h5AccessAllowedOriginsPlaceholder': 'https://chat.example.com, https://phone.example', 'settings.general.h5AccessOriginsHint': 'Enter one origin per line or separate multiple origins with commas.', + 'settings.general.h5AccessOpenHint': 'Token access is temporarily disabled; H5 requests are allowed without an Authorization header.', 'settings.general.h5AccessSave': 'Save H5 settings', 'settings.general.h5AccessUrl': 'H5 URL', - 'settings.general.h5AccessSafetyNote': 'Treat the full token like a password and only allow origins you fully control.', + 'settings.general.h5AccessSafetyNote': 'Only expose this URL on networks and devices you trust until token access is restored.', 'settings.general.h5AccessError': 'Failed to update H5 access settings.', 'settings.general.webFetchPreflightTitle': 'WebFetch Preflight', 'settings.general.webFetchPreflightDescription': 'Desktop sessions skip Claude\'s domain preflight by default to avoid false failures on third-party providers and restricted networks.', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 753af5dd..86ba03f1 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -699,7 +699,7 @@ export const zh: Record = { 'settings.general.notificationsTestTitle': 'Claude Code Haha 通知已启用', 'settings.general.notificationsTestBody': '后续授权确认和 Agent 回复完成都会通过系统通知提醒。', 'settings.general.h5AccessTitle': 'H5 访问', - 'settings.general.h5AccessDescription': '通过一次性生成的令牌和允许来源列表,为手机或其他设备上的浏览器访问开启可选 H5 模式。', + 'settings.general.h5AccessDescription': 'H5 token 鉴权已临时暂停,浏览器访问会直接放行。只有需要固定外部访问地址时才配置公开 URL。', 'settings.general.h5AccessEnabled': '启用 H5 访问', 'settings.general.h5AccessEnabledHint': '只应在你可控的网络和浏览器来源上开启。', 'settings.general.h5AccessTokenPreview': '令牌预览', @@ -715,9 +715,10 @@ export const zh: Record = { 'settings.general.h5AccessAllowedOrigins': '允许的来源', 'settings.general.h5AccessAllowedOriginsPlaceholder': 'https://chat.example.com, https://phone.example', 'settings.general.h5AccessOriginsHint': '每行一个来源,或使用逗号分隔多个来源。', + 'settings.general.h5AccessOpenHint': '当前不会要求 Authorization header,H5 请求会直接放行。', 'settings.general.h5AccessSave': '保存 H5 设置', 'settings.general.h5AccessUrl': 'H5 链接', - 'settings.general.h5AccessSafetyNote': '完整令牌等同于密码,只允许你完全信任和可控的来源。', + 'settings.general.h5AccessSafetyNote': '恢复 token 鉴权前,只在你信任的网络和设备上暴露这个地址。', 'settings.general.h5AccessError': '更新 H5 设置失败。', 'settings.general.webFetchPreflightTitle': 'WebFetch 预检', 'settings.general.webFetchPreflightDescription': '桌面端默认跳过 Claude 的域名预检,避免第三方服务商或受限网络下出现误报失败。', diff --git a/desktop/src/lib/desktopRuntime.test.ts b/desktop/src/lib/desktopRuntime.test.ts index d97f09e6..b62e7390 100644 --- a/desktop/src/lib/desktopRuntime.test.ts +++ b/desktop/src/lib/desktopRuntime.test.ts @@ -52,16 +52,16 @@ describe('desktopRuntime browser H5 bootstrap', () => { expect(requiresH5AuthForServerUrl('http://[::1]:3456')).toBe(false) expect(requiresH5AuthForServerUrl('http://127.0.0.1:3456')).toBe(false) expect(requiresH5AuthForServerUrl('http://localhost:3456')).toBe(false) - expect(requiresH5AuthForServerUrl('https://public.example.com/app')).toBe(true) - expect(requiresH5AuthForServerUrl('https://public.example.com/app', 'phone.example.test')).toBe(true) + expect(requiresH5AuthForServerUrl('https://public.example.com/app')).toBe(false) + expect(requiresH5AuthForServerUrl('https://public.example.com/app', 'phone.example.test')).toBe(false) }) - it('only lets localhost browser WebUI bypass H5 auth for private LAN servers', () => { + it('does not require H5 auth for LAN or public browser URLs while token access is paused', () => { expect(requiresH5AuthForServerUrl('http://192.168.0.102:28670', '127.0.0.1')).toBe(false) expect(requiresH5AuthForServerUrl('http://10.0.0.5:28670', 'localhost')).toBe(false) expect(requiresH5AuthForServerUrl('http://172.20.1.8:28670', 'localhost')).toBe(false) - expect(requiresH5AuthForServerUrl('https://public.example.com/app', 'localhost')).toBe(true) - expect(requiresH5AuthForServerUrl('http://192.168.0.102:28670', 'phone.example.test')).toBe(true) + expect(requiresH5AuthForServerUrl('https://public.example.com/app', 'localhost')).toBe(false) + expect(requiresH5AuthForServerUrl('http://192.168.0.102:28670', 'phone.example.test')).toBe(false) }) it('clears an invalid token but preserves the remembered remote server URL', async () => { @@ -188,6 +188,23 @@ describe('desktopRuntime browser H5 bootstrap', () => { }) }) + it('lets browser H5 connect to a public server URL without H5 token', async () => { + window.history.pushState({}, '', '/?serverUrl=https%3A%2F%2Fpublic.example.com%2Fapp') + window.localStorage.setItem(H5_TOKEN_STORAGE_KEY, 'remote-token') + globalThis.fetch = vi.fn().mockResolvedValue( + new Response(null, { status: 200 }), + ) as typeof fetch + + await expect(initializeDesktopServerUrl()).resolves.toBe('https://public.example.com/app') + + expect(clientMocks.setBaseUrl).toHaveBeenLastCalledWith('https://public.example.com/app') + expect(clientMocks.setAuthToken).toHaveBeenLastCalledWith(null) + expect(clientMocks.postVerify).not.toHaveBeenCalled() + expect(globalThis.fetch).toHaveBeenCalledWith('https://public.example.com/app/api/status', { + cache: 'no-store', + }) + }) + it('shows the H5 token recovery view when a local browser connects to an auth-required LAN server', async () => { window.history.pushState({}, '', '/?serverUrl=http%3A%2F%2F192.168.0.102%3A28670') globalThis.fetch = vi.fn() diff --git a/desktop/src/lib/desktopRuntime.ts b/desktop/src/lib/desktopRuntime.ts index 0eeacc6d..4d84c1ea 100644 --- a/desktop/src/lib/desktopRuntime.ts +++ b/desktop/src/lib/desktopRuntime.ts @@ -269,46 +269,9 @@ export function isLoopbackHostname(hostname: string) { } export function requiresH5AuthForServerUrl(serverUrl: string, browserHostname = getBrowserHostname()) { - try { - const serverHostname = new URL(serverUrl).hostname - if (isLoopbackHostname(serverHostname)) { - return false - } - if (browserHostname && isLoopbackHostname(browserHostname) && isPrivateNetworkHostname(serverHostname)) { - return false - } - return true - } catch { - return false - } -} - -function isPrivateNetworkHostname(hostname: string) { - const normalized = hostname.trim().replace(/^\[/, '').replace(/\]$/, '').toLowerCase() - - if (normalized === '0.0.0.0') { - return true - } - - const ipv4Parts = normalized.split('.') - if (ipv4Parts.length === 4 && ipv4Parts.every((part) => /^\d+$/.test(part))) { - const octets = ipv4Parts.map((part) => Number(part)) - if (octets.some((octet) => !Number.isInteger(octet) || octet < 0 || octet > 255)) { - return false - } - const a = octets[0] ?? -1 - const b = octets[1] ?? -1 - return ( - a === 10 || - (a === 172 && b >= 16 && b <= 31) || - (a === 192 && b === 168) || - (a === 169 && b === 254) - ) - } - - return normalized.startsWith('fc') || - normalized.startsWith('fd') || - normalized.startsWith('fe80:') + void serverUrl + void browserHostname + return false } function getBrowserHostname() { diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index abf14a13..c0ea85f4 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -5,7 +5,6 @@ import { useTranslation } from '../i18n' import { Modal } from '../components/shared/Modal' import { ConfirmDialog } from '../components/shared/ConfirmDialog' import { Input } from '../components/shared/Input' -import { Textarea } from '../components/shared/Textarea' import { Button } from '../components/shared/Button' import { Dropdown } from '../components/shared/Dropdown' import type { PermissionMode, EffortLevel, ThemeMode, WebSearchMode } from '../types/settings' @@ -48,8 +47,6 @@ import { } from '../lib/providerSettingsJson' import { copyTextToClipboard } from '../components/chat/clipboard' -const H5_GENERATED_TOKEN_TIMEOUT_MS = 30_000 - export function Settings() { const [activeTab, setActiveTab] = useState('providers') const pendingSettingsTab = useUIStore((s) => s.pendingSettingsTab) @@ -1364,9 +1361,6 @@ function GeneralSettings() { setWebSearch, h5Access, h5AccessError, - enableH5Access, - disableH5Access, - regenerateH5AccessToken, updateH5AccessSettings, responseLanguage, setResponseLanguage, @@ -1374,16 +1368,12 @@ function GeneralSettings() { const t = useTranslation() const [webSearchDraft, setWebSearchDraft] = useState(webSearch) const [h5PublicBaseUrlDraft, setH5PublicBaseUrlDraft] = useState(h5Access.publicBaseUrl ?? '') - const [h5AllowedOriginsDraft, setH5AllowedOriginsDraft] = useState(serializeAllowedOrigins(h5Access.allowedOrigins)) - const [generatedH5Token, setGeneratedH5Token] = useState(null) const [notificationPermission, setNotificationPermission] = useState('default') const [notificationActionRunning, setNotificationActionRunning] = useState(false) const [h5ActionRunning, setH5ActionRunning] = useState(false) const webSearchDirty = JSON.stringify(webSearchDraft) !== JSON.stringify(webSearch) - const h5AccessUrl = h5Access.enabled && h5Access.publicBaseUrl ? h5Access.publicBaseUrl : null - const h5AccessDirty = - h5PublicBaseUrlDraft.trim() !== (h5Access.publicBaseUrl ?? '') || - !arraysEqual(parseAllowedOriginsDraft(h5AllowedOriginsDraft), h5Access.allowedOrigins) + const h5AccessUrl = h5Access.publicBaseUrl + const h5AccessDirty = h5PublicBaseUrlDraft.trim() !== (h5Access.publicBaseUrl ?? '') useEffect(() => { setWebSearchDraft(webSearch) @@ -1391,21 +1381,8 @@ function GeneralSettings() { useEffect(() => { setH5PublicBaseUrlDraft(h5Access.publicBaseUrl ?? '') - setH5AllowedOriginsDraft(serializeAllowedOrigins(h5Access.allowedOrigins)) }, [h5Access]) - useEffect(() => { - if (!generatedH5Token) return - - const timeout = window.setTimeout(() => { - setGeneratedH5Token(null) - }, H5_GENERATED_TOKEN_TIMEOUT_MS) - - return () => { - window.clearTimeout(timeout) - } - }, [generatedH5Token]) - useEffect(() => { let cancelled = false getDesktopNotificationPermission().then((permission) => { @@ -1531,36 +1508,14 @@ function GeneralSettings() { } } - const handleH5AccessToggle = async (enabled: boolean) => { - await runH5Action(async () => { - if (enabled) { - const token = await enableH5Access() - setGeneratedH5Token(token) - return - } - - setGeneratedH5Token(null) - await disableH5Access() - }) - } - const handleH5SettingsSave = async () => { await runH5Action(async () => { await updateH5AccessSettings({ publicBaseUrl: h5PublicBaseUrlDraft.trim() || null, - allowedOrigins: parseAllowedOriginsDraft(h5AllowedOriginsDraft), }) }) } - const handleGeneratedH5TokenCopy = async () => { - if (!generatedH5Token) return - const copied = await copyTextToClipboard(generatedH5Token) - if (copied) { - setGeneratedH5Token(null) - } - } - const handleH5UrlCopy = async () => { if (!h5AccessUrl) return await copyTextToClipboard(h5AccessUrl) @@ -1845,83 +1800,7 @@ function GeneralSettings() { {t('settings.general.h5AccessDescription')}

- - -
-
-
-
- {t('settings.general.h5AccessTokenPreview')} -
-
- {h5Access.tokenPreview ?? t('settings.general.h5AccessDisabledValue')} -
-
- -
-
- - {generatedH5Token && ( -
-
-
- {t('settings.general.h5AccessGeneratedToken')} -
-
- - -
-
- - {generatedH5Token} - -

- {t('settings.general.h5AccessGeneratedTokenHint')} -

-
- )} - -
+
setH5PublicBaseUrlDraft(event.target.value)} /> -