收口H5原生导航目标预校验

H5 原生 App 导航请求发送前拒绝外域和危险协议目标

H5 HostBridge 测试覆盖不安全导航目标不触达原生壳

原生壳门禁反查 navigation.openNativePage 预校验
This commit is contained in:
2026-06-20 04:43:50 +08:00
parent 082e056b18
commit 0e807d1fe8
3 changed files with 112 additions and 1 deletions
+13
View File
@@ -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),',
@@ -1346,6 +1346,69 @@ describe('hostBridge', () => {
});
});
test('原生 App 宿主导航请求发送前先拒绝明显不安全目标', async () => {
const invoke = vi.fn(async (_command: string, args?: Record<string, unknown>) => {
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<string, unknown>) => {
+36 -1
View File
@@ -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) {