fix(session-manager): derive attachment extension from mimeType and att.type
When a channel bridge passes an attachment without an explicit `name`, extractAttachmentFiles fell back to `attachment-<ts>` with no extension. Agents could not tell whether the file was a JPEG, PDF, or audio clip, and tools keyed on extension (image viewers, exiftool, etc.) misbehaved. Two cases are now covered: 1. Channels that set `mimeType` but no `name` (Discord/Slack documents, Telegram document uploads). A small MIME-to-extension table covers the common content types — image/*, audio/*, video/*, pdf, zip, txt, json. Unknown MIMEs fall back to the unsuffixed name. 2. Channels that set `att.type` but no `mimeType` (Telegram photos, stickers, voice, animations). The chat-sdk bridge sets a coarse media-class (`photo` / `sticker` / `voice` / `video` / `animation`) which is reliable enough to derive a canonical extension. Telegram GIFs are MP4 under the hood. The existing isSafeAttachmentName security guard is preserved — the derived name still passes through it before disk I/O. The new lookup tables emit static values from internal maps and cannot construct a path-traversal payload; attacker-controlled att.name continues to flow through the same validator.
This commit is contained in:
@@ -230,6 +230,60 @@ export function writeSessionMessage(
|
|||||||
updateSession(sessionId, { last_active: new Date().toISOString() });
|
updateSession(sessionId, { last_active: new Date().toISOString() });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Map common MIME types to canonical file extensions. Used to derive a
|
||||||
|
// usable suffix when the channel bridge passes an attachment without an
|
||||||
|
// explicit `name`. Without an extension, agents (and humans) can't tell
|
||||||
|
// what kind of file landed in the inbox.
|
||||||
|
const MIME_TO_EXT: Record<string, string> = {
|
||||||
|
'image/jpeg': 'jpg',
|
||||||
|
'image/png': 'png',
|
||||||
|
'image/webp': 'webp',
|
||||||
|
'image/gif': 'gif',
|
||||||
|
'image/heic': 'heic',
|
||||||
|
'audio/ogg': 'ogg',
|
||||||
|
'audio/mpeg': 'mp3',
|
||||||
|
'audio/wav': 'wav',
|
||||||
|
'audio/mp4': 'm4a',
|
||||||
|
'video/mp4': 'mp4',
|
||||||
|
'video/webm': 'webm',
|
||||||
|
'video/quicktime': 'mov',
|
||||||
|
'application/pdf': 'pdf',
|
||||||
|
'text/plain': 'txt',
|
||||||
|
'application/json': 'json',
|
||||||
|
'application/zip': 'zip',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Fallback when `mimeType` is missing — Telegram photos and stickers arrive
|
||||||
|
// without an explicit MIME on the attachment object. The channel bridge sets
|
||||||
|
// `att.type` to a coarse media-class (`photo` / `sticker` / `voice` / etc.)
|
||||||
|
// which is reliable enough to derive a canonical extension. Telegram's GIFs
|
||||||
|
// are actually MP4, hence `animation: 'mp4'`.
|
||||||
|
const TYPE_TO_EXT: Record<string, string> = {
|
||||||
|
image: 'jpg',
|
||||||
|
photo: 'jpg',
|
||||||
|
sticker: 'webp',
|
||||||
|
voice: 'ogg',
|
||||||
|
audio: 'mp3',
|
||||||
|
video: 'mp4',
|
||||||
|
animation: 'mp4',
|
||||||
|
};
|
||||||
|
|
||||||
|
function extForMime(mime: string | undefined): string {
|
||||||
|
if (!mime) return '';
|
||||||
|
const clean = mime.split(';')[0].trim().toLowerCase();
|
||||||
|
return MIME_TO_EXT[clean] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function deriveAttachmentName(att: Record<string, unknown>): string {
|
||||||
|
const explicit = att.name as string | undefined;
|
||||||
|
if (explicit) return explicit;
|
||||||
|
let ext = extForMime(att.mimeType as string | undefined);
|
||||||
|
if (!ext && typeof att.type === 'string') {
|
||||||
|
ext = TYPE_TO_EXT[att.type.toLowerCase()] ?? '';
|
||||||
|
}
|
||||||
|
return ext ? `attachment-${Date.now()}.${ext}` : `attachment-${Date.now()}`;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If message content has attachments with base64 `data`, save them to
|
* If message content has attachments with base64 `data`, save them to
|
||||||
* the session's inbox directory and replace with `localPath`.
|
* the session's inbox directory and replace with `localPath`.
|
||||||
@@ -259,7 +313,7 @@ function extractAttachmentFiles(
|
|||||||
// this guard, `path.join(inboxDir, '../../...')` writes anywhere the
|
// this guard, `path.join(inboxDir, '../../...')` writes anywhere the
|
||||||
// host process has fs permission — see Signal Desktop's Nov 2025
|
// host process has fs permission — see Signal Desktop's Nov 2025
|
||||||
// attachment-fileName advisory for the same archetype.
|
// attachment-fileName advisory for the same archetype.
|
||||||
const rawName = (att.name as string | undefined) ?? `attachment-${Date.now()}`;
|
const rawName = deriveAttachmentName(att);
|
||||||
const filename = isSafeAttachmentName(rawName) ? rawName : `attachment-${Date.now()}`;
|
const filename = isSafeAttachmentName(rawName) ? rawName : `attachment-${Date.now()}`;
|
||||||
if (filename !== rawName) {
|
if (filename !== rawName) {
|
||||||
log.warn('Refused unsafe attachment filename — would escape inbox', {
|
log.warn('Refused unsafe attachment filename — would escape inbox', {
|
||||||
|
|||||||
Reference in New Issue
Block a user