fix(security): MIME filename sanitizing to '.' or '..' escapes the attachment dir and crashes message open #484

Open
opened 2026-07-10 19:14:19 +00:00 by JMR-dev · 0 comments
JMR-dev commented 2026-07-10 19:14:19 +00:00 (Migrated from github.com)

Verified finding(s) from the 2026-07-09 whole-repo multi-agent review (independent finder, then adversarial verifier; verdict CONFIRMED).

Triage: above the cut — fix dispatched immediately; this issue tracks the fix to Done.

app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt:260 — high

A hostile MIME part whose filename sanitizes to ".." or "." (sanitizeAttachmentName strips path separators but not dot segments) makes ensureAttachmentFile's cache-hit check return a directory, and file.readBytes() here throws an uncaught IOException that crashes the app every time the message is opened.

Failure scenario: Attacker sends an HTML email with an inline part (Content-ID set) named "..": first open, attachmentFile resolves to attachments///.. , mkdirs creates the dir and the write fails inside the inner runCatching (image just omitted). Every subsequent open, target.exists() is true (the path resolves to the directory, length 4096 > 0) so ensureAttachmentFile returns the directory; readBytes() at line 260 is OUTSIDE the runCatching, the IOException propagates out of inlineImages into ReaderViewModel's unguarded viewModelScope.launch (ReaderViewModel.kt:92), and the process crashes — a repeatable, remotely-triggered crash on opening that message.

Verifier justification (CONFIRMED): Every link in the chain checks out. (1) sanitizeAttachmentName (OutgoingMessage.kt:43-44) is raw.substringAfterLast('/').substringAfterLast('\\').filterNot { it.isISOControl() }.ifBlank { "attachment" } — the input ".." survives unchanged (not blank, no path separators, no control chars). The attachment filename originates unsanitized from the server via attachmentName() (ImapClient.kt:649-651), which only MIME-decodes part.fileName. (2) attachmentFile (MailRepositoryImpl.kt:608-610) returns File(attachmentCacheDir(cacheDir, messageId), "$partIndex/..") which resolves to the directory. (3) An inline part gets a non-null contentId (ImapClient.kt:634), so inlineImages selects it via filter { it.contentId != null }. (4) First open: mkdirs() (line 312) creates the dir, the outputStream() write onto a directory fails, but is swallowed by the runCatching opened at line 257 / closed at 259 — image merely omitted. (5) Every later open: the ".." path now resolves to the existing directory, so target.exists() && target.length() > 0L (line 308) is true (a directory reports length 4096 on Android ext4/f2fs) and ensureAttachmentFile returns the directory. (6) Line 260 InlineImage(contentId = row.contentId!!, mimeType = row.mimeType, bytes = file.readBytes()) is OUTSIDE the runCatching; File.readBytes() on a directory throws FileNotFoundException (EISDIR). (7) It propagates out of inlineImages into ReaderViewModel.kt:92, inside the onSuccess lambda of an unguarded viewModelScope.launch (the fold's onFailure only wraps openMessage's own Result), crashing the process. No canonical-path check, isFile guard, or dot-segment rejection exists anywhere on the path, and no test covers a ".." filename. This contradicts none of the known-intentional behaviors. The only environment-dependent step is a directory reporting length>0, which is reliably true on Android filesystems.

Defective line: InlineImage(contentId = row.contentId!!, mimeType = row.mimeType, bytes = file.readBytes())

Fix hint: In sanitizeAttachmentName (OutgoingMessage.kt:43), reject/replace pure dot-segments (map "." and ".." — and any name reducing to them after trimming — to "attachment"). Additionally, harden ensureAttachmentFile (MailRepositoryImpl.kt:308) to require target.isFile (not just exists()+length>0) and wrap the file.readBytes() at line 260 inside the existing runCatching so a bad part is omitted rather than crashing the reader.

Verified finding(s) from the 2026-07-09 whole-repo multi-agent review (independent finder, then adversarial verifier; verdict **CONFIRMED**). **Triage: above the cut — fix dispatched immediately; this issue tracks the fix to Done.** ## `app/src/main/kotlin/org/libremail/data/repository/MailRepositoryImpl.kt:260` — high A hostile MIME part whose filename sanitizes to ".." or "." (sanitizeAttachmentName strips path separators but not dot segments) makes ensureAttachmentFile's cache-hit check return a directory, and file.readBytes() here throws an uncaught IOException that crashes the app every time the message is opened. **Failure scenario:** Attacker sends an HTML email with an inline part (Content-ID set) named "..": first open, attachmentFile resolves to attachments/<msgId>/<part>/.. , mkdirs creates the <part> dir and the write fails inside the inner runCatching (image just omitted). Every subsequent open, target.exists() is true (the path resolves to the <msgId> directory, length 4096 > 0) so ensureAttachmentFile returns the directory; readBytes() at line 260 is OUTSIDE the runCatching, the IOException propagates out of inlineImages into ReaderViewModel's unguarded viewModelScope.launch (ReaderViewModel.kt:92), and the process crashes — a repeatable, remotely-triggered crash on opening that message. **Verifier justification (CONFIRMED):** Every link in the chain checks out. (1) sanitizeAttachmentName (OutgoingMessage.kt:43-44) is `raw.substringAfterLast('/').substringAfterLast('\\').filterNot { it.isISOControl() }.ifBlank { "attachment" }` — the input ".." survives unchanged (not blank, no path separators, no control chars). The attachment filename originates unsanitized from the server via attachmentName() (ImapClient.kt:649-651), which only MIME-decodes part.fileName. (2) attachmentFile (MailRepositoryImpl.kt:608-610) returns File(attachmentCacheDir(cacheDir, messageId), "$partIndex/..") which resolves to the <messageId> directory. (3) An inline part gets a non-null contentId (ImapClient.kt:634), so inlineImages selects it via filter { it.contentId != null }. (4) First open: mkdirs() (line 312) creates the <partIndex> dir, the outputStream() write onto a directory fails, but is swallowed by the runCatching opened at line 257 / closed at 259 — image merely omitted. (5) Every later open: the ".." path now resolves to the existing <messageId> directory, so `target.exists() && target.length() > 0L` (line 308) is true (a directory reports length 4096 on Android ext4/f2fs) and ensureAttachmentFile returns the directory. (6) Line 260 `InlineImage(contentId = row.contentId!!, mimeType = row.mimeType, bytes = file.readBytes())` is OUTSIDE the runCatching; File.readBytes() on a directory throws FileNotFoundException (EISDIR). (7) It propagates out of inlineImages into ReaderViewModel.kt:92, inside the onSuccess lambda of an unguarded viewModelScope.launch (the fold's onFailure only wraps openMessage's own Result), crashing the process. No canonical-path check, isFile guard, or dot-segment rejection exists anywhere on the path, and no test covers a ".." filename. This contradicts none of the known-intentional behaviors. The only environment-dependent step is a directory reporting length>0, which is reliably true on Android filesystems. **Defective line:** `InlineImage(contentId = row.contentId!!, mimeType = row.mimeType, bytes = file.readBytes())` **Fix hint:** In sanitizeAttachmentName (OutgoingMessage.kt:43), reject/replace pure dot-segments (map "." and ".." — and any name reducing to them after trimming — to "attachment"). Additionally, harden ensureAttachmentFile (MailRepositoryImpl.kt:308) to require target.isFile (not just exists()+length>0) and wrap the file.readBytes() at line 260 inside the existing runCatching so a bad part is omitted rather than crashing the reader.
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: JMR-dev/LibreMail#484