收紧移动壳分享链接边界
移动壳分享链接归一到公开主站同源地址 补充协议相对链接和缓存回退拒绝测试 配置检查锁定分享链接归一策略 更新原生壳方案和共享决策记录
This commit is contained in:
@@ -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', {}));
|
||||
|
||||
|
||||
@@ -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,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 附加原生移动壳上下文', () => {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user