Skip to content

Fix writers that emit untrusted bytes unquoted - #863

Open
danieleggert wants to merge 8 commits into
mainfrom
harden-1-encoder-fixes
Open

danieleggert wants to merge 8 commits into
mainfrom
harden-1-encoder-fixes

Conversation

@danieleggert

@danieleggert danieleggert commented Oct 12, 2026 •

Copy link
Copy Markdown
Collaborator

[1/6] One of the design goals of this library is that the API types can only represent valid state. But that doesn’t hold true today. These 6 stacked PRs are smaller steps towards that goal. #863 #864 #865 #866 #867 #868

Motivation:

These writers emitted values raw or quoted without escaping: .urlFetch([ByteBuffer(string: "x\r\nA2 DELETE INBOX")]) injects a command. A proxy re-encoding a hostile BODYSTRUCTURE or [URLMECH] does too.

Modifications:

No API change. Values go through writeIMAPString; URLFETCH URLs stay bare when they're 1*ASTRING-CHAR, to avoid a literal per URLAUTH URL.

  • MailboxPath separator escapes were swapped.
  • MechanismBase64 is base64-encoded on write; fixtures locked in the mismatch.

Result:

Each writer round-trips every value its type accepts.

@danieleggert
danieleggert force-pushed the harden-1-encoder-fixes branch from 5a28d8b to 4cfef4e Compare October 28, 2026 13:30
`parseListMailbox` was only tested against DEL. Cover wildcards,
resp-specials, the quoted-string branch, and an empty input.
`GmailLabel("\\x)\r\nA2 DELETE INBOX\r\nA3 NOOP (")` was written raw
because it starts with `\`, injecting a command into STORE X-GM-LABELS.
Reachable from server data via `init(mailboxName:)`.

Write bare only for `"\" atom`; otherwise `writeIMAPString`. Also fixes
round-trip of astring labels such as `"\\Foo bar"`.
`.urlFetch([ByteBuffer(string: "x\r\nA2 DELETE INBOX")])` was written
verbatim, injecting a command.

RFC 4467: `url-full = astring`. Write bare if `1*ASTRING-CHAR` (keeps
long URLAUTH URLs out of literals), else `writeIMAPString` (`%`, `*`).
`Encoding("x\" 0 0) UID 9 (")` was spliced into `"…"` unescaped, so a
MIME `Content-Transfer-Encoding` header spoofs FETCH items. The parser
accepts any `string` (incl. literals with CRLF), so proxies re-emitted it.

`body-fld-enc = (DQUOTE ("7BIT"/…) DQUOTE) / string`. Use
`writeIMAPString`; standard values are unchanged on the wire.
`Media.Subtype("x\"\r\n* 1 EXPUNGE")` was spliced into `"…"` unescaped;
a MIME `Content-Type` header could inject an untagged response.

`media-basic = ((DQUOTE (…) DQUOTE) / string) SP media-subtype`,
`media-subtype = string`. Use `writeIMAPString` (still uppercased).
`SearchCorrelator(tag: "a\") * BYE")` was spliced into `"…"` unescaped,
so ESEARCH / UIDBATCHES responses could carry injected data.

RFC 4466: `tag-string = string`. Use `writeIMAPString`, and parse a
`string` rather than `"` tag `"` so every tag round-trips.
The quoted-special cases were swapped: `\` encoded as `"\"`
(unterminated), `"` as `"\\"` (a backslash). `pathSeparator: "\n"` was
written as a raw LF inside `"…"`, and NUL silently became NIL.

`(DQUOTE QUOTED-CHAR DQUOTE / nil)`, `QUOTED-CHAR = <TEXT-CHAR except
quoted-specials> / "\" quoted-specials`. Swap the cases; parse the
escaped forms; `MailboxPath.init` throws for CR, LF, CRLF, NUL.
The parser base64-decodes `mechanism-base64`, but the encoder wrote the
decoded bytes raw: parse `INTERNAL=YQ==` → "a" → `INTERNAL=a`. A proxy
re-encoding `[URLMECH INTERNAL X=<base64 of CRLF…>]` emitted raw CRLF.

RFC 4467: `mechanism-base64 = mechanism ["=" base64]`. Encode on write;
fix the fixtures that locked in the mismatch.
@danieleggert
danieleggert force-pushed the harden-1-encoder-fixes branch from 4cfef4e to 8e6f565 Compare October 30, 2026 14:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

🔨 semver/patch No public API change.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants