收紧移动壳分享链接边界

移动壳分享链接归一到公开主站同源地址

补充协议相对链接和缓存回退拒绝测试

配置检查锁定分享链接归一策略

更新原生壳方案和共享决策记录
This commit is contained in:
2026-06-19 00:53:05 +08:00
parent bb85986d1f
commit af1e95febb
7 changed files with 168 additions and 16 deletions
@@ -12,6 +12,8 @@ const bridgePath = new URL('../src/host-bridge/bridge.ts', import.meta.url);
const bridgeSource = fs.readFileSync(bridgePath, 'utf8');
const dispatchPath = new URL('../src/host-bridge/dispatch.ts', import.meta.url);
const dispatchSource = fs.readFileSync(dispatchPath, 'utf8');
const sharePath = new URL('../src/host-bridge/share.ts', import.meta.url);
const shareSource = fs.readFileSync(sharePath, 'utf8');
const bridgeDirPath = new URL('../src/host-bridge/', import.meta.url);
const bridgeSourceFiles = fs
.readdirSync(bridgeDirPath, { withFileTypes: true })
@@ -945,6 +947,21 @@ for (const snippet of [
}
}
for (const snippet of [
'ALLOWED_PRODUCTION_WEB_ORIGIN',
'normalizePublicShareUrl',
"rawUrl.startsWith('//')",
'url.origin !== ALLOWED_PRODUCTION_WEB_ORIGIN',
]) {
if (!shareSource.includes(snippet)) {
throw new Error(`mobile shell share URL policy missing ${snippet}`);
}
}
if (shareSource.includes("const WEB_APP_ORIGIN = 'https://app.genarrative.world'")) {
throw new Error('mobile shell share URL policy must reuse the shared web origin');
}
for (const snippet of [
'buildMobileShellUrl(',
'HOST_BRIDGE_VERSION.toString()',
@@ -810,6 +810,91 @@ describe('handleMobileHostBridgeMessage', () => {
});
});
test('share.open 只把同源路径归一为公开主站分享 URL', async () => {
const response = await send(
request('share.open', {
title: '测试作品',
path: '/works/detail?work=PZ-2#play',
}),
);
expectOk(response);
expect(Share.share).toHaveBeenCalledWith({
title: '测试作品',
message: 'https://app.genarrative.world/works/detail?work=PZ-2#play',
url: 'https://app.genarrative.world/works/detail?work=PZ-2#play',
});
});
test.each([
'https://example.com/works/detail?work=PZ-1',
'//example.com/works/detail?work=PZ-1',
'//app.genarrative.world/works/detail?work=PZ-1',
'javascript:alert(1)',
])('share.open 拒绝非公开主站 URL%s', async (url) => {
const response = await send(
request('share.open', {
title: '测试作品',
url,
}),
);
expect(expectFailed(response).error.code).toBe('invalid_request');
expect(Share.share).not.toHaveBeenCalled();
});
test('share.open 带非法 URL 时不会回退到缓存分享目标', async () => {
expectOk(
await send(
request('share.setTarget', {
target: {
type: 'genarrative:share-target',
payload: {
title: '暖灯猫街',
work: 'PZ-00000001',
},
},
}),
),
);
vi.mocked(Share.share).mockClear();
const response = await send(
request('share.open', {
title: '测试作品',
url: 'https://example.com/works/detail?work=PZ-1',
}),
);
expect(expectFailed(response).error.code).toBe('invalid_request');
expect(Share.share).not.toHaveBeenCalled();
});
test('share.open 拒绝指向外域的 targetPath', async () => {
const response = await send(
request('share.open', {
title: '测试作品',
targetPath: '//example.com/works/detail?work=PZ-1',
}),
);
expect(expectFailed(response).error.code).toBe('invalid_request');
expect(Share.share).not.toHaveBeenCalled();
});
test('share.open 拒绝协议相对的同源 targetPath', async () => {
const response = await send(
request('share.open', {
title: '测试作品',
targetPath: '//app.genarrative.world/works/detail?work=PZ-1',
}),
);
expect(expectFailed(response).error.code).toBe('invalid_request');
expect(Share.share).not.toHaveBeenCalled();
});
test('share.open 没有可分享内容时拒绝请求', async () => {
const response = await send(request('share.open', {}));
+61 -14
View File
@@ -1,9 +1,13 @@
import { Share } from 'react-native';
import { type ShareOpenPayload } from '../../../../packages/shared/src/contracts/hostBridge';
import { ALLOWED_PRODUCTION_WEB_ORIGIN } from '../shell/url';
import { invalidRequest } from './protocol';
const WEB_APP_ORIGIN = 'https://app.genarrative.world';
type SharePayloadNormalization =
| { status: 'empty' }
| { status: 'invalid' }
| { status: 'valid'; payload: ShareOpenPayload };
function stringField(value: unknown, field: string) {
if (!value || typeof value !== 'object') {
@@ -29,14 +33,31 @@ function shareTargetPayload(value: unknown) {
}
function workDetailUrl(work: string) {
return `${WEB_APP_ORIGIN}/works/detail?work=${encodeURIComponent(work)}`;
return `${ALLOWED_PRODUCTION_WEB_ORIGIN}/works/detail?work=${encodeURIComponent(work)}`;
}
function webAppPathUrl(path: string) {
return new URL(path, WEB_APP_ORIGIN).toString();
function normalizePublicShareUrl(rawUrl: string | undefined) {
if (!rawUrl) {
return undefined;
}
if (rawUrl.startsWith('//')) {
return undefined;
}
try {
const url = new URL(rawUrl, ALLOWED_PRODUCTION_WEB_ORIGIN);
if (url.origin !== ALLOWED_PRODUCTION_WEB_ORIGIN) {
return undefined;
}
return url.toString();
} catch {
return undefined;
}
}
function normalizeSharePayload(value: unknown): ShareOpenPayload | null {
function normalizeSharePayload(value: unknown): SharePayloadNormalization {
const target = shareTargetPayload(value);
const payload =
target && typeof target === 'object'
@@ -44,33 +65,59 @@ function normalizeSharePayload(value: unknown): ShareOpenPayload | null {
: target;
if (!payload || typeof payload !== 'object') {
return null;
return { status: 'empty' };
}
const title = stringField(payload, 'title');
const message = stringField(payload, 'message');
const directUrl = stringField(payload, 'url') ?? stringField(payload, 'href');
const rawDirectUrl = stringField(payload, 'url') ?? stringField(payload, 'href');
const directUrl = normalizePublicShareUrl(rawDirectUrl);
if (rawDirectUrl && !directUrl) {
return { status: 'invalid' };
}
const work = stringField(payload, 'work');
const path = stringField(payload, 'path') ?? stringField(payload, 'targetPath');
const rawPath = stringField(payload, 'path') ?? stringField(payload, 'targetPath');
const pathUrl = normalizePublicShareUrl(rawPath);
if (rawPath && !pathUrl) {
return { status: 'invalid' };
}
const url =
directUrl ??
(work ? workDetailUrl(work) : undefined) ??
(path ? webAppPathUrl(path) : undefined);
pathUrl;
if (!title && !message && !url) {
return null;
return { status: 'empty' };
}
return {
...(title ? { title } : {}),
...(message ? { message } : {}),
...(url ? { url } : {}),
status: 'valid',
payload: {
...(title ? { title } : {}),
...(message ? { message } : {}),
...(url ? { url } : {}),
},
};
}
export async function openShare(payload: unknown, currentShareTarget: unknown) {
const explicitPayload = normalizeSharePayload(payload);
if (explicitPayload.status === 'invalid') {
throw invalidRequest('share target is invalid');
}
const cachedPayload =
explicitPayload.status === 'valid'
? explicitPayload
: normalizeSharePayload(currentShareTarget);
if (cachedPayload.status === 'invalid') {
throw invalidRequest('share target is invalid');
}
const sharePayload =
normalizeSharePayload(payload) ?? normalizeSharePayload(currentShareTarget);
cachedPayload.status === 'valid' ? cachedPayload.payload : undefined;
if (!sharePayload) {
throw invalidRequest('share target is required');
}
+2
View File
@@ -2,6 +2,7 @@ import { describe, expect, test } from 'vitest';
import { HOST_BRIDGE_VERSION } from '../../../../packages/shared/src/contracts/hostBridge';
import {
ALLOWED_PRODUCTION_WEB_ORIGIN,
DEFAULT_MOBILE_SHELL_WEB_URL,
buildMobileShellUrl,
resolveMobileShellBaseWebUrl,
@@ -10,6 +11,7 @@ import {
describe('buildMobileShellUrl', () => {
test('默认 H5 地址指向真实主站', () => {
expect(DEFAULT_MOBILE_SHELL_WEB_URL).toBe('https://app.genarrative.world/');
expect(ALLOWED_PRODUCTION_WEB_ORIGIN).toBe('https://app.genarrative.world');
});
test('为 H5 附加原生移动壳上下文', () => {
+1 -1
View File
@@ -11,7 +11,7 @@ export type MobileShellUrlOptions = {
};
export const DEFAULT_MOBILE_SHELL_WEB_URL = 'https://app.genarrative.world/';
const ALLOWED_PRODUCTION_WEB_ORIGIN = 'https://app.genarrative.world';
export const ALLOWED_PRODUCTION_WEB_ORIGIN = 'https://app.genarrative.world';
const LOCAL_DEVELOPMENT_WEB_HOSTS = new Set([
'127.0.0.1',
'localhost',
@@ -24,6 +24,7 @@
- 2026-06-18 外链接入:H5 新增 `openHostExternalUrl()` facade`native_app` 下会把外链归一化为允许协议的绝对 URL 后请求 `app.openExternalUrl`;ICP备案号和 RPG 资产调试原图入口已优先走宿主系统浏览器,普通浏览器和小程序保留原 `<a>` 行为,宿主不可用或拒绝时回退浏览器外链。
- 2026-06-18 外链协议白名单门禁:`packages/shared/src/contracts/hostBridge.ts``HOST_BRIDGE_EXTERNAL_URL_PROTOCOLS``app.openExternalUrl` 唯一协议来源,当前只允许 `http:``https:``mailto:``tel:`;Expo 直接复用共享归一化逻辑,Tauri Rust 侧必须用 URL parser 镜像同一清单,根级 `npm run check:native-shells` 会拒绝共享契约与桌面壳协议清单漂移。
- 2026-06-18 移动壳 WebView 导航收紧:Expo WebView 自身拦截外域导航时复用 HostBridge 外链协议白名单,只把 `http:``https:``mailto:``tel:` 交给 `Linking.openURL``javascript:``file:`、相对异常路径等危险目标直接阻断,避免离开同源主站后仍保留完整 HostBridge。
- 2026-06-19 移动壳系统分享 URL 边界:Expo `share.open` 调用 React Native 系统分享面板前,只允许把 `url``href``path``targetPath``work` 归一为 `https://app.genarrative.world` 同源公开 URL;外域、协议相对 URL、`javascript:` 等危险目标必须返回 `invalid_request`,且显式非法 payload 不得回退到之前缓存的 `share.setTarget` 目标。分享实现复用移动壳入口 URL 的生产主站 origin,配置检查会拒绝重新声明同值 origin 或移除协议相对 URL 拦截。
- 2026-06-18 能力声明收紧:`packages/shared/src/contracts/hostBridge.ts` 提供 HostBridge method / capability 白名单,H5 的 `getHostRuntime()` 会解析并过滤 `hostCapabilities``openHostShare``writeHostClipboardText``requestHostHapticsImpact``setHostAppTitle``exportHostTextFile` 等 native 能力只在宿主声明对应 capability 后调用。发布分享弹窗只有声明 `share.open` 时才显示“系统分享”,避免旧壳或裁剪壳露出不可用入口。
- 2026-06-18 宿主 runtime 回读:主 App 启动时会通过真实 `host.getRuntime` 回读 Expo / Tauri runtime 并缓存过滤后的能力清单,能力来源为 URL `hostCapabilities` 与宿主真实回包的并集;裁剪壳或旧入口 URL 缺少 `hostCapabilities` 时也能启用真实声明能力,但仍不会仅凭 `native_app` 或 transport 存在推断能力可用。
- 2026-06-18 壳能力防漂移:`npm run mobile-shell:typecheck``npm run desktop-shell:typecheck` 会校验 Expo / Tauri 壳声明的 capability 均来自共享 HostBridge 白名单,并校验壳 runtime 回包、H5 URL `hostCapabilities` 和实现分支保持一致;微信小程序 `WECHAT_HOST_CAPABILITIES` 也由 `miniprogram/host-bridge/protocol.test.js` 反查同一共享白名单。新增能力必须先更新契约和真实壳实现,再通过这些检查。
File diff suppressed because one or more lines are too long