限制移动壳生产本机H5入口

生产移动壳只允许公共 H5 主站入口

开发模式继续允许显式本机 H5 联调地址

补充移动壳 URL 与 ShellApp 测试和门禁

同步项目决策日志
This commit is contained in:
2026-06-21 19:09:11 +08:00
parent 6b5e75a070
commit 0393e3fef3
7 changed files with 126 additions and 13 deletions
+25 -1
View File
@@ -1920,6 +1920,9 @@ if (shareSource.includes("const WEB_APP_ORIGIN = 'https://app.genarrative.world'
for (const snippet of [
'buildMobileShellUrl(',
'MobileShellBaseWebUrlOptions',
'allowLocalDevelopment?: boolean',
'Boolean(options.allowLocalDevelopment)',
'HOST_BRIDGE_NATIVE_APP_QUERY',
'HOST_BRIDGE_NATIVE_APP_QUERY_KEY',
'HOST_BRIDGE_VERSION.toString()',
@@ -1980,13 +1983,34 @@ for (const snippet of [
}
}
if (
!shellAppSource.includes('allowLocalDevelopment: __DEV__') ||
!deepLinkSource.includes('baseWebUrlOptions: MobileShellBaseWebUrlOptions = {}') ||
!deepLinkSource.includes('resolveMobileShellBaseWebUrl(\n baseWebUrl,\n baseWebUrlOptions,')
) {
throw new Error('mobile shell must only allow local H5 URLs through explicit development mode');
}
for (const snippet of [
'移动壳基准 URL 默认只接受生产主站',
"expect(resolveMobileShellBaseWebUrl('http://127.0.0.1:3000/')).toBe(",
'移动壳基准 URL 只在开发模式接受本机入口',
'allowLocalDevelopment: true',
'production shell ignores local H5 URL env before opening WebView',
'development shell allows explicit local H5 URL env',
]) {
if (!urlTestSource.includes(snippet) && !shellAppTestSource.includes(snippet)) {
throw new Error(`mobile shell local H5 URL tests must include ${snippet}`);
}
}
for (const snippet of [
'MobileShellDeepLinkResolution',
"status: 'default'",
"status: 'mapped'",
"status: 'rejected'",
'resolveMobileShellUrlFromDeepLink',
'resolveMobileShellBaseWebUrl(baseWebUrl)',
'resolveMobileShellBaseWebUrl(\n baseWebUrl,\n baseWebUrlOptions,',
'resolveTargetPath(rawUrl, webOrigin)',
'buildMobileShellUrl(new URL(targetPath, webOrigin).toString(), options)',
]) {
+41 -1
View File
@@ -222,10 +222,16 @@ vi.mock('react-native', () => ({
React.createElement('div', props, children),
}));
async function importShellApp() {
async function importShellApp(isDev = true) {
vi.stubGlobal('__DEV__', isDev);
vi.resetModules();
return (await import('./ShellApp')).default;
}
async function importShellAppWithDevFlag(isDev: boolean) {
return await importShellApp(isDev);
}
function buildRequest(): HostBridgeRequest {
return {
bridge: HOST_BRIDGE_PROTOCOL,
@@ -276,11 +282,45 @@ function lastHostBridgeEvent(eventName: string) {
afterEach(() => {
shellHarness.reset();
vi.unstubAllEnvs();
vi.unstubAllGlobals();
vi.resetModules();
vi.clearAllMocks();
});
describe('ShellApp QR scanner HostBridge flow', () => {
test('production shell ignores local H5 URL env before opening WebView', async () => {
vi.stubEnv('EXPO_PUBLIC_GENARRATIVE_WEB_URL', 'http://127.0.0.1:3000/');
const ShellApp = await importShellAppWithDevFlag(false);
render(<ShellApp />);
const webViewProps = shellHarness.webViewProps.current as {
source?: { uri?: string };
};
const sourceUrl = new URL(webViewProps.source?.uri ?? '');
expect(sourceUrl.origin).toBe('https://app.genarrative.world');
expect(sourceUrl.searchParams.get('clientRuntime')).toBe('native_app');
expect(sourceUrl.searchParams.get('hostShell')).toBe('expo_mobile');
});
test('development shell allows explicit local H5 URL env', async () => {
vi.stubEnv('EXPO_PUBLIC_GENARRATIVE_WEB_URL', 'http://127.0.0.1:3000/');
const ShellApp = await importShellAppWithDevFlag(true);
render(<ShellApp />);
const webViewProps = shellHarness.webViewProps.current as {
source?: { uri?: string };
};
const sourceUrl = new URL(webViewProps.source?.uri ?? '');
expect(sourceUrl.origin).toBe('http://127.0.0.1:3000');
expect(sourceUrl.searchParams.get('clientRuntime')).toBe('native_app');
expect(sourceUrl.searchParams.get('hostShell')).toBe('expo_mobile');
});
test('scanner.scanQrCode drives overlay camera scan and injects HostBridge response into WebView', async () => {
const ShellApp = await importShellApp();
render(<ShellApp />);
+6
View File
@@ -110,6 +110,9 @@ export default function ShellApp() {
const [canGoBack, setCanGoBack] = useState(false);
const baseWebUrl = resolveMobileShellBaseWebUrl(
process.env.EXPO_PUBLIC_GENARRATIVE_WEB_URL,
{
allowLocalDevelopment: __DEV__,
},
);
const urlOptions = useMemo(
() => ({
@@ -242,6 +245,9 @@ export default function ShellApp() {
url,
baseWebUrl,
urlOptions,
{
allowLocalDevelopment: __DEV__,
},
);
if (resolution.status === 'rejected') {
logMobileShellDeepLinkFailure(`${source}.rejected`, url);
+13 -2
View File
@@ -1,5 +1,6 @@
import {
buildMobileShellUrl,
type MobileShellBaseWebUrlOptions,
resolveMobileShellBaseWebUrl,
type MobileShellUrlOptions,
} from './url';
@@ -59,16 +60,26 @@ export function buildMobileShellUrlFromDeepLink(
rawUrl: string | null | undefined,
baseWebUrl: string,
options: MobileShellUrlOptions,
baseWebUrlOptions: MobileShellBaseWebUrlOptions = {},
) {
return resolveMobileShellUrlFromDeepLink(rawUrl, baseWebUrl, options).url;
return resolveMobileShellUrlFromDeepLink(
rawUrl,
baseWebUrl,
options,
baseWebUrlOptions,
).url;
}
export function resolveMobileShellUrlFromDeepLink(
rawUrl: string | null | undefined,
baseWebUrl: string,
options: MobileShellUrlOptions,
baseWebUrlOptions: MobileShellBaseWebUrlOptions = {},
): MobileShellDeepLinkResolution {
const normalizedBaseWebUrl = resolveMobileShellBaseWebUrl(baseWebUrl);
const normalizedBaseWebUrl = resolveMobileShellBaseWebUrl(
baseWebUrl,
baseWebUrlOptions,
);
const defaultUrl = buildMobileShellUrl(normalizedBaseWebUrl, options);
if (!rawUrl) {
return {
+20 -4
View File
@@ -89,17 +89,17 @@ describe('buildMobileShellUrl', () => {
);
});
test('移动壳基准 URL 只接受生产主站和本机开发入口', () => {
test('移动壳基准 URL 默认只接受生产主站', () => {
expect(
resolveMobileShellBaseWebUrl('https://app.genarrative.world/path'),
).toBe(
'https://app.genarrative.world/path',
);
expect(resolveMobileShellBaseWebUrl(' http://127.0.0.1:3000/ ')).toBe(
'http://127.0.0.1:3000/',
expect(resolveMobileShellBaseWebUrl('http://127.0.0.1:3000/')).toBe(
DEFAULT_MOBILE_SHELL_WEB_URL,
);
expect(resolveMobileShellBaseWebUrl('http://localhost:3000/')).toBe(
'http://localhost:3000/',
DEFAULT_MOBILE_SHELL_WEB_URL,
);
expect(resolveMobileShellBaseWebUrl('https://example.com/path')).toBe(
DEFAULT_MOBILE_SHELL_WEB_URL,
@@ -119,6 +119,22 @@ describe('buildMobileShellUrl', () => {
);
});
test('移动壳基准 URL 只在开发模式接受本机入口', () => {
const options = {
allowLocalDevelopment: true,
};
expect(
resolveMobileShellBaseWebUrl(' http://127.0.0.1:3000/ ', options),
).toBe('http://127.0.0.1:3000/');
expect(resolveMobileShellBaseWebUrl('http://localhost:3000/', options)).toBe(
'http://localhost:3000/',
);
expect(resolveMobileShellBaseWebUrl('http://[::1]:3000/', options)).toBe(
'http://[::1]:3000/',
);
});
test('非法启动 URL 回退到默认 H5 地址并继续附加宿主上下文', () => {
const url = new URL(
buildMobileShellUrl('javascript:alert(1)', {
+20 -5
View File
@@ -14,6 +14,10 @@ export type MobileShellUrlOptions = {
capabilities: readonly HostBridgeCapability[];
};
export type MobileShellBaseWebUrlOptions = {
allowLocalDevelopment?: boolean;
};
export const DEFAULT_MOBILE_SHELL_WEB_URL = HOST_BRIDGE_PUBLIC_WEB_URL;
export const ALLOWED_PRODUCTION_WEB_ORIGIN = HOST_BRIDGE_PUBLIC_WEB_ORIGIN;
const LOCAL_DEVELOPMENT_WEB_HOSTS = new Set([
@@ -22,16 +26,23 @@ const LOCAL_DEVELOPMENT_WEB_HOSTS = new Set([
'[::1]',
]);
function isAllowedMobileShellBaseUrl(url: URL) {
function isAllowedMobileShellBaseUrl(
url: URL,
options: MobileShellBaseWebUrlOptions = {},
) {
if (url.origin === ALLOWED_PRODUCTION_WEB_ORIGIN) {
return true;
}
return url.protocol === 'http:' &&
return Boolean(options.allowLocalDevelopment) &&
url.protocol === 'http:' &&
LOCAL_DEVELOPMENT_WEB_HOSTS.has(url.hostname);
}
export function resolveMobileShellBaseWebUrl(rawUrl: unknown) {
export function resolveMobileShellBaseWebUrl(
rawUrl: unknown,
options: MobileShellBaseWebUrlOptions = {},
) {
if (typeof rawUrl !== 'string') {
return DEFAULT_MOBILE_SHELL_WEB_URL;
}
@@ -43,7 +54,7 @@ export function resolveMobileShellBaseWebUrl(rawUrl: unknown) {
try {
const url = new URL(value);
if (!isAllowedMobileShellBaseUrl(url)) {
if (!isAllowedMobileShellBaseUrl(url, options)) {
return DEFAULT_MOBILE_SHELL_WEB_URL;
}
@@ -57,7 +68,11 @@ export function buildMobileShellUrl(
rawUrl: string,
options: MobileShellUrlOptions,
) {
const url = new URL(resolveMobileShellBaseWebUrl(rawUrl));
const url = new URL(
resolveMobileShellBaseWebUrl(rawUrl, {
allowLocalDevelopment: true,
}),
);
url.searchParams.set(
HOST_BRIDGE_NATIVE_APP_QUERY_KEY.clientRuntime,
HOST_BRIDGE_NATIVE_APP_QUERY.clientRuntime,
@@ -189,6 +189,7 @@
- 2026-06-20 桌面 HostBridge replay 内部失败边界:Tauri `HostBridgeReplayState` 的 cache lock、slot lock 和 condvar wait 异常不得 panic,也不得把 Rust 内部错误细节回传给 H5;桌面壳只写 `desktop host bridge replay failed for ...` 固定阶段标签,不把 mutex / condvar 错误文本写入 stderr,并统一返回 `host_error: desktop host bridge request failed`。桌面配置检查反查 `reserve(...)``Result` 出口、稳定错误响应、label-only 诊断和 poison lock 单测。
- 2026-06-20 移动 HostBridge runtime 能力回包边界:Expo `host.getRuntime` 回包里的 `capabilities` 必须直接等于共享契约 `HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES``HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES`,并使用与 `platform` 字段一致的归一平台值选择 profile;移动壳 runtime 单测和配置检查反查精确 profile 断言,避免 H5 实际消费的能力回包与入口 URL 能力 query 或共享 profile 分叉。
- 2026-06-20 移动 production bundle 宿主上下文边界:`apps/mobile-shell/scripts/check-expo-export.mjs` 必须读取 iOS / Android Metro export bundle,确认可分发 bundle metadata 是 Metro version 0、只包含当前平台 `fileMetadata`、指向 Hermes `AppEntry-*.hbc`,且 bundle 包含共享生产 H5 URL、`native_app``expo_mobile``hostCapabilities``hostVersion``bridgeVersion`,并不包含本机开发 H5 URL;移动壳配置检查反查 export smoke 的这些 token 和 metadata 结构门禁,避免 production bundle 丢失宿主上下文、混入本机入口或导出形态漂移。
- 2026-06-21 移动壳本机 H5 入口边界:`EXPO_PUBLIC_GENARRATIVE_WEB_URL` 只允许生产主站或开发态显式本机 H5 联调地址;`ShellApp` 必须用 `__DEV__` 控制 `allowLocalDevelopment`production runtime 遇到 `127.0.0.1``localhost``::1` 时回退到共享生产主站。Deep Link 入口复用同一基准 URL 归一选项,避免可分发移动壳被环境变量带到本机调试页面。
- 2026-06-20 桌面 release 主窗口宿主上下文边界:Tauri release 配置只保留基础 `index.html`,Rust app 装配层必须在主窗口启动配置单测里断言补齐 `clientRuntime``clientType``hostShell``hostPlatform``hostVersion``bridgeVersion``hostCapabilities`;桌面单端配置检查反查这些断言存在,避免首屏 H5 丢失桌面壳运行态 query 后只靠 runtime 回读补救。
- 2026-06-20 移动壳协议 helper 单测边界:`apps/mobile-shell/src/host-bridge/protocol.test.ts` 直接覆盖 Expo 移动壳 HostBridge JSON 解析、envelope 和 request id 校验、未知 method 拒绝、ok / failure 响应包装、unsupported / invalid_request 错误构造,以及 native helper 错误归一时只透传共享错误码与字符串 message,不泄露非法错误码、nativeStack 或其它私有字段;根级 `npm run check:native-shells` 会把该测试文件列入移动桥接层结构清单,避免协议边界只靠完整 bridge 流程间接覆盖。
- 2026-06-20 移动扫码 overlay 单测边界:`apps/mobile-shell/src/shell/QrScannerOverlay.test.tsx` 直接覆盖移动扫码 overlay 的相机权限请求、二维码扫码成功、权限拒绝失败和关闭取消;单端配置检查会反查该组件测试存在,根级 `npm run check:native-shells` 会把该测试文件列入移动 shell 层结构清单,避免扫码 UI 容器只靠 `ShellApp.test.tsx` 的完整 HostBridge 流程间接覆盖。