收紧媒体导出载荷边界

将图片和音频导出载荷预校验提升到共享 HostBridge 契约
让 H5 facade 按共享媒体导出边界归一化文件名、MIME 和 base64 载荷
保留 Expo 与 Tauri 壳对真实字节和 MIME 的二次校验
增加媒体导出载荷测试和三端壳总门禁反查
同步宿主壳协议文档和共享决策记录
This commit is contained in:
2026-06-19 16:18:54 +08:00
parent 14da1a7e12
commit 71510ef797
7 changed files with 187 additions and 8 deletions
@@ -37,7 +37,9 @@ import {
normalizeHostBridgeClipboardText,
normalizeHostBridgeColorScheme,
normalizeHostBridgeConnectionType,
normalizeHostBridgeExportAudioPayload,
normalizeHostBridgeExportFileName,
normalizeHostBridgeExportImagePayload,
normalizeHostBridgeExternalUrl,
normalizeHostBridgeExternalUrlPayload,
normalizeHostBridgeHapticsImpactStyle,
@@ -349,6 +351,45 @@ describe('HostBridge shared contract helpers', () => {
expect(HOST_BRIDGE_IMPORT_DOCUMENT_MAX_BYTES).toBe(5 * 1024 * 1024);
});
test('归一化宿主图片和音频导出载荷', () => {
expect(
normalizeHostBridgeExportImagePayload({
fileName: ' ../分享:卡?.png ',
base64Data: ' c2hhcmU= ',
mimeType: 'image/png',
}),
).toEqual({
fileName: '分享-卡-.png',
base64Data: 'c2hhcmU=',
mimeType: 'image/png',
});
expect(
normalizeHostBridgeExportAudioPayload({
fileName: '敲击音效.wav',
base64Data: ' YXVkaW8= ',
mimeType: 'audio/wav',
}),
).toEqual({
fileName: '敲击音效.wav',
base64Data: 'YXVkaW8=',
mimeType: 'audio/wav',
});
expect(
normalizeHostBridgeExportImagePayload({
fileName: 'bad.gif',
base64Data: 'c2hhcmU=',
mimeType: 'image/gif',
}),
).toBeNull();
expect(
normalizeHostBridgeExportAudioPayload({
fileName: 'empty.wav',
base64Data: ' ',
mimeType: 'audio/wav',
}),
).toBeNull();
});
test('归一化宿主本地通知内容', () => {
expect(
normalizeHostBridgeLocalNotification({
@@ -573,6 +573,10 @@ export const HOST_BRIDGE_IMAGE_MIME_TYPES = [
'image/webp',
] as const satisfies readonly HostBridgeImageMimeType[];
const HOST_BRIDGE_IMAGE_MIME_TYPE_SET = new Set<HostBridgeImageMimeType>(
HOST_BRIDGE_IMAGE_MIME_TYPES,
);
export type FileImportImageResult = {
action: 'selected' | 'dropped' | 'captured';
fileName: string;
@@ -625,6 +629,10 @@ export const HOST_BRIDGE_AUDIO_MIME_TYPES = [
'audio/webm',
] as const satisfies readonly HostBridgeAudioMimeType[];
const HOST_BRIDGE_AUDIO_MIME_TYPE_SET = new Set<HostBridgeAudioMimeType>(
HOST_BRIDGE_AUDIO_MIME_TYPES,
);
export type FileImportAudioResult = {
action: 'selected';
fileName: string;
@@ -653,6 +661,80 @@ export const HOST_BRIDGE_IMPORT_IMAGE_MAX_BYTES = 10 * 1024 * 1024;
export const HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES = 20 * 1024 * 1024;
export const HOST_BRIDGE_IMPORT_AUDIO_MAX_BYTES = 20 * 1024 * 1024;
function normalizeHostBridgeBase64Data(rawData: unknown) {
if (typeof rawData !== 'string') {
return null;
}
const base64Data = rawData.trim();
if (!base64Data || hasHostBridgeControlCharacter(base64Data)) {
return null;
}
return base64Data;
}
function estimateHostBridgeBase64Bytes(base64Data: string) {
const normalizedLength = base64Data.replace(/\s+/g, '').length;
return Math.ceil((normalizedLength * 3) / 4);
}
export function normalizeHostBridgeExportImagePayload(
payload: unknown,
): FileExportImagePayload | null {
if (!payload || typeof payload !== 'object') {
return null;
}
const candidate = payload as Partial<FileExportImagePayload>;
const mimeType = candidate.mimeType as HostBridgeImageMimeType;
if (!HOST_BRIDGE_IMAGE_MIME_TYPE_SET.has(mimeType)) {
return null;
}
const base64Data = normalizeHostBridgeBase64Data(candidate.base64Data);
if (
!base64Data ||
estimateHostBridgeBase64Bytes(base64Data) > HOST_BRIDGE_EXPORT_IMAGE_MAX_BYTES
) {
return null;
}
return {
fileName: normalizeHostBridgeExportFileName(candidate.fileName),
base64Data,
mimeType,
};
}
export function normalizeHostBridgeExportAudioPayload(
payload: unknown,
): FileExportAudioPayload | null {
if (!payload || typeof payload !== 'object') {
return null;
}
const candidate = payload as Partial<FileExportAudioPayload>;
const mimeType = candidate.mimeType as HostBridgeAudioMimeType;
if (!HOST_BRIDGE_AUDIO_MIME_TYPE_SET.has(mimeType)) {
return null;
}
const base64Data = normalizeHostBridgeBase64Data(candidate.base64Data);
if (
!base64Data ||
estimateHostBridgeBase64Bytes(base64Data) > HOST_BRIDGE_EXPORT_AUDIO_MAX_BYTES
) {
return null;
}
return {
fileName: normalizeHostBridgeExportFileName(candidate.fileName),
base64Data,
mimeType,
};
}
export const HOST_BRIDGE_HAPTICS_IMPACT_STYLES = [
'light',
'medium',