fix(desktop): trust loopback web dev access

Allow local browser origins such as 127.0.0.1, localhost, and ::1 to use the desktop server without H5 token flow, while keeping LAN and public origins behind H5 access rules. Also make Vite SPA healthcheck fallback to the default loopback backend and document scoped verification expectations.

Tested: bun test src/server/__tests__/h5-access-policy.test.ts
Tested: bun test src/server/__tests__/h5-access-auth.test.ts
Tested: bun test src/server/middleware/cors.test.ts
Tested: cd desktop && bun run test -- src/lib/desktopRuntime.test.ts --run
Tested: git diff --check
Not-tested: bun run verify and coverage were intentionally skipped for this scoped local-dev fix.
Scope-risk: moderate
This commit is contained in:
程序员阿江(Relakkes) 2026-06-12 12:29:15 +08:00
parent 3d02e3a91a
commit 9238481e86
8 changed files with 297 additions and 62 deletions

View File

@ -46,25 +46,28 @@ Install root dependencies with `bun install`. Install desktop dependencies in `d
- `bun run check:impact`: print the changed-area impact report and recommended local checks.
## Verification Routing
Use the narrowest meaningful verification while iterating, then run the correct gate before claiming readiness. Do not run long gates after every small edit; reserve them for handoff, push/PR readiness, release readiness, or when the changed surface demands them.
Use the narrowest meaningful verification while iterating, then run the correct gate for the actual handoff level. Do not run long gates after every small edit. In normal local-development handoff, prefer focused regression tests plus the single affected surface gate. Reserve `bun run verify`, `bun run check:coverage`, and other full quality gates for PR-ready, push/merge, release, explicitly requested full validation, or genuinely high-risk changes.
If a user asks for a small fix, local explanation, or quick iteration, do not silently escalate to full PR verification. State the targeted checks you ran and, if relevant, say that full `verify`/coverage was intentionally not run because the change is not being called PR-ready. If a full gate was started and the user asks to stop or says it is too expensive, stop it and continue with scoped verification.
| Situation | Command | Notes |
| --- | --- | --- |
| Fast inner loop for pure logic | Focused `bun test <file>` or nearest package test | Add/update the regression test first when behavior changes. |
| Small scoped bugfix or local dev-flow fix | Focused regression test(s), then the narrowest affected surface gate if needed | Example: a CORS helper plus desktop bootstrap test should run those focused tests; add `check:server`/`check:desktop` only when the touched surface or handoff needs broader confidence. Do not run `verify`/coverage by default. |
| Desktop UI/store/API work | `bun run check:desktop` | Runs desktop lint, Vitest, and production build. For visible UI flows, also use browser/agent-browser smoke when unit tests cannot prove the workflow. |
| Server/API/provider/runtime/MCP/OAuth/WebSocket work | `bun run check:server` | Covers `src/server`, `src/tools`, provider/runtime, MCP, OAuth, WebSocket, and API behavior. |
| IM adapter work | `bun run check:adapters` | On a fresh checkout, run `cd adapters && bun install` first if dependencies are missing. |
| Electron/native/sidecar/packaging/version changes | `bun run check:native` | Runs sidecar build, Electron host checks, Electron `--dir` packaging, and current-platform package-smoke. |
| Docs, README, release notes, or docs workflow changes | `bun run check:docs` | This runs `npm ci`; run it sequentially, not in parallel with commands that depend on root `node_modules`. |
| Persistence shape changes | `bun run check:persistence-upgrade` | Required for local JSON, `localStorage`, app config migrations, and old-fixture upgrade behavior. |
| Coverage during handoff | `bun run check:coverage` or `bun run verify` | Changed executable production lines must meet the changed-line threshold. |
| PR-ready coverage | `bun run check:coverage` or `bun run verify` | Required before calling a change PR-ready, push-ready, mergeable, or release-ready. Not required for ordinary local handoff unless the user asks for PR-level proof or the changed surface is high-risk. |
| Optional fast local check | `bun run quality:push` | Path-aware PR mode with coverage skipped by default; run manually when useful, not as a push-time blocker or substitute for PR-ready verification. |
| PR-ready / final agent handoff for code changes | `bun run verify` | Unified local entrypoint: `bun run verify` is equivalent to `bun run quality:pr` and is the default non-live quality gate. |
| PR-ready / push-ready / full validation handoff for code changes | `bun run verify` | Unified local entrypoint: `bun run verify` is equivalent to `bun run quality:pr`. Run it only when claiming PR-ready, push-ready, mergeable, or when the user explicitly asks for full validation. |
| Live agent/provider confidence | `bun run quality:providers`, then `bun run quality:smoke --provider-model <provider:model[:label]>` | Quick live provider/proxy and desktop agent-browser smoke when provider access exists. |
| High-risk pre-merge confidence | `bun run quality:gate --mode baseline --allow-live --provider-model <provider:model[:label]>` | Use for agent-loop, provider routing, model selection, tool execution, session resume, desktop chat, or other core Coding Agent paths. |
| Release readiness | `bun run quality:gate --mode release --allow-live --provider-model <provider:model[:label]>` | Required before calling a release ready when provider credentials/quota are available. If blocked, report the exact live-provider blocker. |
If `bun run verify` fails, do not stop at reporting the failure. Read the latest quality report, identify the failed lane in the Result Matrix, open the lane log under `artifacts/quality-runs/<timestamp>/logs/<lane>.log`, fix the concrete issue, rerun the narrow check, then rerun `bun run verify`.
If `bun run verify` is intentionally run and fails, do not stop at reporting the failure. Read the latest quality report, identify the failed lane in the Result Matrix, open the lane log under `artifacts/quality-runs/<timestamp>/logs/<lane>.log`, fix the concrete issue, rerun the narrow check, then rerun `bun run verify` only when the user still wants PR-level validation.
## Feature Quality Contract
Every feature, bugfix, and behavior change must ship with proof that matches the changed surface. Treat this as the implementation contract for both human authors and AI coding agents.
@ -73,10 +76,10 @@ Every feature, bugfix, and behavior change must ship with proof that matches the
- Production code changes under `desktop/src`, `src/server`, `src/tools`, `src/utils`, or `adapters` must include a same-area test file in the same PR unless a maintainer explicitly approves `allow-missing-tests`.
- Pure logic requires unit tests. Server/API/provider/runtime changes require server or request-shape tests. Desktop UI/store/API changes require Vitest or Testing Library coverage. User-facing desktop flows require browser/agent-browser smoke when the flow cannot be trusted through unit tests alone.
- Agent loop, tool execution, provider routing, model selection, file editing, permissions, session resume, and desktop chat changes require mock/fixture tests in PR plus live smoke or baseline evidence from a maintainer machine when provider access exists.
- Coverage is part of the feature, not an afterthought. Generated/build output is excluded, maintained product areas should move toward 75-80%+, and every changed executable production line must meet the changed-line coverage gate in `scripts/quality-gate/coverage-thresholds.json` before push/PR readiness.
- Coverage is part of PR readiness, not an afterthought. Generated/build output is excluded, maintained product areas should move toward 75-80%+, and every changed executable production line must meet the changed-line coverage gate in `scripts/quality-gate/coverage-thresholds.json` before push/PR readiness. For local non-PR handoff, focused regression tests are acceptable; record that coverage was not run instead of running it by default.
- Do not lower `scripts/quality-gate/coverage-baseline.json` or `coverage-thresholds.json` unless the PR carries maintainer approval via `allow-coverage-baseline-change` and explains why. Legacy areas below target are debt; new work must leave the touched area higher than it found it.
- E2E is required when the feature crosses process boundaries, browser UI, WebSocket/session state, provider proxying, native sidecars, or release packaging. Use the narrowest meaningful E2E lane first, then `quality:baseline` or `quality:release` for core Coding Agent paths.
- A PR is not ready until the author records changed files, tests added, coverage report path, E2E/live evidence or explicit blocker, and remaining risk. AI agents must include this evidence before saying "complete", "ready", or "mergeable".
- A PR is not ready until the author records changed files, tests added, coverage report path, E2E/live evidence or explicit blocker, and remaining risk. AI agents must include this evidence before saying "PR-ready", "push-ready", "mergeable", or "release-ready". For ordinary local handoff, include changed files, targeted tests/checks run, tests not run, and remaining risk without escalating to PR-level gates.
## Persistent Storage Compatibility
- Any change to local JSON, `localStorage`, or app config persistence formats must ship with a forward migration, an old-fixture regression test, and a persistence upgrade gate.
@ -126,4 +129,4 @@ Every feature, bugfix, and behavior change must ship with proof that matches the
- `Directive:` for forward-looking warnings.
- `Tested:` and `Not-tested:` for verification evidence and gaps.
- PRs should explain user-visible impact, link related issues, list verification steps, include screenshots for desktop/docs UI changes, and call out follow-up work or known gaps.
- The final agent handoff or PR description must include changed files, tests added or updated, coverage report path, E2E/live evidence or blocker, pass/fail/skip counts from the quality report when available, and remaining risk/rollback notes.
- A PR description must include changed files, tests added or updated, coverage report path, E2E/live evidence or blocker, pass/fail/skip counts from the quality report when available, and remaining risk/rollback notes. A normal local agent handoff may be lighter: summarize changed files, focused tests/checks run, skipped full gates, and remaining risk/rollback notes.

View File

@ -59,8 +59,13 @@ describe('desktopRuntime browser H5 bootstrap', () => {
it('treats IPv6 loopback as local', () => {
expect(isLoopbackHostname('[::1]')).toBe(true)
expect(isLoopbackHostname('::1')).toBe(true)
expect(isLoopbackHostname('127.0.1.1')).toBe(true)
expect(isLoopbackHostname('127.example.com')).toBe(false)
expect(isLoopbackHostname('127.bad.0.1')).toBe(false)
expect(requiresH5AuthForServerUrl('http://[::1]:3456')).toBe(false)
expect(requiresH5AuthForServerUrl('http://127.0.0.1:3456')).toBe(false)
expect(requiresH5AuthForServerUrl('http://127.0.1.1:3456')).toBe(false)
expect(requiresH5AuthForServerUrl('http://127.example.com:3456')).toBe(true)
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)
@ -185,8 +190,74 @@ describe('desktopRuntime browser H5 bootstrap', () => {
consoleError.mockRestore()
})
it('does not treat a Vite SPA fallback response as a desktop server healthcheck', async () => {
it('falls back to the default backend when a loopback dev origin serves a Vite SPA fallback', async () => {
vi.useFakeTimers()
globalThis.fetch = vi.fn((input) => {
if (String(input) === `${window.location.origin}/health`) {
return Promise.resolve(new Response('<!doctype html>', {
status: 200,
headers: { 'content-type': 'text/html' },
}))
}
return Promise.resolve(healthOkResponse())
}) as typeof fetch
const startup = expect(initializeDesktopServerUrl()).resolves.toBe('http://127.0.0.1:3456')
await vi.runAllTimersAsync()
await startup
expect(clientMocks.setBaseUrl).toHaveBeenLastCalledWith('http://127.0.0.1:3456')
expect(clientMocks.setAuthToken).toHaveBeenLastCalledWith(null)
expect(globalThis.fetch).toHaveBeenCalledWith(`${window.location.origin}/health`, {
cache: 'no-store',
})
expect(globalThis.fetch).toHaveBeenCalledWith('http://127.0.0.1:3456/health', {
cache: 'no-store',
})
})
it('does not fall back when an explicit Vite desktop server URL returns a SPA fallback', async () => {
vi.useFakeTimers()
clientMocks.defaultBaseUrl = 'http://127.0.0.1:55189'
clientMocks.explicitDefaultBaseUrl = true
globalThis.fetch = vi.fn().mockResolvedValue(
new Response('<!doctype html>', {
status: 200,
headers: { 'content-type': 'text/html' },
}),
) as typeof fetch
const startup = expect(initializeDesktopServerUrl()).rejects.toThrow(
'Server healthcheck failed: healthcheck returned non-JSON response from http://127.0.0.1:55189/health',
)
await vi.runAllTimersAsync()
await startup
expect(clientMocks.setBaseUrl).toHaveBeenLastCalledWith('http://127.0.0.1:55189')
})
it('does not fall back from a loopback dev origin to a non-loopback default backend', async () => {
vi.useFakeTimers()
clientMocks.defaultBaseUrl = 'https://public.example.com'
globalThis.fetch = vi.fn().mockResolvedValue(
new Response('<!doctype html>', {
status: 200,
headers: { 'content-type': 'text/html' },
}),
) as typeof fetch
const startup = expect(initializeDesktopServerUrl()).rejects.toThrow(
`Server healthcheck failed: healthcheck returned non-JSON response from ${window.location.origin}/health`,
)
await vi.runAllTimersAsync()
await startup
expect(clientMocks.setBaseUrl).toHaveBeenLastCalledWith(window.location.origin)
})
it('does not fall back from a loopback dev origin to an invalid default backend', async () => {
vi.useFakeTimers()
clientMocks.defaultBaseUrl = 'not-a-url'
globalThis.fetch = vi.fn().mockResolvedValue(
new Response('<!doctype html>', {
status: 200,
@ -201,7 +272,6 @@ describe('desktopRuntime browser H5 bootstrap', () => {
await startup
expect(clientMocks.setBaseUrl).toHaveBeenLastCalledWith(window.location.origin)
expect(clientMocks.setAuthToken).toHaveBeenLastCalledWith(null)
})
it('prefers an explicit Vite desktop server URL over the dev server origin', async () => {

View File

@ -185,11 +185,17 @@ async function initializeBrowserServerUrl(fallbackUrl: string) {
const queryToken = normalizeToken(query?.get('h5Token') ?? query?.get('token'))
const stored = readStoredH5Connection()
const configuredUrl = getConfiguredBrowserServerUrl(fallbackUrl)
const sameOriginUrl = getSameOriginServerUrl()
const requestedUrl =
normalizeServerUrl(queryUrl) ??
configuredUrl ??
stored.serverUrl ??
fallbackUrl
const requestedImplicitSameOrigin =
!queryUrl &&
!hasExplicitDefaultBaseUrl() &&
!!sameOriginUrl &&
requestedUrl === sameOriginUrl
const token = queryToken ?? stored.token
const browserH5Runtime = requiresH5AuthForServerUrl(requestedUrl)
@ -202,6 +208,20 @@ async function initializeBrowserServerUrl(fallbackUrl: string) {
try {
await waitForHealth(requestedUrl)
} catch (error) {
if (shouldFallbackFromLoopbackDevOrigin({
error,
requestedUrl,
fallbackUrl,
requestedImplicitSameOrigin,
})) {
setBaseUrl(fallbackUrl)
setAuthToken(null)
await waitForHealth(fallbackUrl)
await ensureBrowserApiAccessibleWithoutH5(fallbackUrl)
markDesktopServerReady()
return fallbackUrl
}
if (browserH5Runtime) {
clearStoredH5Token()
throw normalizeBrowserH5Error(error, requestedUrl)
@ -255,6 +275,7 @@ async function waitForHealth(serverUrl: string) {
const contentType = response.headers.get('content-type') ?? ''
if (!contentType.toLowerCase().includes('application/json')) {
lastError = new Error(`healthcheck returned non-JSON response from ${serverUrl}/health`)
break
} else {
const body = await response.json().catch(() => null)
if (body && typeof body === 'object' && 'status' in body && body.status === 'ok') {
@ -332,9 +353,56 @@ function getConfiguredBrowserServerUrl(fallbackUrl: string) {
return getSameOriginServerUrl()
}
function shouldFallbackFromLoopbackDevOrigin({
error,
requestedUrl,
fallbackUrl,
requestedImplicitSameOrigin,
}: {
error: unknown
requestedUrl: string
fallbackUrl: string
requestedImplicitSameOrigin: boolean
}) {
if (!requestedImplicitSameOrigin || requestedUrl === fallbackUrl) {
return false
}
if (!isLoopbackServerUrl(requestedUrl) || !isLoopbackServerUrl(fallbackUrl)) {
return false
}
return error instanceof Error &&
error.message.includes('healthcheck returned non-JSON response')
}
export function isLoopbackHostname(hostname: string) {
const normalized = hostname.trim().replace(/^\[/, '').replace(/\]$/, '').toLowerCase()
return normalized === '127.0.0.1' || normalized === 'localhost' || normalized === '::1'
return normalized === 'localhost' || normalized === '::1' || isLoopbackIPv4(normalized)
}
function isLoopbackServerUrl(serverUrl: string) {
try {
return isLoopbackHostname(new URL(serverUrl).hostname)
} catch {
return false
}
}
function isLoopbackIPv4(hostname: string) {
const parts = hostname.split('.')
if (parts.length !== 4 || parts[0] !== '127') {
return false
}
return parts.every((part) => {
if (!/^\d+$/.test(part)) {
return false
}
const value = Number(part)
return value >= 0 && value <= 255
})
}
export function requiresH5AuthForServerUrl(serverUrl: string, browserHostname = getBrowserHostname()) {

View File

@ -289,17 +289,17 @@ describe('remote H5 auth and CORS integration', () => {
})
})
test('blocks localhost browser capability requests while H5 access is disabled', async () => {
const response = await fetch(`${baseUrl}/api/status`, {
headers: {
Origin: 'http://127.0.0.1:5179',
},
})
test('allows loopback browser capability requests while H5 access is disabled', async () => {
for (const origin of ['http://127.0.0.1:2024', 'http://localhost:5179', 'http://[::1]:5173']) {
const response = await fetch(`${baseUrl}/api/status`, {
headers: { Origin: origin },
})
expect(response.status).toBe(403)
await expect(response.json()).resolves.toMatchObject({
error: 'Forbidden',
})
expect(response.status).toBe(200)
await expect(response.json()).resolves.toMatchObject({
status: 'ok',
})
}
})
test('does not keep retired Tauri origins trusted after Electron replacement', async () => {
@ -358,21 +358,22 @@ describe('remote H5 auth and CORS integration', () => {
expect(previewResponse.status).toBe(403)
})
test('blocks loopback browser local-file and preview-fs requests while H5 access is disabled', async () => {
test('allows loopback browser local-file and preview-fs requests through the H5 gate while H5 access is disabled', async () => {
const loopbackBrowserOrigin = 'http://localhost:5173'
const localFileResponse = await fetch(localFileUrl(baseUrl, path.join(tmpDir, 'dist', 'index.html')), {
const localFileResponse = await fetch(localFileUrl(baseUrl, path.join(process.cwd(), 'package.json')), {
headers: {
Origin: loopbackBrowserOrigin,
},
})
expect(localFileResponse.status).toBe(403)
expect(localFileResponse.status).toBe(200)
const previewResponse = await fetch(`${baseUrl}/preview-fs/h5-auth-test/index.html`, {
headers: {
Origin: loopbackBrowserOrigin,
},
})
expect(previewResponse.status).toBe(403)
expect(previewResponse.status).not.toBe(401)
expect(previewResponse.status).not.toBe(403)
})
test('blocks remote browser SDK requests while H5 access is disabled', async () => {
@ -759,35 +760,26 @@ describe('remote H5 auth and CORS integration', () => {
expect(missingPreviewToken.status).toBe(401)
})
test('requires H5 token for loopback browser local-file and preview-fs requests when H5 access is enabled', async () => {
test('keeps loopback browser local-file and preview-fs requests tokenless when H5 access is enabled', async () => {
const loopbackBrowserOrigin = 'http://localhost:5173'
const token = await enableH5Access({
allowedOrigins: [loopbackBrowserOrigin],
})
await enableH5Access()
const localFile = localFileUrl(baseUrl, path.join(process.cwd(), 'package.json'))
const missingLocalFileToken = await fetch(localFile, {
const localFileResponse = await fetch(localFile, {
headers: {
Origin: loopbackBrowserOrigin,
},
})
expect(missingLocalFileToken.status).toBe(401)
expect(localFileResponse.status).toBe(200)
await expect(localFileResponse.text()).resolves.toContain('"name"')
const validLocalFileToken = await fetch(localFile, {
headers: {
Origin: loopbackBrowserOrigin,
Authorization: `Bearer ${token}`,
},
})
expect(validLocalFileToken.status).toBe(200)
await expect(validLocalFileToken.text()).resolves.toContain('"name"')
const missingPreviewToken = await fetch(`${baseUrl}/preview-fs/h5-auth-test/index.html`, {
const previewResponse = await fetch(`${baseUrl}/preview-fs/h5-auth-test/index.html`, {
headers: {
Origin: loopbackBrowserOrigin,
},
})
expect(missingPreviewToken.status).toBe(401)
expect(previewResponse.status).not.toBe(401)
expect(previewResponse.status).not.toBe(403)
})
test('does not allow the server API key to replace the H5 token for remote browser requests', async () => {

View File

@ -17,7 +17,10 @@ describe('h5AccessPolicy', () => {
test('recognizes loopback hosts as local trusted requests', () => {
expect(isLoopbackHost('localhost')).toBe(true)
expect(isLoopbackHost('127.0.0.1')).toBe(true)
expect(isLoopbackHost('127.0.1.1')).toBe(true)
expect(isLoopbackHost('[::1]')).toBe(true)
expect(isLoopbackHost('127.example.com')).toBe(false)
expect(isLoopbackHost('127.bad.0.1')).toBe(false)
expect(isLoopbackHost('192.168.0.20')).toBe(false)
})
@ -59,32 +62,40 @@ describe('h5AccessPolicy', () => {
expect(shouldRequireH5Token({ request, url: new URL(request.url), h5Enabled: true, context: localContext })).toBe(false)
})
test('does not trust loopback browser origins for H5 capability routes', () => {
test('keeps loopback browser origins tokenless for local dev capability routes', () => {
for (const pathname of [
'/api/status',
'/api/adapters',
'/proxy/openai/v1/chat/completions',
'/ws/session-1',
'/local-file/Users/alice/report.html',
'/preview-fs/session-1/index.html',
]) {
const request = req(`http://127.0.0.1:3456${pathname}`, {
headers: { Origin: 'http://localhost:5173' },
})
expect(classifyH5Request(request, new URL(request.url), localContext)).toBe('h5-browser')
expect(shouldRequireH5Token({ request, url: new URL(request.url), h5Enabled: true, context: localContext })).toBe(true)
expect(shouldBlockDisabledH5Access({
request,
url: new URL(request.url),
h5Enabled: false,
explicitAuthRequired: false,
context: localContext,
})).toBe(true)
for (const origin of [
'http://localhost:5173',
'http://127.0.0.1:2024',
'http://127.0.1.1:2024',
'http://[::1]:5173',
]) {
const request = req(`http://127.0.0.1:3456${pathname}`, {
headers: { Origin: origin },
})
expect(classifyH5Request(request, new URL(request.url), localContext)).toBe('local-trusted')
expect(shouldRequireH5Token({ request, url: new URL(request.url), h5Enabled: true, context: localContext })).toBe(false)
expect(shouldBlockDisabledH5Access({
request,
url: new URL(request.url),
h5Enabled: false,
explicitAuthRequired: false,
context: localContext,
})).toBe(false)
}
}
})
test('does not trust adapter requests from browser origins', () => {
test('does not trust adapter requests from non-loopback browser origins', () => {
const request = req('http://127.0.0.1:3456/api/adapters', {
headers: { Origin: 'http://localhost:5173' },
headers: { Origin: 'https://phone.example' },
})
expect(classifyH5Request(request, new URL(request.url), localContext)).toBe('h5-browser')
expect(shouldRequireH5Token({ request, url: new URL(request.url), h5Enabled: true, context: localContext })).toBe(true)

View File

@ -3,7 +3,6 @@ export type H5RequestContext = {
clientAddress: string | null
}
const LOCAL_HOSTS = new Set(['localhost', '127.0.0.1', '::1'])
const LOCAL_DESKTOP_ORIGINS = new Set(['file://'])
export function normalizeHostname(hostname: string): string {
@ -15,12 +14,43 @@ export function isLoopbackHost(hostname: string): boolean {
if (normalized.startsWith('::ffff:')) {
return isLoopbackHost(normalized.slice('::ffff:'.length))
}
return LOCAL_HOSTS.has(normalized)
return normalized === 'localhost' || normalized === '::1' || isLoopbackIPv4(normalized)
}
function isLoopbackIPv4(hostname: string): boolean {
const parts = hostname.split('.')
if (parts.length !== 4 || parts[0] !== '127') {
return false
}
return parts.every((part) => {
if (!/^\d+$/.test(part)) {
return false
}
const value = Number(part)
return value >= 0 && value <= 255
})
}
function isLoopbackBrowserOrigin(origin: string): boolean {
let parsed: URL
try {
parsed = new URL(origin)
} catch {
return false
}
if (!['http:', 'https:'].includes(parsed.protocol)) {
return false
}
return isLoopbackHost(parsed.hostname)
}
function isLocalDesktopOrNavigationOrigin(origin: string | null): boolean {
if (!origin) return true
return LOCAL_DESKTOP_ORIGINS.has(origin)
return LOCAL_DESKTOP_ORIGINS.has(origin) || isLoopbackBrowserOrigin(origin)
}
function isFilesystemCapabilityPath(pathname: string): boolean {

View File

@ -84,8 +84,32 @@ describe('resolveCors', () => {
}
})
it('does not keep loopback browser origins allowed when H5 token mode is active', async () => {
for (const origin of ['http://localhost:5173', 'http://127.0.0.1:5179']) {
it('keeps loopback browser origins allowed when H5 token mode is active', async () => {
for (const origin of ['http://localhost:3000', 'http://127.0.0.1:2024', 'http://127.0.1.1:2024', 'http://[::1]:5173']) {
const result = await resolveCors(origin, 'http://192.168.0.20:3456', {
h5Enabled: true,
isOriginAllowed: async () => false,
})
expect(result.allowed).toBe(true)
expect(result.rejected).toBe(false)
expect(result.headers['Access-Control-Allow-Origin']).toBe(origin)
}
})
it('keeps missing origins allowed when H5 token mode is active', async () => {
const result = await resolveCors(null, 'http://192.168.0.20:3456', {
h5Enabled: true,
isOriginAllowed: async () => false,
})
expect(result.allowed).toBe(true)
expect(result.rejected).toBe(false)
expect(result.headers['Access-Control-Allow-Origin']).toBe('http://localhost:3000')
})
it('does not keep LAN browser origins allowed when H5 token mode is active', async () => {
for (const origin of ['http://192.168.0.20:2024', 'http://10.0.0.5:5173', 'http://127.example.com:5173', 'http://127.bad.0.1:5173', 'not-a-url']) {
const result = await resolveCors(origin, 'http://192.168.0.20:3456', {
h5Enabled: true,
isOriginAllowed: async () => false,

View File

@ -40,7 +40,44 @@ function isLocalOrigin(origin?: string | null): boolean {
return true
}
return LOCAL_DESKTOP_ORIGINS.has(origin)
return LOCAL_DESKTOP_ORIGINS.has(origin) || isLoopbackBrowserOrigin(origin)
}
function isLoopbackBrowserOrigin(origin: string): boolean {
let parsed: URL
try {
parsed = new URL(origin)
} catch {
return false
}
if (!['http:', 'https:'].includes(parsed.protocol)) {
return false
}
const hostname = parsed.hostname
.trim()
.replace(/^\[/, '')
.replace(/\]$/, '')
.toLowerCase()
return hostname === 'localhost' || hostname === '::1' || isLoopbackIPv4(hostname)
}
function isLoopbackIPv4(hostname: string): boolean {
const parts = hostname.split('.')
if (parts.length !== 4 || parts[0] !== '127') {
return false
}
return parts.every((part) => {
if (!/^\d+$/.test(part)) {
return false
}
const value = Number(part)
return value >= 0 && value <= 255
})
}
export async function resolveCors(