移动壳校验文件真实内容

为移动图片和音频导入导出增加 bytes 头部校验

补充移动 HostBridge 文件能力测试与配置门禁

更新宿主壳文件能力文档
This commit is contained in:
2026-06-18 20:54:40 +08:00
parent 19ec8daf37
commit 3deb901179
5 changed files with 254 additions and 26 deletions
@@ -922,6 +922,14 @@ for (const forbiddenNotificationSnippet of [
}
}
for (const forbiddenHostBridgeRuntimeSnippet of ['atob(', 'Buffer.from']) {
if (hostBridgeSource.includes(forbiddenHostBridgeRuntimeSnippet)) {
throw new Error(
`mobile shell HostBridge production code must not rely on ${forbiddenHostBridgeRuntimeSnippet}`,
);
}
}
if (
!/trigger:\s*Platform\.OS === 'android'\s*\?\s*\{\s*channelId: LOCAL_NOTIFICATION_CHANNEL_ID\s*\}\s*:\s*null/.test(
dispatchSource,
@@ -956,6 +964,12 @@ for (const snippet of [
'ImagePicker.requestCameraPermissionsAsync',
'File(asset.uri)',
'file.base64()',
'detectImageMimeType',
'detectAudioMimeType',
'ensureImageBytesMatchMimeType',
'ensureAudioBytesMatchMimeType',
"'image bytes do not match MIME'",
"'audio bytes do not match MIME'",
'normalizeHostBridgeExportFileName',
'normalizeHostBridgeClipboardText',
'normalizeHostBridgeHapticsImpactStyle',
+106 -25
View File
@@ -53,6 +53,22 @@ const TEST_MOBILE_URL_OPTIONS: MobileShellUrlOptions = {
capabilities: ['host.getRuntime', 'share.open'],
};
function encodeBytes(bytes: readonly number[]) {
return Buffer.from(bytes).toString('base64');
}
const PNG_BASE64 = encodeBytes([
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0, 0, 0, 0,
]);
const JPEG_BASE64 = encodeBytes([0xff, 0xd8, 0xff, 0xe0, 0, 0, 0]);
const WAV_BASE64 = Buffer.from('RIFF\x04\x00\x00\x00WAVE', 'binary').toString(
'base64',
);
const MP3_BASE64 = Buffer.from('ID3\x04\x00\x00\x00\x00\x00\x10', 'binary').toString(
'base64',
);
const WEBM_BASE64 = encodeBytes([0x1a, 0x45, 0xdf, 0xa3, 0x01, 0x00]);
vi.mock('expo-clipboard', () => ({
getStringAsync: vi.fn(),
setStringAsync: vi.fn(),
@@ -928,7 +944,7 @@ describe('handleMobileHostBridgeMessage', () => {
const response = await send(
request('file.exportImage', {
fileName: ' ../分享:卡?.png ',
base64Data: 'c2hhcmUtY2FyZA==',
base64Data: PNG_BASE64,
mimeType: 'image/png',
}),
);
@@ -938,12 +954,12 @@ describe('handleMobileHostBridgeMessage', () => {
expect(okResponse.result).toEqual({
action: 'saved',
fileName: '分享-卡-.png',
bytes: 10,
bytes: 12,
});
expect(writtenFiles).toEqual([
{
uri: 'file:///cache/分享-卡-.png',
content: 'c2hhcmUtY2FyZA==',
content: PNG_BASE64,
options: { encoding: 'base64' },
},
]);
@@ -961,7 +977,7 @@ describe('handleMobileHostBridgeMessage', () => {
const unsupportedMime = await send(
request('file.exportImage', {
fileName: '分享卡.txt',
base64Data: 'c2hhcmUtY2FyZA==',
base64Data: PNG_BASE64,
mimeType: 'text/plain',
}),
);
@@ -977,6 +993,18 @@ describe('handleMobileHostBridgeMessage', () => {
);
expect(expectFailed(oversized).error.code).toBe('invalid_request');
const mismatched = await send(
request('file.exportImage', {
fileName: '分享卡.png',
base64Data: JPEG_BASE64,
mimeType: 'image/png',
}),
);
expect(expectFailed(mismatched).error.message).toBe(
'image bytes do not match MIME',
);
expect(writtenFiles).toEqual([]);
expect(Sharing.shareAsync).not.toHaveBeenCalled();
});
@@ -985,7 +1013,7 @@ describe('handleMobileHostBridgeMessage', () => {
const response = await send(
request('file.exportAudio', {
fileName: ' ../敲击:音效?.wav ',
base64Data: 'YXVkaW8=',
base64Data: WAV_BASE64,
mimeType: 'audio/wav',
}),
);
@@ -995,12 +1023,12 @@ describe('handleMobileHostBridgeMessage', () => {
expect(okResponse.result).toEqual({
action: 'saved',
fileName: '敲击-音效-.wav',
bytes: 5,
bytes: 12,
});
expect(writtenFiles).toEqual([
{
uri: 'file:///cache/敲击-音效-.wav',
content: 'YXVkaW8=',
content: WAV_BASE64,
options: { encoding: 'base64' },
},
]);
@@ -1020,7 +1048,7 @@ describe('handleMobileHostBridgeMessage', () => {
const response = await send(
request('file.exportAudio', {
fileName: 'hit.wav',
base64Data: 'YXVkaW8=',
base64Data: WAV_BASE64,
mimeType: 'audio/wav',
}),
);
@@ -1036,7 +1064,7 @@ describe('handleMobileHostBridgeMessage', () => {
const unsupportedMime = await send(
request('file.exportAudio', {
fileName: 'hit.txt',
base64Data: 'YXVkaW8=',
base64Data: WAV_BASE64,
mimeType: 'text/plain',
}),
);
@@ -1059,6 +1087,17 @@ describe('handleMobileHostBridgeMessage', () => {
}),
);
expect(expectFailed(oversized).error.code).toBe('invalid_request');
const mismatched = await send(
request('file.exportAudio', {
fileName: 'hit.wav',
base64Data: MP3_BASE64,
mimeType: 'audio/wav',
}),
);
expect(expectFailed(mismatched).error.message).toBe(
'audio bytes do not match MIME',
);
expect(writtenFiles).toEqual([]);
expect(Sharing.shareAsync).not.toHaveBeenCalled();
});
@@ -1073,8 +1112,8 @@ describe('handleMobileHostBridgeMessage', () => {
height: 80,
type: 'image',
fileName: ' ../参考:图?.png ',
fileSize: 5,
base64: 'aW1hZ2U=',
fileSize: 12,
base64: PNG_BASE64,
mimeType: 'image/png',
},
],
@@ -1085,9 +1124,9 @@ describe('handleMobileHostBridgeMessage', () => {
expect(expectOk(response).result).toEqual({
action: 'selected',
fileName: '参考-图-.png',
base64Data: 'aW1hZ2U=',
base64Data: PNG_BASE64,
mimeType: 'image/png',
bytes: 5,
bytes: 12,
});
expect(ImagePicker.requestMediaLibraryPermissionsAsync).toHaveBeenCalled();
expect(ImagePicker.launchImageLibraryAsync).toHaveBeenCalledWith({
@@ -1145,7 +1184,7 @@ describe('handleMobileHostBridgeMessage', () => {
type: 'image',
fileName: '参考图.gif',
fileSize: 5,
base64: 'aW1hZ2U=',
base64: PNG_BASE64,
mimeType: 'image/gif',
},
],
@@ -1165,7 +1204,7 @@ describe('handleMobileHostBridgeMessage', () => {
type: 'image',
fileName: '参考图.png',
fileSize: 10 * 1024 * 1024 + 1,
base64: 'aW1hZ2U=',
base64: PNG_BASE64,
mimeType: 'image/png',
},
],
@@ -1174,6 +1213,28 @@ describe('handleMobileHostBridgeMessage', () => {
const oversized = await send(request('file.importImage'));
expect(expectFailed(oversized).error.code).toBe('invalid_request');
vi.mocked(ImagePicker.launchImageLibraryAsync).mockResolvedValue({
canceled: false,
assets: [
{
uri: 'file:///private/mobile/参考图.png',
width: 120,
height: 80,
type: 'image',
fileName: '参考图.png',
fileSize: 7,
base64: JPEG_BASE64,
mimeType: 'image/png',
},
],
});
const mismatched = await send(request('file.importImage'));
expect(expectFailed(mismatched).error.message).toBe(
'image bytes do not match MIME',
);
});
test('file.captureImage 调起系统相机并返回受控图片数据', async () => {
@@ -1186,8 +1247,8 @@ describe('handleMobileHostBridgeMessage', () => {
height: 80,
type: 'image',
fileName: null,
fileSize: 6,
base64: 'Y2FtZXJh',
fileSize: 7,
base64: JPEG_BASE64,
mimeType: 'image/jpeg',
},
],
@@ -1198,9 +1259,9 @@ describe('handleMobileHostBridgeMessage', () => {
expect(expectOk(response).result).toEqual({
action: 'captured',
fileName: 'genarrative-import.jpg',
base64Data: 'Y2FtZXJh',
base64Data: JPEG_BASE64,
mimeType: 'image/jpeg',
bytes: 6,
bytes: 7,
});
expect(ImagePicker.requestCameraPermissionsAsync).toHaveBeenCalled();
expect(ImagePicker.launchCameraAsync).toHaveBeenCalledWith({
@@ -1242,7 +1303,7 @@ describe('handleMobileHostBridgeMessage', () => {
});
test('file.importAudio 调起系统文档选择器并返回受控音频数据', async () => {
fileBase64Data.set('file:///private/mobile/hit.webm', 'YXVkaW8=');
fileBase64Data.set('file:///private/mobile/hit.webm', WEBM_BASE64);
vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({
canceled: false,
assets: [
@@ -1250,7 +1311,7 @@ describe('handleMobileHostBridgeMessage', () => {
uri: 'file:///private/mobile/hit.webm',
name: ' ../敲击:音效?.webm ',
mimeType: 'audio/webm',
size: 5,
size: 6,
lastModified: 1,
},
],
@@ -1261,9 +1322,9 @@ describe('handleMobileHostBridgeMessage', () => {
expect(expectOk(response).result).toEqual({
action: 'selected',
fileName: '敲击-音效-.webm',
base64Data: 'YXVkaW8=',
base64Data: WEBM_BASE64,
mimeType: 'audio/webm',
bytes: 5,
bytes: 6,
});
expect(DocumentPicker.getDocumentAsync).toHaveBeenCalledWith({
copyToCacheDirectory: true,
@@ -1288,7 +1349,7 @@ describe('handleMobileHostBridgeMessage', () => {
expect(expectFailed(cancelled).error.code).toBe('cancelled');
fileBase64Data.set('file:///private/mobile/hit.txt', 'YXVkaW8=');
fileBase64Data.set('file:///private/mobile/hit.txt', WEBM_BASE64);
vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({
canceled: false,
assets: [
@@ -1306,7 +1367,7 @@ describe('handleMobileHostBridgeMessage', () => {
expect(expectFailed(unsupportedMime).error.code).toBe('invalid_request');
fileBase64Data.set('file:///private/mobile/hit.webm', 'YXVkaW8=');
fileBase64Data.set('file:///private/mobile/hit.webm', WEBM_BASE64);
vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({
canceled: false,
assets: [
@@ -1323,5 +1384,25 @@ describe('handleMobileHostBridgeMessage', () => {
const oversized = await send(request('file.importAudio'));
expect(expectFailed(oversized).error.code).toBe('invalid_request');
fileBase64Data.set('file:///private/mobile/hit.webm', MP3_BASE64);
vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValue({
canceled: false,
assets: [
{
uri: 'file:///private/mobile/hit.webm',
name: 'hit.webm',
mimeType: 'audio/webm',
size: 10,
lastModified: 1,
},
],
});
const mismatched = await send(request('file.importAudio'));
expect(expectFailed(mismatched).error.message).toBe(
'audio bytes do not match MIME',
);
});
});
+131 -1
View File
@@ -45,6 +45,8 @@ const HOST_BRIDGE_AUDIO_MIME_TYPES = new Set<HostBridgeAudioMimeType>([
'audio/ogg',
'audio/webm',
]);
const BASE64_ALPHABET =
'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
export function utf8ByteLength(value: string) {
let bytes = 0;
@@ -85,6 +87,127 @@ function base64DecodedByteLength(value: string) {
return Math.floor((value.length * 3) / 4) - padding;
}
function base64Bytes(value: string) {
const bytes = new Uint8Array(base64DecodedByteLength(value));
let byteIndex = 0;
for (let index = 0; index < value.length; index += 4) {
const first = BASE64_ALPHABET.indexOf(value[index] ?? 'A');
const second = BASE64_ALPHABET.indexOf(value[index + 1] ?? 'A');
const third =
value[index + 2] === '='
? 0
: BASE64_ALPHABET.indexOf(value[index + 2] ?? 'A');
const fourth =
value[index + 3] === '='
? 0
: BASE64_ALPHABET.indexOf(value[index + 3] ?? 'A');
const chunk = (first << 18) | (second << 12) | (third << 6) | fourth;
if (byteIndex < bytes.length) {
bytes[byteIndex] = (chunk >> 16) & 0xff;
byteIndex += 1;
}
if (byteIndex < bytes.length) {
bytes[byteIndex] = (chunk >> 8) & 0xff;
byteIndex += 1;
}
if (byteIndex < bytes.length) {
bytes[byteIndex] = chunk & 0xff;
byteIndex += 1;
}
}
return bytes;
}
function byteAt(bytes: Uint8Array, index: number) {
return bytes[index] ?? 0;
}
function bytesStartWith(bytes: Uint8Array, header: readonly number[]) {
return header.every((value, index) => byteAt(bytes, index) === value);
}
function riffContainerMatches(bytes: Uint8Array, kind: string) {
return (
bytes.length >= 12 &&
byteAt(bytes, 0) === 0x52 &&
byteAt(bytes, 1) === 0x49 &&
byteAt(bytes, 2) === 0x46 &&
byteAt(bytes, 3) === 0x46 &&
String.fromCharCode(...bytes.slice(8, 12)) === kind
);
}
function detectImageMimeType(base64Data: string): HostBridgeImageMimeType | null {
const bytes = base64Bytes(base64Data);
if (bytesStartWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) {
return 'image/png';
}
if (
bytes.length >= 3 &&
byteAt(bytes, 0) === 0xff &&
byteAt(bytes, 1) === 0xd8 &&
byteAt(bytes, 2) === 0xff
) {
return 'image/jpeg';
}
if (riffContainerMatches(bytes, 'WEBP')) {
return 'image/webp';
}
return null;
}
function detectAudioMimeType(base64Data: string): HostBridgeAudioMimeType | null {
const bytes = base64Bytes(base64Data);
if (
bytesStartWith(bytes, [0x49, 0x44, 0x33]) ||
(bytes.length >= 2 && byteAt(bytes, 0) === 0xff && (byteAt(bytes, 1) & 0xe0) === 0xe0)
) {
return 'audio/mpeg';
}
if (
bytes.length >= 12 &&
byteAt(bytes, 4) === 0x66 &&
byteAt(bytes, 5) === 0x74 &&
byteAt(bytes, 6) === 0x79 &&
byteAt(bytes, 7) === 0x70
) {
return 'audio/mp4';
}
if (riffContainerMatches(bytes, 'WAVE')) {
return 'audio/wav';
}
if (bytesStartWith(bytes, [0x4f, 0x67, 0x67, 0x53])) {
return 'audio/ogg';
}
if (bytesStartWith(bytes, [0x1a, 0x45, 0xdf, 0xa3])) {
return 'audio/webm';
}
return null;
}
function ensureImageBytesMatchMimeType(
base64Data: string,
mimeType: HostBridgeImageMimeType,
) {
if (detectImageMimeType(base64Data) !== mimeType) {
throw invalidRequest('image bytes do not match MIME');
}
}
function ensureAudioBytesMatchMimeType(
base64Data: string,
mimeType: HostBridgeAudioMimeType,
) {
if (detectAudioMimeType(base64Data) !== mimeType) {
throw invalidRequest('audio bytes do not match MIME');
}
}
export async function exportTextFile(
payload: unknown,
): Promise<FileExportTextResult> {
@@ -220,6 +343,7 @@ export async function exportImageFile(
if (bytes > EXPORT_IMAGE_MAX_BYTES) {
throw invalidRequest('image exceeds file export size limit');
}
ensureImageBytesMatchMimeType(base64Data, mimeType as HostBridgeImageMimeType);
const isSharingAvailable = await Sharing.isAvailableAsync();
if (!isSharingAvailable) {
@@ -298,6 +422,7 @@ function imagePickerResultToImportPayload(
if (bytes <= 0 || bytes > IMPORT_IMAGE_MAX_BYTES) {
throw invalidRequest('image exceeds file import size limit');
}
ensureImageBytesMatchMimeType(base64Data, mimeType);
if (
typeof asset.fileSize === 'number' &&
asset.fileSize > IMPORT_IMAGE_MAX_BYTES
@@ -435,6 +560,7 @@ export async function exportAudioFile(
if (bytes <= 0 || bytes > EXPORT_AUDIO_MAX_BYTES) {
throw invalidRequest('audio exceeds file export size limit');
}
ensureAudioBytesMatchMimeType(base64Data, mimeType as HostBridgeAudioMimeType);
const isSharingAvailable = await Sharing.isAvailableAsync();
if (!isSharingAvailable) {
@@ -502,11 +628,15 @@ export async function importAudioFile(): Promise<FileImportAudioResult> {
}
const file = new File(asset.uri);
const base64Data = await file.base64();
const base64Data = normalizedBase64Data(await file.base64());
if (!base64Data) {
throw invalidRequest('base64Data is required');
}
const bytes = base64DecodedByteLength(base64Data);
if (bytes <= 0 || bytes > IMPORT_AUDIO_MAX_BYTES) {
throw invalidRequest('audio exceeds file import size limit');
}
ensureAudioBytesMatchMimeType(base64Data, mimeType);
return {
action: 'selected',
@@ -70,6 +70,7 @@
- 2026-06-18 桌面壳顶层导航边界:Tauri 主 WebView 只允许打包资产 URL 和 `https://app.genarrative.world` 同源 H5 route 留在主窗口;外域 `http:` / `https:`、`mailto:`、`tel:` 导航与 `window.open` 请求交给系统 opener 后拒绝 WebView 留壳;`javascript:`、`file:` 等危险协议直接拒绝。该规则不进入 HostBridge capability,不开放 opener JS guest API,配置检查和 cargo test 覆盖导航策略。
- 2026-06-18 桌面壳默认下载边界:Tauri 主 WebView 的下载事件默认拒绝网页自动下载和 `<a download>` 落盘,桌面文件保存只能通过 `file.exportText`、`file.exportImage`、`file.exportAudio` 等已声明 HostBridge method 进入 Rust 侧系统保存对话框,并继续执行 MIME、大小、文件名清洗和用户确认。该规则不进入 HostBridge capability,配置检查和 cargo test 覆盖下载拒绝策略。
- 2026-06-18 桌面壳文件 bytes 校验:Tauri 图片 / 音频导入导出不得只信扩展名或 H5 声明 MIME;Rust 侧必须识别 PNG / JPEG / WebP、MP3 / MP4-M4A / WAV / OGG / WebM bytes 头部,要求导入文件扩展名对应 MIME 与真实 bytes 匹配,导出 payload 的 `mimeType` 与 `base64Data` 解码 bytes 匹配。不匹配返回 `invalid_request`,继续不暴露本机绝对路径或通用文件系统能力。配置检查和 cargo test 覆盖该边界。
- 2026-06-18 移动壳文件 bytes 校验:Expo 图片 / 音频导入导出不得只信系统 picker 返回 MIME、文件扩展名或 H5 声明 MIME;移动壳必须识别 PNG / JPEG / WebP、MP3 / MP4-M4A / WAV / OGG / WebM base64 bytes 头部,要求导入 MIME 归一结果与真实 bytes 匹配,导出 payload 的 `mimeType` 与 `base64Data` 解码 bytes 匹配。不匹配返回 `invalid_request`,不会写入缓存文件、调起系统分享或把内容回传给 H5。配置检查和移动壳测试覆盖该边界。
- 2026-06-18 桌面壳 DevTools 边界:Tauri 主 WebView 配置必须显式 `devtools=false`,Cargo 依赖不得启用 Tauri `devtools` feature;桌面壳本地调试走普通浏览器和 Vite,不把 debug / release 桌面包变成可打开浏览器检查器的调试容器。配置检查会拒绝主窗口 DevTools 或 release feature 被重新打开。
- 2026-06-18 桌面壳 Tauri 命令白名单:桌面壳源码、Tauri build manifest、主窗口 capability 和本地自动生成权限目录都只能暴露 `host_bridge_request` 一个受控 command;所有桌面能力继续在 Rust 内部按 HostBridge method 白名单分发,不新增可被 H5 直接 `invoke` 的 Tauri command,也不授予插件 JS guest API。检查脚本会拒绝多余 command、权限列表顺序漂移和残留的自动生成权限文件。
- 2026-06-18 HostBridge request id replay:Expo 和 Tauri 壳都必须按 request id 回放首次完成结果;同 id 进行中的请求共享同一执行结果,已完成请求直接回放缓存响应,避免系统分享、外链、剪贴板、文件选择 / 保存、本地通知、窗口导航等宿主副作用被重复触发。两端配置检查和测试会锁住 replay 结构。
@@ -279,6 +279,8 @@ GameBridge 禁止:
2026-06-18 追加:移动壳声明并实现 `file.importText`,通过 Expo DocumentPicker 打开系统文档选择器,只接受 `text/plain`、`text/markdown`、`text/csv`、`application/json` 或对应扩展名,单次不超过 5 MiB;成功只返回清洗后的文件名、MIME、UTF-8 文本内容和字节数,不暴露设备本地 URI,也不开放通用文件系统。H5 创作 Agent 工作台在原生壳声明该能力时优先打开宿主系统选择器,再把返回文本转换成现有浏览器 `File` 并继续调用 `/api/runtime/creation-agent/document-inputs/parse`,不在前端绕过后端文档解析、大小校验或 docx 处理。
2026-06-18 追加:移动壳图片和音频导入 / 导出不再只信 Expo picker 返回的 MIME 或 H5 声明的 MIME。Expo 壳会对 PNG / JPEG / WebP、MP3 / MP4-M4A / WAV / OGG / WebM 做 base64 bytes 头部识别,导入时要求系统 picker 的 MIME 与 bytes 匹配,导出时要求 H5 payload 的 `mimeType` 与 `base64Data` 解码后的 bytes 匹配;不匹配统一返回 `invalid_request`,不会写入缓存文件、调起系统分享或把内容回传给 H5。
2026-06-18 追加:H5 个人中心的邀请码填写和兑换码弹窗开始消费 `clipboard.readText`。Expo 壳仍只通过 `expo-clipboard` 返回纯文本,H5 只把文本填入现有输入框,不自动提交,也不把剪贴板内容交给宿主侧业务处理;普通浏览器、小程序和未声明该能力的裁剪壳不显示粘贴动作。
2026-06-18 追加:H5 的草稿生成完成 / 失败收口开始消费 `notification.showLocal`。Expo 壳仍只发送即时本地通知,不注册远程推送 token、不做定时提醒;H5 按草稿来源对完成和失败通知去重,同一草稿重新进入生成中后才允许再次通知,通知失败不阻断弹窗、作品架和后端状态回读。