mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-18 13:23:33 +08:00
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
This commit is contained in:
parent
ae7e33cf95
commit
1597685d37
@ -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(<Settings />)
|
||||
|
||||
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(<Settings />)
|
||||
|
||||
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(<Settings />)
|
||||
|
||||
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(<Settings />)
|
||||
|
||||
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(<Settings />)
|
||||
|
||||
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(<Settings />)
|
||||
@ -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'],
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@ -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.',
|
||||
|
||||
@ -699,7 +699,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'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<TranslationKey, string> = {
|
||||
'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 的域名预检,避免第三方服务商或受限网络下出现误报失败。',
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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() {
|
||||
|
||||
@ -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<SettingsTab>('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<string | null>(null)
|
||||
const [notificationPermission, setNotificationPermission] = useState<DesktopNotificationPermission>('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')}
|
||||
</p>
|
||||
<div className="rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-4 py-4">
|
||||
<label className="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
aria-label={t('settings.general.h5AccessEnabled')}
|
||||
checked={h5Access.enabled}
|
||||
onChange={(event) => void handleH5AccessToggle(event.target.checked)}
|
||||
disabled={h5ActionRunning}
|
||||
className="peer sr-only"
|
||||
/>
|
||||
<SettingsCheckboxMark checked={h5Access.enabled} disabled={h5ActionRunning} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-medium text-[var(--color-text-primary)]">
|
||||
{t('settings.general.h5AccessEnabled')}
|
||||
</div>
|
||||
<div className="text-xs text-[var(--color-text-tertiary)] mt-1 leading-5">
|
||||
{t('settings.general.h5AccessEnabledHint')}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div className="mt-4 border-t border-[var(--color-border)]/60 pt-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs uppercase tracking-[0.08em] text-[var(--color-text-tertiary)]">
|
||||
{t('settings.general.h5AccessTokenPreview')}
|
||||
</div>
|
||||
<div className="mt-1 text-sm font-medium text-[var(--color-text-primary)]">
|
||||
{h5Access.tokenPreview ?? t('settings.general.h5AccessDisabledValue')}
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => void runH5Action(async () => {
|
||||
const token = await regenerateH5AccessToken()
|
||||
setGeneratedH5Token(token)
|
||||
})}
|
||||
disabled={!h5Access.enabled || h5ActionRunning}
|
||||
>
|
||||
{t('settings.general.h5AccessRegenerate')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{generatedH5Token && (
|
||||
<div className="mt-4 border-t border-[var(--color-border)]/60 pt-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="text-sm font-medium text-[var(--color-text-primary)]">
|
||||
{t('settings.general.h5AccessGeneratedToken')}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onClick={() => void handleGeneratedH5TokenCopy()}
|
||||
>
|
||||
{t('settings.general.h5AccessCopy')}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onClick={() => setGeneratedH5Token(null)}
|
||||
>
|
||||
{t('settings.general.h5AccessHideToken')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<code className="mt-2 block rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] px-3 py-2 text-xs text-[var(--color-text-primary)] break-all">
|
||||
{generatedH5Token}
|
||||
</code>
|
||||
<p className="mt-2 text-xs text-[var(--color-text-tertiary)]">
|
||||
{t('settings.general.h5AccessGeneratedTokenHint')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 grid grid-cols-1 gap-3 border-t border-[var(--color-border)]/60 pt-4">
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<Input
|
||||
id="h5-access-public-url"
|
||||
label={t('settings.general.h5AccessPublicUrl')}
|
||||
@ -1929,17 +1808,9 @@ function GeneralSettings() {
|
||||
placeholder={t('settings.general.h5AccessPublicUrlPlaceholder')}
|
||||
onChange={(event) => setH5PublicBaseUrlDraft(event.target.value)}
|
||||
/>
|
||||
<Textarea
|
||||
id="h5-access-allowed-origins"
|
||||
label={t('settings.general.h5AccessAllowedOrigins')}
|
||||
value={h5AllowedOriginsDraft}
|
||||
placeholder={t('settings.general.h5AccessAllowedOriginsPlaceholder')}
|
||||
onChange={(event) => setH5AllowedOriginsDraft(event.target.value)}
|
||||
className="min-h-[88px]"
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<p className="text-xs text-[var(--color-text-tertiary)]">
|
||||
{t('settings.general.h5AccessOriginsHint')}
|
||||
{t('settings.general.h5AccessOpenHint')}
|
||||
</p>
|
||||
<Button
|
||||
size="sm"
|
||||
@ -2009,21 +1880,6 @@ function SettingsCheckboxMark({ checked, disabled = false }: { checked: boolean;
|
||||
)
|
||||
}
|
||||
|
||||
function serializeAllowedOrigins(origins: string[]) {
|
||||
return origins.join(', ')
|
||||
}
|
||||
|
||||
function parseAllowedOriginsDraft(value: string) {
|
||||
return value
|
||||
.split(/[\n,]/)
|
||||
.map((origin) => origin.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function arraysEqual(left: string[], right: string[]) {
|
||||
return left.length === right.length && left.every((value, index) => value === right[index])
|
||||
}
|
||||
|
||||
// ─── Agents Settings ──────────────────────────────────────
|
||||
|
||||
const AGENT_COLORS: Record<string, string> = {
|
||||
|
||||
@ -2,7 +2,7 @@ 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 { canBypassRemoteAuthForLocalBrowser, startServer } from '../index.js'
|
||||
import { startServer } from '../index.js'
|
||||
import { H5AccessService } from '../services/h5AccessService.js'
|
||||
import { ProviderService } from '../services/providerService.js'
|
||||
|
||||
@ -14,6 +14,7 @@ let originalConfigDir: string | undefined
|
||||
let originalAnthropicApiKey: string | undefined
|
||||
let originalH5DistDir: string | undefined
|
||||
let originalClaudeAppRoot: string | undefined
|
||||
let originalServerAuthRequired: string | undefined
|
||||
let originalServerPort = 3456
|
||||
|
||||
async function waitForServer(url: string): Promise<void> {
|
||||
@ -35,7 +36,13 @@ function randomPort(): number {
|
||||
return 18000 + Math.floor(Math.random() * 10000)
|
||||
}
|
||||
|
||||
async function startRemoteServer(): Promise<void> {
|
||||
async function startRemoteServer(options: { authRequired?: boolean } = {}): Promise<void> {
|
||||
if (options.authRequired) {
|
||||
process.env.SERVER_AUTH_REQUIRED = '1'
|
||||
} else {
|
||||
delete process.env.SERVER_AUTH_REQUIRED
|
||||
}
|
||||
|
||||
const port = randomPort()
|
||||
server = startServer(port, '0.0.0.0')
|
||||
baseUrl = `http://127.0.0.1:${port}`
|
||||
@ -43,6 +50,12 @@ async function startRemoteServer(): Promise<void> {
|
||||
await waitForServer(`${baseUrl}/health`)
|
||||
}
|
||||
|
||||
async function restartRemoteServer(options: { authRequired?: boolean } = {}): Promise<void> {
|
||||
server?.stop(true)
|
||||
server = undefined
|
||||
await startRemoteServer(options)
|
||||
}
|
||||
|
||||
function makeUpgradeHeaders(origin?: string): HeadersInit {
|
||||
return {
|
||||
Connection: 'Upgrade',
|
||||
@ -92,6 +105,7 @@ beforeEach(async () => {
|
||||
originalAnthropicApiKey = process.env.ANTHROPIC_API_KEY
|
||||
originalH5DistDir = process.env.CLAUDE_H5_DIST_DIR
|
||||
originalClaudeAppRoot = process.env.CLAUDE_APP_ROOT
|
||||
originalServerAuthRequired = process.env.SERVER_AUTH_REQUIRED
|
||||
originalServerPort = ProviderService.getServerPort()
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
||||
const h5DistDir = path.join(tmpDir, 'dist')
|
||||
@ -121,6 +135,8 @@ afterEach(async () => {
|
||||
else process.env.CLAUDE_H5_DIST_DIR = originalH5DistDir
|
||||
if (originalClaudeAppRoot === undefined) delete process.env.CLAUDE_APP_ROOT
|
||||
else process.env.CLAUDE_APP_ROOT = originalClaudeAppRoot
|
||||
if (originalServerAuthRequired === undefined) delete process.env.SERVER_AUTH_REQUIRED
|
||||
else process.env.SERVER_AUTH_REQUIRED = originalServerAuthRequired
|
||||
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
@ -153,12 +169,12 @@ describe('remote H5 auth and CORS integration', () => {
|
||||
await expect(response.text()).resolves.toContain('Mapped H5 Shell')
|
||||
})
|
||||
|
||||
test('rejects /api/status when H5 is disabled and no Anthropic key exists', async () => {
|
||||
test('allows /api/status by default without H5 token or Anthropic key', async () => {
|
||||
const response = await fetch(`${baseUrl}/api/status`)
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(response.status).toBe(200)
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
error: 'Unauthorized',
|
||||
status: 'ok',
|
||||
})
|
||||
})
|
||||
|
||||
@ -190,21 +206,7 @@ describe('remote H5 auth and CORS integration', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('only lets localhost WebUI origin bypass H5 auth for loopback or private server hosts', () => {
|
||||
expect(canBypassRemoteAuthForLocalBrowser('http://127.0.0.1:5179', '127.0.0.1')).toBe(true)
|
||||
expect(canBypassRemoteAuthForLocalBrowser('http://localhost:5179', '192.168.0.102')).toBe(true)
|
||||
expect(canBypassRemoteAuthForLocalBrowser('http://tauri.localhost', '127.0.0.1')).toBe(true)
|
||||
expect(canBypassRemoteAuthForLocalBrowser('tauri://localhost', '127.0.0.1')).toBe(true)
|
||||
expect(canBypassRemoteAuthForLocalBrowser('asset://localhost', '127.0.0.1')).toBe(true)
|
||||
expect(canBypassRemoteAuthForLocalBrowser('http://localhost:5179', '10.0.0.5')).toBe(true)
|
||||
expect(canBypassRemoteAuthForLocalBrowser('http://localhost:5179', '172.20.1.8')).toBe(true)
|
||||
expect(canBypassRemoteAuthForLocalBrowser('http://localhost:5179', 'public.example.com')).toBe(false)
|
||||
expect(canBypassRemoteAuthForLocalBrowser('http://tauri.localhost', 'public.example.com')).toBe(false)
|
||||
expect(canBypassRemoteAuthForLocalBrowser('http://tauri.localhost', '192.168.0.102')).toBe(false)
|
||||
expect(canBypassRemoteAuthForLocalBrowser('http://192.168.0.50:5179', '192.168.0.102')).toBe(false)
|
||||
})
|
||||
|
||||
test('rejects /api/status when H5 is enabled and bearer token is wrong', async () => {
|
||||
test('keeps /api/status open by default even when a stale bearer token is sent', async () => {
|
||||
await enableH5Access()
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/status`, {
|
||||
@ -213,10 +215,13 @@ describe('remote H5 auth and CORS integration', () => {
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(response.status).toBe(200)
|
||||
await expect(response.json()).resolves.toMatchObject({
|
||||
status: 'ok',
|
||||
})
|
||||
})
|
||||
|
||||
test('allows /api/status when H5 is enabled and bearer token is correct', async () => {
|
||||
test('allows /api/status with a bearer token while default auth is open', async () => {
|
||||
const token = await enableH5Access()
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/status`, {
|
||||
@ -231,7 +236,7 @@ describe('remote H5 auth and CORS integration', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('rejects unlisted CORS origins', async () => {
|
||||
test('allows arbitrary CORS origins while default H5 access is open', async () => {
|
||||
const token = await enableH5Access({
|
||||
allowedOrigins: ['https://allowed.example.com'],
|
||||
})
|
||||
@ -245,8 +250,8 @@ describe('remote H5 auth and CORS integration', () => {
|
||||
},
|
||||
})
|
||||
|
||||
expect(response.status).toBe(403)
|
||||
expect(response.headers.get('Access-Control-Allow-Origin')).toBeNull()
|
||||
expect(response.status).toBe(204)
|
||||
expect(response.headers.get('Access-Control-Allow-Origin')).toBe('https://blocked.example.com')
|
||||
})
|
||||
|
||||
test('allows same-origin H5 browser requests without a separate origin allowlist entry', async () => {
|
||||
@ -284,9 +289,31 @@ describe('remote H5 auth and CORS integration', () => {
|
||||
expect(response.headers.get('Vary')).toBe('Origin')
|
||||
})
|
||||
|
||||
test('rejects websocket upgrade without a valid H5 token and accepts the correct token', async () => {
|
||||
test('opens websocket upgrades without H5 token by default', async () => {
|
||||
await expectWebSocketOpen(`${wsBaseUrl}/ws/h5-auth-test`)
|
||||
})
|
||||
|
||||
test('honors explicit auth opt-in for REST and websocket requests', async () => {
|
||||
await restartRemoteServer({ authRequired: true })
|
||||
const token = await enableH5Access()
|
||||
|
||||
const missingStatusResponse = await fetch(`${baseUrl}/api/status`)
|
||||
expect(missingStatusResponse.status).toBe(401)
|
||||
|
||||
const wrongStatusResponse = await fetch(`${baseUrl}/api/status`, {
|
||||
headers: {
|
||||
Authorization: 'Bearer wrong-token',
|
||||
},
|
||||
})
|
||||
expect(wrongStatusResponse.status).toBe(401)
|
||||
|
||||
const validStatusResponse = await fetch(`${baseUrl}/api/status`, {
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
})
|
||||
expect(validStatusResponse.status).toBe(200)
|
||||
|
||||
const missingTokenResponse = await fetch(`${baseUrl}/ws/h5-auth-test`, {
|
||||
headers: makeUpgradeHeaders(),
|
||||
})
|
||||
|
||||
@ -50,69 +50,6 @@ const SERVER_OPTIONS = resolveServerOptions()
|
||||
const PORT = SERVER_OPTIONS.port
|
||||
const HOST = SERVER_OPTIONS.host
|
||||
|
||||
function isLocalServerHost(host: string): boolean {
|
||||
return host === '127.0.0.1' || host === 'localhost' || host === '::1'
|
||||
}
|
||||
|
||||
function isLocalBrowserOrigin(origin: string | null): boolean {
|
||||
if (!origin) return false
|
||||
|
||||
try {
|
||||
return isLocalServerHost(new URL(origin).hostname)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isTauriWebViewOrigin(origin: string | null): boolean {
|
||||
if (!origin) return false
|
||||
|
||||
try {
|
||||
const url = new URL(origin)
|
||||
return url.hostname === 'tauri.localhost' ||
|
||||
((url.protocol === 'tauri:' || url.protocol === 'asset:') && url.hostname === 'localhost')
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
function isPrivateNetworkHost(host: string): boolean {
|
||||
const normalized = host.trim().replace(/^\[/, '').replace(/\]$/, '').toLowerCase()
|
||||
|
||||
if (normalized === '0.0.0.0') {
|
||||
return true
|
||||
}
|
||||
|
||||
const parts = normalized.split('.')
|
||||
if (parts.length === 4 && parts.every((part) => /^\d+$/.test(part))) {
|
||||
const octets = parts.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:')
|
||||
}
|
||||
|
||||
export function canBypassRemoteAuthForLocalBrowser(origin: string | null, requestHost: string): boolean {
|
||||
if (isTauriWebViewOrigin(origin)) {
|
||||
return isLocalServerHost(requestHost)
|
||||
}
|
||||
|
||||
return isLocalBrowserOrigin(origin) &&
|
||||
(isLocalServerHost(requestHost) || isPrivateNetworkHost(requestHost))
|
||||
}
|
||||
|
||||
function withCors(response: Response, cors: CorsResolution): Response {
|
||||
const headers = new Headers(response.headers)
|
||||
for (const [key, value] of Object.entries(cors.headers)) {
|
||||
@ -142,15 +79,13 @@ export function startServer(port = PORT, host = HOST) {
|
||||
: host
|
||||
|
||||
/**
|
||||
* Auth is required when explicitly opted in or when bound to a non-localhost address.
|
||||
* - Default localhost dev: no auth needed (tests pass as-is).
|
||||
* - Production / non-localhost (e.g. 0.0.0.0): auth enforced automatically.
|
||||
* - Explicit opt-in: SERVER_AUTH_REQUIRED=1 forces auth even on localhost.
|
||||
* 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.
|
||||
*/
|
||||
const forceAuth =
|
||||
SERVER_OPTIONS.authRequired ||
|
||||
process.env.SERVER_AUTH_REQUIRED === '1'
|
||||
const remoteHostAuthRequired = !isLocalServerHost(host)
|
||||
|
||||
const server = Bun.serve<WebSocketData>({
|
||||
port,
|
||||
@ -162,10 +97,7 @@ export function startServer(port = PORT, host = HOST) {
|
||||
const url = new URL(req.url)
|
||||
const origin = req.headers.get('Origin')
|
||||
const cors = await resolveCors(origin, url.origin)
|
||||
const authRequired = forceAuth || (
|
||||
remoteHostAuthRequired &&
|
||||
!canBypassRemoteAuthForLocalBrowser(origin, url.hostname)
|
||||
)
|
||||
const authRequired = forceAuth
|
||||
|
||||
// Handle CORS preflight
|
||||
if (req.method === 'OPTIONS') {
|
||||
|
||||
@ -13,8 +13,8 @@ describe('corsHeaders', () => {
|
||||
expect(corsHeaders('tauri://localhost')['Access-Control-Allow-Origin']).toBe('tauri://localhost')
|
||||
})
|
||||
|
||||
it('falls back for unknown origins', () => {
|
||||
expect(corsHeaders('https://example.com')['Access-Control-Allow-Origin']).toBe('http://localhost:3000')
|
||||
it('allows arbitrary origins while H5 access is open', () => {
|
||||
expect(corsHeaders('https://example.com')['Access-Control-Allow-Origin']).toBe('https://example.com')
|
||||
expect(corsHeaders(null)['Access-Control-Allow-Origin']).toBe('http://localhost:3000')
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,16 +1,9 @@
|
||||
/**
|
||||
* CORS middleware for local desktop app communication
|
||||
* CORS middleware for desktop and temporary open H5 access.
|
||||
*/
|
||||
|
||||
import { H5AccessService } from '../services/h5AccessService.js'
|
||||
|
||||
const ALLOWED_ORIGIN_RE =
|
||||
/^(?:https?:\/\/(?:localhost|127\.0\.0\.1|tauri\.localhost)(?::\d+)?|tauri:\/\/localhost|asset:\/\/localhost)$/
|
||||
|
||||
export function corsHeaders(origin?: string | null): Record<string, string> {
|
||||
// Allow localhost origins (http/https) and Tauri WebView origins
|
||||
const allowedOrigin =
|
||||
origin && ALLOWED_ORIGIN_RE.test(origin) ? origin : 'http://localhost:3000'
|
||||
const allowedOrigin = origin || 'http://localhost:3000'
|
||||
return {
|
||||
'Access-Control-Allow-Origin': allowedOrigin,
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, PATCH, DELETE, OPTIONS',
|
||||
@ -37,7 +30,7 @@ export type CorsResolution = {
|
||||
|
||||
export async function resolveCors(
|
||||
origin?: string | null,
|
||||
requestOrigin?: string | null,
|
||||
_requestOrigin?: string | null,
|
||||
): Promise<CorsResolution> {
|
||||
if (!origin) {
|
||||
return {
|
||||
@ -47,40 +40,12 @@ export async function resolveCors(
|
||||
}
|
||||
}
|
||||
|
||||
if (ALLOWED_ORIGIN_RE.test(origin)) {
|
||||
return {
|
||||
allowed: true,
|
||||
rejected: false,
|
||||
headers: corsHeaders(origin),
|
||||
}
|
||||
}
|
||||
|
||||
if (requestOrigin && origin === requestOrigin) {
|
||||
return {
|
||||
allowed: true,
|
||||
rejected: false,
|
||||
headers: {
|
||||
...baseCorsHeaders(),
|
||||
'Access-Control-Allow-Origin': origin,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const h5AccessService = new H5AccessService()
|
||||
if (await h5AccessService.isOriginAllowed(origin)) {
|
||||
return {
|
||||
allowed: true,
|
||||
rejected: false,
|
||||
headers: {
|
||||
...baseCorsHeaders(),
|
||||
'Access-Control-Allow-Origin': origin,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
allowed: false,
|
||||
rejected: true,
|
||||
headers: baseCorsHeaders(),
|
||||
allowed: true,
|
||||
rejected: false,
|
||||
headers: {
|
||||
...baseCorsHeaders(),
|
||||
'Access-Control-Allow-Origin': origin,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user