diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index 0e82faf96..975536e94 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -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()', diff --git a/apps/mobile-shell/src/host-bridge/bridge.test.ts b/apps/mobile-shell/src/host-bridge/bridge.test.ts index 68999210d..b0f007779 100644 --- a/apps/mobile-shell/src/host-bridge/bridge.test.ts +++ b/apps/mobile-shell/src/host-bridge/bridge.test.ts @@ -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', {})); diff --git a/apps/mobile-shell/src/host-bridge/share.ts b/apps/mobile-shell/src/host-bridge/share.ts index 52607d232..871d2c7d8 100644 --- a/apps/mobile-shell/src/host-bridge/share.ts +++ b/apps/mobile-shell/src/host-bridge/share.ts @@ -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'); } diff --git a/apps/mobile-shell/src/shell/url.test.ts b/apps/mobile-shell/src/shell/url.test.ts index 5fcd3fe56..7827bc16a 100644 --- a/apps/mobile-shell/src/shell/url.test.ts +++ b/apps/mobile-shell/src/shell/url.test.ts @@ -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 附加原生移动壳上下文', () => { diff --git a/apps/mobile-shell/src/shell/url.ts b/apps/mobile-shell/src/shell/url.ts index a1251fcd7..c8f4c7319 100644 --- a/apps/mobile-shell/src/shell/url.ts +++ b/apps/mobile-shell/src/shell/url.ts @@ -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', diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 4b1cb8048..c08b3b18a 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -24,6 +24,7 @@ - 2026-06-18 外链接入:H5 新增 `openHostExternalUrl()` facade,`native_app` 下会把外链归一化为允许协议的绝对 URL 后请求 `app.openExternalUrl`;ICP备案号和 RPG 资产调试原图入口已优先走宿主系统浏览器,普通浏览器和小程序保留原 `` 行为,宿主不可用或拒绝时回退浏览器外链。 - 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` 反查同一共享白名单。新增能力必须先更新契约和真实壳实现,再通过这些检查。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 070a17a17..29bdb7a66 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -277,7 +277,7 @@ GameBridge 禁止: - iOS / Android 深链打开作品详情、创作页和邀请码。 - 登录和支付先 fallback 到 H5;只把能力边界跑通。 -当前状态:已新增 `apps/mobile-shell/`,通过 Expo development build 运行,`react-native-webview` 加载 H5 URL 并附加 `native_app` 宿主 query。移动壳使用真实品牌图标资产,已接入 `genarrative://` scheme、iOS associated domain 和 Android app link filter,启动和运行时 deep link 只会映射到同源 H5 路径并继续附加 HostBridge 上下文,外域和危险协议回退到默认主站入口。首轮真实能力包括 `host.getRuntime`、`appearance.getColorScheme`、`host.events`、`app.lifecycle`、`network.status`、`network.statusChanged`、`share.open`、`share.setTarget`、`navigation.openNativePage`、`navigation.canGoBack`、`app.reloadWebView`、`app.openExternalUrl`、`clipboard.writeText`、`clipboard.readText`、`file.exportText`、`file.exportImage`、`file.importImage`、`file.captureImage`、`file.importAudio`、`file.exportAudio`、`haptics.impact`、`notification.showLocal` 和 Android 返回键回退;其中 `appearance.getColorScheme` 只读系统配色偏好,不强改 H5 或系统主题;`app.lifecycle` 通过 React Native `AppState` 注入 `active` / `inactive` / `background` 统一状态,供 H5 游戏循环、音频和轮询做真实暂停 / 恢复判断,H5 的 `useHostLifecycleActive()` 会把该事件归一成运行态可播放状态,WebAudio 背景音乐和拼图、抓大鹅等固定玩法 `