diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index 4ae46e55e..70dab7944 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -1080,6 +1080,19 @@ function assertH5HostBridgePayloadBoundaries() { 'H5 HostBridge facade must normalize app.openExternalUrl payloads with the shared external URL boundary', ); } + if ( + !h5HostBridgeSource.includes('function normalizeNativeAppPageUrl(url: string)') || + !h5HostBridgeSource.includes("trimmedUrl.startsWith('//')") || + !h5HostBridgeSource.includes( + "nativePageUrl.origin !== HOST_BRIDGE_PUBLIC_WEB_ORIGIN", + ) || + !h5HostBridgeSource.includes('const normalizedUrl = normalizeNativeAppPageUrl(url);') || + !h5HostBridgeSource.includes("{ url: normalizedUrl },") + ) { + throw new Error( + 'H5 HostBridge facade must reject unsafe native app navigation targets before sending navigation.openNativePage', + ); + } if ( !h5HostBridgeSource.includes( 'absolutizeHostSharePayloadUrls(params),', diff --git a/src/services/host-bridge/hostBridge.test.ts b/src/services/host-bridge/hostBridge.test.ts index 54bfd6ad6..fb1ef6a8f 100644 --- a/src/services/host-bridge/hostBridge.test.ts +++ b/src/services/host-bridge/hostBridge.test.ts @@ -1346,6 +1346,69 @@ describe('hostBridge', () => { }); }); + test('原生 App 宿主导航请求发送前先拒绝明显不安全目标', async () => { + const invoke = vi.fn(async (_command: string, args?: Record) => { + const request = (args as { request: { id: string } }).request; + return { + bridge: 'GenarrativeHostBridge', + version: 1, + id: request.id, + ok: true, + result: true, + }; + }); + window.history.replaceState( + null, + '', + nativeAppPath(['navigation.openNativePage']), + ); + window.__TAURI__ = { + core: { + invoke: asTauriInvoke(invoke), + }, + }; + + await expect( + navigateHostNativePage('https://example.com/works/detail?work=PZ-1'), + ).resolves.toBe(false); + await expect( + navigateHostNativePage('//example.com/works/detail?work=PZ-1'), + ).resolves.toBe(false); + await expect( + navigateHostNativePage('javascript:alert(1)'), + ).resolves.toBe(false); + await expect( + navigateHostNativePage('https://app.genarrative.world/\nnext'), + ).resolves.toBe(false); + expect(invoke).not.toHaveBeenCalled(); + + await expect( + navigateHostNativePage( + 'https://app.genarrative.world/works/detail?work=PZ-1', + ), + ).resolves.toBe(true); + await expect( + navigateHostNativePage('works/detail?work=PZ-2'), + ).resolves.toBe(true); + + expect(invoke).toHaveBeenCalledWith('host_bridge_request', { + request: expect.objectContaining({ + method: 'navigation.openNativePage', + payload: { + url: 'https://app.genarrative.world/works/detail?work=PZ-1', + }, + }), + }); + expect(invoke).toHaveBeenCalledWith('host_bridge_request', { + request: expect.objectContaining({ + method: 'navigation.openNativePage', + payload: { + url: 'works/detail?work=PZ-2', + }, + }), + }); + }); + test('原生 App HostBridge 写剪贴板前按共享边界截断文本', async () => { const invoke = vi.fn( async (_command: string, args?: Record) => { diff --git a/src/services/host-bridge/hostBridge.ts b/src/services/host-bridge/hostBridge.ts index 54eebcaff..59c9fc2d8 100644 --- a/src/services/host-bridge/hostBridge.ts +++ b/src/services/host-bridge/hostBridge.ts @@ -508,9 +508,13 @@ export async function navigateHostNativePage( if (!canUseNativeHostCapability('navigation.openNativePage')) { return false; } + const normalizedUrl = normalizeNativeAppPageUrl(url); + if (!normalizedUrl) { + return false; + } const result = await requestNativeHostBoolean( 'navigation.openNativePage', - { url }, + { url: normalizedUrl }, ); if (result) { options.beforeNavigate?.(); @@ -604,6 +608,37 @@ function buildAbsoluteUrl(value: string) { return new URL(value, window.location.origin).href; } +function normalizeNativeAppPageUrl(url: string) { + const trimmedUrl = url.trim(); + if (!trimmedUrl || /[\u0000-\u001f\u007f]/u.test(trimmedUrl)) { + return null; + } + + if (trimmedUrl.startsWith('//')) { + return null; + } + + if (/^[a-z][a-z0-9+.-]*:/i.test(trimmedUrl)) { + try { + const nativePageUrl = new URL(trimmedUrl); + if ( + nativePageUrl.protocol !== 'http:' && + nativePageUrl.protocol !== 'https:' + ) { + return null; + } + if (nativePageUrl.origin !== HOST_BRIDGE_PUBLIC_WEB_ORIGIN) { + return null; + } + return nativePageUrl.toString(); + } catch { + return null; + } + } + + return trimmedUrl; +} + function normalizeHostExternalUrlPayload(url: string) { const trimmedUrl = url.trim(); if (!trimmedUrl) {