收紧移动壳文件导入边界

移动壳文本和音频导入读取前校验可信文件大小

补充缺少 picker size 时的 File.size 回归测试

扩展移动壳配置门禁防止绕过读前大小检查

更新 HostBridge 载荷边界文档和共享决策
This commit is contained in:
2026-06-19 03:04:09 +08:00
parent deea0e8655
commit 9fcba9f4a5
6 changed files with 175 additions and 18 deletions
@@ -195,6 +195,33 @@ function extractMobileBridgeHandledMethods(source) {
return [...match[1].matchAll(/case '([^']+)':/g)].map((entry) => entry[1]);
}
function extractFunctionBody(source, functionName) {
const start = source.indexOf(`function ${functionName}`);
if (start === -1) {
throw new Error(`unable to read function ${functionName}`);
}
const openBrace = source.indexOf('{', start);
if (openBrace === -1) {
throw new Error(`unable to read function body ${functionName}`);
}
let depth = 0;
for (let index = openBrace; index < source.length; index += 1) {
const character = source[index];
if (character === '{') {
depth += 1;
} else if (character === '}') {
depth -= 1;
if (depth === 0) {
return source.slice(openBrace + 1, index);
}
}
}
throw new Error(`unable to read complete function body ${functionName}`);
}
function assertNoBlockedMobileChannelDependencies(packageJson, packageLabel) {
const dependencySections = [
'dependencies',
@@ -559,6 +586,24 @@ for (const localBoundary of forbiddenLocalPayloadBoundaryDeclarations) {
}
}
if (!hostBridgeSource.includes('function assertImportedFileSizeWithinLimit')) {
throw new Error('mobile shell must centralize imported file size checks');
}
for (const [functionName, readCall] of [
['importTextFile', 'file.text()'],
['importAudioFile', 'file.base64()'],
]) {
const functionBody = extractFunctionBody(hostBridgeSource, functionName);
const sizeCheckIndex = functionBody.indexOf('assertImportedFileSizeWithinLimit(');
const readIndex = functionBody.indexOf(readCall);
if (sizeCheckIndex === -1 || readIndex === -1 || sizeCheckIndex > readIndex) {
throw new Error(
`mobile shell ${functionName} must check file size before ${readCall}`,
);
}
}
for (const profileSource of [
'HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES',
'HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES',
@@ -79,6 +79,9 @@ vi.mock('expo-clipboard', () => ({
const fileTexts = vi.hoisted(() => new Map<string, string>());
const fileBase64Data = vi.hoisted(() => new Map<string, string>());
const fileSizes = vi.hoisted(() => new Map<string, number | null>());
const fileTextReads = vi.hoisted(() => [] as string[]);
const fileBase64Reads = vi.hoisted(() => [] as string[]);
const writtenFiles = vi.hoisted(
() =>
@@ -109,11 +112,17 @@ vi.mock('expo-file-system', () => ({
});
}
get size() {
return fileSizes.get(this.uri) ?? null;
}
text() {
fileTextReads.push(this.uri);
return Promise.resolve(fileTexts.get(this.uri) ?? '');
}
base64() {
fileBase64Reads.push(this.uri);
return Promise.resolve(fileBase64Data.get(this.uri) ?? '');
}
},
@@ -328,6 +337,9 @@ afterEach(() => {
vi.mocked(Share.share).mockReset();
fileTexts.clear();
fileBase64Data.clear();
fileSizes.clear();
fileTextReads.length = 0;
fileBase64Reads.length = 0;
writtenFiles.length = 0;
resetMobileHostBridgeForTest();
});
@@ -1000,6 +1012,41 @@ describe('handleMobileHostBridgeMessage', () => {
});
});
test('file.importText 在系统选择结果缺少 size 时先用文件大小门禁', async () => {
fileTexts.set('file:///private/mobile/story.md', '暖灯猫街');
fileSizes.set('file:///private/mobile/story.md', 12);
vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({
canceled: false,
assets: [
{
uri: 'file:///private/mobile/story.md',
name: 'story.md',
mimeType: 'text/markdown',
lastModified: 1,
},
],
});
const response = await send(request('file.importText'));
expect(expectOk(response).result).toEqual({
action: 'selected',
fileName: 'story.md',
content: '暖灯猫街',
mimeType: 'text/markdown',
bytes: 12,
});
expect(fileTextReads).toEqual(['file:///private/mobile/story.md']);
fileSizes.set('file:///private/mobile/story.md', 5 * 1024 * 1024 + 1);
fileTextReads.length = 0;
const oversized = await send(request('file.importText'));
expect(expectFailed(oversized).error.code).toBe('invalid_request');
expect(fileTextReads).toEqual([]);
});
test('file.importText 取消选择时返回 cancelled', async () => {
vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({
canceled: true,
@@ -1468,6 +1515,41 @@ describe('handleMobileHostBridgeMessage', () => {
});
});
test('file.importAudio 在系统选择结果缺少 size 时先用文件大小门禁', async () => {
fileBase64Data.set('file:///private/mobile/hit.webm', WEBM_BASE64);
fileSizes.set('file:///private/mobile/hit.webm', 6);
vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({
canceled: false,
assets: [
{
uri: 'file:///private/mobile/hit.webm',
name: 'hit.webm',
mimeType: 'audio/webm',
lastModified: 1,
},
],
});
const response = await send(request('file.importAudio'));
expect(expectOk(response).result).toEqual({
action: 'selected',
fileName: 'hit.webm',
base64Data: WEBM_BASE64,
mimeType: 'audio/webm',
bytes: 6,
});
expect(fileBase64Reads).toEqual(['file:///private/mobile/hit.webm']);
fileSizes.set('file:///private/mobile/hit.webm', 20 * 1024 * 1024 + 1);
fileBase64Reads.length = 0;
const oversized = await send(request('file.importAudio'));
expect(expectFailed(oversized).error.code).toBe('invalid_request');
expect(fileBase64Reads).toEqual([]);
});
test('file.importAudio 允许系统 MIME 缺失时按扩展名和 bytes 校验导入', async () => {
fileBase64Data.set('file:///private/mobile/hit.m4a', MP3_BASE64);
vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({
+42 -12
View File
@@ -294,6 +294,36 @@ function normalizeImportedTextMimeType(
return null;
}
function assertImportedFileSizeWithinLimit(
pickerSize: unknown,
file: File,
maxBytes: number,
message: string,
) {
if (
typeof pickerSize === 'number' &&
Number.isFinite(pickerSize) &&
pickerSize > 0 &&
pickerSize <= maxBytes
) {
return;
}
if (typeof pickerSize === 'number') {
throw invalidRequest(message);
}
const fileSize = file.size;
if (
typeof fileSize !== 'number' ||
!Number.isFinite(fileSize) ||
fileSize <= 0 ||
fileSize > maxBytes
) {
throw invalidRequest(message);
}
}
export async function importTextFile(): Promise<FileImportTextResult> {
const result = await DocumentPicker.getDocumentAsync({
copyToCacheDirectory: true,
@@ -319,14 +349,14 @@ export async function importTextFile(): Promise<FileImportTextResult> {
if (!mimeType) {
throw invalidRequest('mimeType must be an allowed text type');
}
if (
typeof asset.size === 'number' &&
(asset.size <= 0 || asset.size > HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES)
) {
throw invalidRequest('text exceeds file import size limit');
}
const file = new File(asset.uri);
assertImportedFileSizeWithinLimit(
asset.size,
file,
HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES,
'text exceeds file import size limit',
);
const content = await file.text();
const bytes = utf8ByteLength(content);
if (bytes <= 0 || bytes > HOST_BRIDGE_IMPORT_TEXT_MAX_BYTES) {
@@ -636,14 +666,14 @@ export async function importAudioFile(): Promise<FileImportAudioResult> {
if (!mimeType) {
throw invalidRequest('mimeType must be an allowed audio type');
}
if (
typeof asset.size === 'number' &&
(asset.size <= 0 || asset.size > HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES)
) {
throw invalidRequest('audio exceeds file import size limit');
}
const file = new File(asset.uri);
assertImportedFileSizeWithinLimit(
asset.size,
file,
HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES,
'audio exceeds file import size limit',
);
const base64Data = normalizedBase64Data(await file.base64());
if (!base64Data) {
throw invalidRequest('base64Data is required');
@@ -2519,7 +2519,7 @@
## 2026-06-19 HostBridge 载荷边界单一来源
- 背景:文件导入导出、剪贴板、角标、本地通知和 request id 都已经在 Expo 与 Tauri 两套壳里有运行时校验;如果 MIME 清单、字节上限或文本长度只靠人工同步,新增文件类型或调整上限时会出现 H5 契约、移动壳和桌面壳互相漂移。
- 决策:`packages/shared/src/contracts/hostBridge.ts` 是 HostBridge 载荷边界的声明来源,导出文本 / 图片 / 音频 MIME 清单、导入 / 导出字节上限、导出文件名 fallback / 长度上限、request id 长度、角标上限、剪贴板文本长度和本地通知标题 / 正文长度。Expo 移动壳必须直接导入这些共享常量,`apps/mobile-shell/scripts/check-config.mjs` 会拒绝移动壳重新本地声明文件大小或 MIME 清单;Tauri 桌面壳按 Rust 运行时代码镜像实现,`apps/desktop-shell/scripts/check-config.mjs` 必须反查共享契约并拒绝漂移。
- 决策:`packages/shared/src/contracts/hostBridge.ts` 是 HostBridge 载荷边界的声明来源,导出文本 / 图片 / 音频 MIME 清单、导入 / 导出字节上限、导出文件名 fallback / 长度上限、request id 长度、角标上限、剪贴板文本长度和本地通知标题 / 正文长度。Expo 移动壳必须直接导入这些共享常量,`apps/mobile-shell/scripts/check-config.mjs` 会拒绝移动壳重新本地声明文件大小或 MIME 清单;移动壳 `file.importText` / `file.importAudio` 必须在读取文本内容或音频 base64 前,通过 picker `size` 或 Expo `File.size` 拿到可信 byte count 并完成上限校验,无法拿到可信大小时直接拒绝导入。Tauri 桌面壳按 Rust 运行时代码镜像实现,`apps/desktop-shell/scripts/check-config.mjs` 必须反查共享契约并拒绝漂移。
- 影响范围:`packages/shared/src/contracts/hostBridge.ts``apps/mobile-shell/src/host-bridge/files.ts``apps/mobile-shell/scripts/check-config.mjs``apps/desktop-shell/src-tauri/src/host_bridge/``apps/desktop-shell/scripts/check-config.mjs`、Expo / Tauri HostBridge 方案文档。
- 验证方式:`npm run mobile-shell:typecheck``npm run desktop-shell:typecheck``npm run test -- packages/shared/src/contracts/hostBridge.test.ts``npm run check:native-shells``npm run check:encoding``git diff --check`
File diff suppressed because one or more lines are too long
@@ -60,12 +60,12 @@ AI H5 sandbox
- `openHostExternalUrl()`:原生 App 宿主的受控外链入口。H5 中需要离开主站的外链在 `native_app` 下先通过 `app.openExternalUrl` 请求宿主系统浏览器打开;只允许 `http:``https:``mailto:``tel:`,相对路径会先归一化到当前站点绝对 URL。宿主不可用或拒绝时回退浏览器外链行为,普通浏览器和小程序保持原有 `<a>` 语义。
- `navigateHostNativePage()`:受控跳转宿主页,供订阅授权、支付、登录等 adapter 复用。Expo 移动壳首版只接受同源 H5 route 并切换 WebView URLTauri 桌面壳同样只接受 `https://app.genarrative.world` 同源 H5 route 并在主窗口内跳转。真正原生页面、登录和支付能力必须等对应 SDK / 页面接入后再声明支持。
- `exportHostTextFile()`:原生 App 宿主的受控文本导出入口。Expo 移动壳通过 `file.exportText` 写入缓存文本文件并交给系统分享 / 保存面板;Tauri 桌面壳通过 `file.exportText` 打开系统保存对话框并写入用户选择的文件。文件名必须清洗,单次文本不超过 5 MiB,成功只返回文件名和字节数,不把本机绝对路径暴露给 H5;系统分享不可用或用户取消时返回明确错误,由 H5 fallback 承接。
- `importHostTextFile()`:原生 App 宿主的受控文本导入入口。Expo 移动壳通过 Expo DocumentPicker 打开系统文档选择器,Tauri 桌面壳通过系统文件选择框读取用户选择的文本文件;两端都只接受 `text/plain``text/markdown``text/csv``application/json` 或对应扩展名,单次不超过 5 MiB,成功只返回清洗后的文件名、MIME、UTF-8 文本内容和字节数,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力;用户取消时由 H5 facade 归为 `false`。创作 Agent 工作台在 `native_app` 且声明该能力时优先调用宿主文本导入,并把结果转换成现有浏览器 `File` 后继续复用后端 `/api/runtime/creation-agent/document-inputs/parse` 解析链路;普通浏览器、小程序和未声明能力的裁剪壳继续使用原文件输入。
- `importHostTextFile()`:原生 App 宿主的受控文本导入入口。Expo 移动壳通过 Expo DocumentPicker 打开系统文档选择器,Tauri 桌面壳通过系统文件选择框读取用户选择的文本文件;两端都只接受 `text/plain``text/markdown``text/csv``application/json` 或对应扩展名,单次不超过 5 MiB,成功只返回清洗后的文件名、MIME、UTF-8 文本内容和字节数,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力;宿主必须在读取文本内容前拿到可信 byte count 并完成上限校验,移动壳在 picker 缺少 `size` 时改用 Expo `File.size`,仍拿不到可信大小时直接拒绝导入;用户取消时由 H5 facade 归为 `false`。创作 Agent 工作台在 `native_app` 且声明该能力时优先调用宿主文本导入,并把结果转换成现有浏览器 `File` 后继续复用后端 `/api/runtime/creation-agent/document-inputs/parse` 解析链路;普通浏览器、小程序和未声明能力的裁剪壳继续使用原文件输入。
- `exportHostImageFile()`:原生 App 宿主的受控图片导出入口。H5 只传自己生成的图片 `base64Data`、清洗后的文件名和允许的 `image/png` / `image/jpeg` / `image/webp` MIME;Expo 移动壳写入缓存图片后交给系统分享 / 保存面板,Tauri 桌面壳打开系统保存对话框并写入图片字节。单次图片不超过 5 MiB,成功只返回文件名和字节数,不回传本机绝对路径。当前分享卡下载在 native app 中优先走 `file.exportImage`,宿主未声明时保留浏览器下载路径。
- `importHostImageFile()` / `captureHostImageFile()` / `subscribeHostImageDrop()`:原生 App 宿主的受控图片导入入口。Expo 移动壳通过 Expo ImagePicker 请求相册权限并打开系统相册选择器,也可在声明 `file.captureImage` 时请求相机权限并打开系统相机拍摄图片;Tauri 壳通过系统文件选择框或主窗口拖拽事件读取用户选择 / 拖入的图片,不声明拍摄能力。图片能力都只接受 `image/png``image/jpeg``image/webp`,单次不超过 10 MiB,成功只返回文件名、MIME、base64 内容、字节数和可选拖入坐标,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力;移动拍摄不请求麦克风权限。H5 的通用图片输入面板 `CreativeImageInputPanel``native_app` 且声明 `file.importImage` / `file.captureImage` 时分别调用宿主导入 / 拍摄,并把结果转换成现有 `File` 回调;反馈页上传凭证、个人资料头像上传和方洞结果页图片槽位上传在 `native_app` 且声明 `file.importImage` 时同样优先调用宿主图片导入,其中反馈页继续复用原有数量、大小、data URL 和提交 payload 校验,头像继续复用 H5 侧图片类型、5 MiB 大小限制、方形裁剪与 `updateAuthProfile` 上传链路,方洞结果页继续把图片内容写回当前封面 / 背景 / 形状 / 洞口槽位并走现有自动保存和发布链路;在桌面壳同时声明 `file.imageDropped` 时,只有拖入坐标命中当前主图卡片且未被上层元素遮挡的面板会消费该事件。普通浏览器、小程序和未声明能力的裁剪壳继续使用浏览器文件输入。
HostBridge 事件名以 `packages/shared/src/contracts/hostBridge.ts``HOST_BRIDGE_EVENTS` 为唯一白名单,当前为 `app.lifecycle``network.statusChanged``navigation.canGoBack``file.imageDropped`;事件名必须同时进入 capability 白名单。Expo 壳事件注入使用共享 `HostBridgeEventName` 类型,Tauri 壳 `shell/events.rs` 镜像同一清单并拒绝未知事件,H5 `nativeAppHostBridge` 只分发共享白名单内事件。
- `importHostAudioFile()`:原生 App 宿主的受控音频导入入口。Expo 移动壳通过 Expo DocumentPicker 打开系统音频选择器,Tauri 壳通过系统文件选择框读取用户选择的音频;两端都只接受 `audio/mpeg``audio/mp4``audio/wav``audio/ogg``audio/webm` 或对应扩展名,单次不超过 20 MiB,成功只返回清洗后的文件名、MIME、base64 内容和字节数,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力。H5 的通用音频输入面板 `CreativeAudioInputPanel``native_app` 且声明 `file.importAudio` 时优先调用宿主导入,并把结果转换成现有 `File` 后继续复用 `readFileAsAsset(file, 'uploaded')` 音频处理链路;普通浏览器、小程序和未声明能力的裁剪壳继续使用浏览器文件输入。
- `importHostAudioFile()`:原生 App 宿主的受控音频导入入口。Expo 移动壳通过 Expo DocumentPicker 打开系统音频选择器,Tauri 壳通过系统文件选择框读取用户选择的音频;两端都只接受 `audio/mpeg``audio/mp4``audio/wav``audio/ogg``audio/webm` 或对应扩展名,单次不超过 20 MiB,成功只返回清洗后的文件名、MIME、base64 内容和字节数,不暴露设备本地 URI 或本机绝对路径,也不开放通用文件系统能力;宿主必须在读取音频内容或生成 base64 前拿到可信 byte count 并完成上限校验,移动壳在 picker 缺少 `size` 时改用 Expo `File.size`,仍拿不到可信大小时直接拒绝导入。H5 的通用音频输入面板 `CreativeAudioInputPanel``native_app` 且声明 `file.importAudio` 时优先调用宿主导入,并把结果转换成现有 `File` 后继续复用 `readFileAsAsset(file, 'uploaded')` 音频处理链路;普通浏览器、小程序和未声明能力的裁剪壳继续使用浏览器文件输入。
- `exportHostAudioFile()`:原生 App 宿主的受控音频导出入口。H5 只传当前页面已持有的音频 `base64Data`、清洗后的文件名和允许的 `audio/mpeg` / `audio/mp4` / `audio/wav` / `audio/ogg` / `audio/webm` MIME;Expo 移动壳写入缓存音频后交给系统分享 / 保存面板,Tauri 壳打开系统保存对话框并写入音频字节。单次音频不超过 20 MiB,成功只返回文件名和字节数,不回传本机绝对路径,也不让宿主代读任意本地文件。H5 的通用音频输入面板只在当前资产包含本地 `Blob``fileName` 和允许 MIME 且宿主声明 `file.exportAudio` 时展示导出入口;远端已上传音频、浏览器、小程序和未声明能力的裁剪壳不展示该入口。
## 迁移顺序