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.
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.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
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— highA 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, sotarget.exists() && target.length() > 0L(line 308) is true (a directory reports length 4096 on Android ext4/f2fs) and ensureAttachmentFile returns the directory. (6) Line 260InlineImage(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.