chore(security): validate Content-ID before MIME/Graph use #207

Merged
JMR-dev merged 2 commits from fix-204-contentid-validation into main 2026-07-03 13:57:54 +00:00
JMR-dev commented 2026-07-03 12:11:22 +00:00 (Migrated from github.com)

From the post-batch security review (defense-in-depth follow-up to #203).

Finding

SmtpSender.inlinePart builds the MIME header as contentID = "<${attachment.contentId}>", and GraphSender puts contentId straight into the Graph sendMail JSON — neither strips CR/LF or other ISO control characters.

Not currently exploitable: contentId is always an app-generated img-<uuid>@libremail (ComposeViewModel.onImagePicked), so it can't contain CR/LF today. This is insurance against a future change that lets a user- or external-value flow into contentId, at which point the SMTP path would be a MIME header-injection vector.

Fix

New shared sanitizeContentId(raw: String?) strips ISO control chars (incl. CR/LF), applied at both sinks — the point where the value actually becomes dangerous, so it covers any future origin of contentId:

  • SmtpSender.inlinePart — before the Content-ID header value.
  • GraphSender.buildSendMailPayload — before the JSON contentId field.

Sink-side (rather than mint-side) placement mirrors sanitizeAttachmentName from #203 and is the robust choke point. No behavior change for the app-generated ids in use today (they contain no control chars).

Tests

  • SmtpSenderTest — sends an inline image whose contentId is logo@libremail\r\nX-Injected: evil and asserts, via a real GreenMail SMTP round-trip, that no X-Injected header appears on any MIME part (recursive walk) and the emitted Content-ID stays on a single line.
  • GraphSenderTest — asserts the crafted contentId is stripped to logo@libremailevil (no CR/LF) in the built payload.

Full local preflight green (JDK 21, --max-workers=8, one at a time): assembleDebug, testDebugUnitTest (SmtpSenderTest 7/7, GraphSenderTest 8/8), lintDebug, ktlintCheck+detekt, compileDebugAndroidTestKotlin. No Room/schema change.

Closes #204

🤖 Generated with Claude Code

From the post-batch security review (defense-in-depth follow-up to #203). ## Finding `SmtpSender.inlinePart` builds the MIME header as `contentID = "<${attachment.contentId}>"`, and `GraphSender` puts `contentId` straight into the Graph `sendMail` JSON — neither strips CR/LF or other ISO control characters. **Not currently exploitable:** `contentId` is always an app-generated `img-<uuid>@libremail` (`ComposeViewModel.onImagePicked`), so it can't contain CR/LF today. This is insurance against a future change that lets a user- or external-value flow into `contentId`, at which point the SMTP path would be a MIME header-injection vector. ## Fix New shared `sanitizeContentId(raw: String?)` strips ISO control chars (incl. CR/LF), applied at **both sinks** — the point where the value actually becomes dangerous, so it covers any future origin of `contentId`: - `SmtpSender.inlinePart` — before the `Content-ID` header value. - `GraphSender.buildSendMailPayload` — before the JSON `contentId` field. Sink-side (rather than mint-side) placement mirrors `sanitizeAttachmentName` from #203 and is the robust choke point. No behavior change for the app-generated ids in use today (they contain no control chars). ## Tests - `SmtpSenderTest` — sends an inline image whose `contentId` is `logo@libremail\r\nX-Injected: evil` and asserts, via a real GreenMail SMTP round-trip, that no `X-Injected` header appears on any MIME part (recursive walk) and the emitted `Content-ID` stays on a single line. - `GraphSenderTest` — asserts the crafted `contentId` is stripped to `logo@libremailevil` (no CR/LF) in the built payload. Full local preflight green (JDK 21, `--max-workers=8`, one at a time): `assembleDebug`, `testDebugUnitTest` (SmtpSenderTest 7/7, GraphSenderTest 8/8), `lintDebug`, `ktlintCheck`+`detekt`, `compileDebugAndroidTestKotlin`. No Room/schema change. Closes #204 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.