收口宿主壳外链打开协议

共享 HostBridge 契约新增外链 URL 协议白名单校验

Expo 移动壳打开外链前拒绝危险协议

Tauri 桌面壳打开外链前拒绝危险协议

补充共享契约、移动壳和桌面壳外链校验测试

更新宿主壳方案和团队共享决策记录
This commit is contained in:
2026-06-17 22:31:24 +08:00
parent 8b14c6ebe5
commit 61d910400e
7 changed files with 161 additions and 9 deletions

View File

@@ -0,0 +1,27 @@
import { describe, expect, test } from 'vitest';
import { normalizeHostBridgeExternalUrl } from './hostBridge';
describe('HostBridge shared contract helpers', () => {
test('只允许明确的外链协议交给宿主打开', () => {
expect(normalizeHostBridgeExternalUrl(' https://example.com/a ')).toBe(
'https://example.com/a',
);
expect(normalizeHostBridgeExternalUrl('mailto:hi@example.com')).toBe(
'mailto:hi@example.com',
);
expect(normalizeHostBridgeExternalUrl('tel:+12345678')).toBe(
'tel:+12345678',
);
});
test('拒绝空值、控制字符和危险协议', () => {
expect(normalizeHostBridgeExternalUrl('')).toBeNull();
expect(normalizeHostBridgeExternalUrl('javascript:alert(1)')).toBeNull();
expect(normalizeHostBridgeExternalUrl('file:///etc/passwd')).toBeNull();
expect(
normalizeHostBridgeExternalUrl('https://example.com/\nnext'),
).toBeNull();
expect(normalizeHostBridgeExternalUrl('/relative/path')).toBeNull();
});
});

View File

@@ -87,6 +87,49 @@ export type OpenExternalUrlPayload = {
url: string;
};
export const HOST_BRIDGE_EXTERNAL_URL_PROTOCOLS = [
'http:',
'https:',
'mailto:',
'tel:',
] as const;
export type HostBridgeExternalUrlProtocol =
(typeof HOST_BRIDGE_EXTERNAL_URL_PROTOCOLS)[number];
function hasHostBridgeControlCharacter(value: string) {
return [...value].some((character) => {
const codePoint = character.codePointAt(0) ?? 0;
return codePoint <= 31 || codePoint === 127;
});
}
export function normalizeHostBridgeExternalUrl(rawUrl: unknown) {
if (typeof rawUrl !== 'string') {
return null;
}
const urlText = rawUrl.trim();
if (!urlText || hasHostBridgeControlCharacter(urlText)) {
return null;
}
try {
const url = new URL(urlText);
if (
!HOST_BRIDGE_EXTERNAL_URL_PROTOCOLS.includes(
url.protocol as HostBridgeExternalUrlProtocol,
)
) {
return null;
}
return url.toString();
} catch {
return null;
}
}
export type ClipboardWriteTextPayload = {
text: string;
};