Guard IMAP Command

IMAP command-line validator (RFC 9051 IMAP4rev2; obsoletes RFC 3501). Gates every command-line the framework's inbound IMAP listener accepts from peers — CAPABILITY / NOOP / LOGOUT / STARTTLS / AUTHENTICATE / LOGIN / ENABLE / SELECT / EXAMINE / CREATE / DELETE / RENAME / SUBSCRIBE / UNSUBSCRIBE / LIST / NAMESPACE / STATUS / APPEND / IDLE / CHECK / CLOSE / UNSELECT / EXPUNGE / SEARCH / FETCH / STORE / COPY / MOVE / UID / GETQUOTA / SETQUOTA / GETQUOTAROOT / ID.

## Smuggling defense — bare-CR / bare-LF refusal

Same wire-protocol smuggling class as SMTP: implementations that accept bare-CR or bare-LF in a command line let a hostile peer inject a second command past a per-line filter. RFC 9051 §2.2.1 requires CRLF only; this validator refuses every bare CR / bare LF / NUL / C0 / DEL / C1 character outside of explicit literal blocks (which the wire-protocol reader has already framed before handing the line to this validator).

## Literal-injection defense

IMAP carries inline length-prefixed literals: {n}. Per RFC 9051 §2.2.2 the literal opener {n} MUST appear at the end of a command line, with the n bytes following on subsequent line(s). RFC 7888 LITERAL+ relaxes the round-trip but is only honored post-AUTH. The validator detects literal openers as either:

Per-literal byte cap defaults to 64 MiB (operator opts down via maxLiteralBytes); the LISTENER then enforces the post-literal read against this cap.

## Mailbox-name traversal

Mailbox names per RFC 9051 §5.1 — UTF-8 hierarchy with the server-chosen delimiter (typically / or .). Refuses path- traversal (..), NUL bytes, control chars, leading/trailing slash, overlong UTF-8 sequences, and (under strict) modified- UTF7 (RFC 3501 §5.1.3 legacy encoding — operators with legacy MUAs opt in via allowLegacyMUtf7).

## Per-verb checks

The verb selects which checks run. A verb RFC 9051 §6 gives no arguments refuses any with guard-imap-command/unexpected-args. A verb whose first argument is a mailbox name has that name measured against the mailbox cap, one whose first argument is a sequence set has its element count measured, and a SEARCH-family verb has its parenthesis nesting measured. Argument arity beyond the zero-argument verbs is not checked here, so a listener that needs LOGIN to carry exactly two arguments checks that itself.

## Caps

- Command line (tag + verb + arguments excluding literal payload) capped at 8 KiB. RFC 9051 does not mandate a line cap but most servers limit at 8 KiB or 16 KiB to bound memory; operators on permissive can extend. - Mailbox name capped at 1 KiB. - Sequence set element count capped at 10,000 per command. - SEARCH expression nesting (AND/OR/NOT) capped at 32 levels. - Per-literal byte cap (64 MiB default).

Throws GuardImapCommandError on every refusal. Pure-functional — no I/O, no state. The IMAP listener composes one instance per accepted connection.

b.guardImapCommand.limitsFor(opts?) #

stable0.20.32
{
  profile:  "strict" | "balanced" | "permissive",
  posture:  "hipaa" | "pci-dss" | "gdpr" | "soc2",
}

Resolve the caps a profile and posture select, as { maxLineBytes, maxLiteralBytes, maxMailboxBytes, maxSequenceSetItems, maxSearchDepth, allowLegacyMUtf7 }. validate applies these, and b.mail.server.imap reads allowLegacyMUtf7 from here rather than deriving its own, so one value decides it. The object is frozen. Throws GuardImapCommandError with code guard-imap-command/bad-profile or guard-imap-command/bad-posture for a name outside the tables, and guard-imap-command/bad-opt for an option this guard does not read, including compliancePosture, whose regime belongs in posture.

maxSequenceSetItems bounds how many elements a set names on the wire, which this guard can count without a mailbox. A message-number range counts as the numbers it covers, so FETCH 1:20000 names 20000 and is refused at strict. A UID range counts as one element: UIDs are sparse, so UID FETCH 100000:120000 may name two messages, and the mailbox is what says how many. A range written against *, which RFC 9051 section 9 defines as the largest number in use, names a count only the selected mailbox knows, so FETCH 1:* is not counted here and is not refused here. What bounds the sets this cap does not is the mailbox itself, the count b.mail.server.imap takes against its selected mailbox, and the per-handler response-byte and wall-clock budgets it applies.

b.guardImapCommand.limitsFor({ profile: "balanced" }).maxSequenceSetItems;
// → 50000

b.guardImapCommand.announcedLiteral(line) #

stable0.20.32

Read the literal a command line announces, as { size, nonSync }, or null when the line ends in no literal opener. validate reads the opener this way, and a listener that REFUSES a line needs the same answer to decide what to do with the octets the client is about to send: a non-synchronizing literal ({n+}, RFC 7888) is already in flight and must be consumed before the next line is parsed, while a synchronizing {n} waits for a continuation the refusal never sends.

The size is reported as announced, with no cap applied, so the caller can refuse a size it will not read rather than read it to find out.

b.guardImapCommand.announcedLiteral("a1 APPEND INBOX {24+}");
// → { size: 24, nonSync: true }

b.guardImapCommand.tagOf(line) #

stable0.20.32

Read the tag off a command line, or null when the line does not open with one. The tag is the atom before the first space, at most 64 characters of ALPHA / DIGIT / "." / "_" / "-", which is the grammar validate applies before it looks at anything else.

A listener answers a refused command BAD against its tag so the client can pair the reply with what it sent, per RFC 9051 section 2.2.1. An untagged BAD is for a line whose tag cannot be read, which is what this returns null for.

b.guardImapCommand.tagOf("a1 CREATE \"x\"");                     // → "a1"
b.guardImapCommand.tagOf("{5}");                                 // → null

b.guardImapCommand.validate(line, opts?) #

stable0.9.49
{
  profile:   "strict" | "balanced" | "permissive",
  posture:   "hipaa" | "pci-dss" | "gdpr" | "soc2",
  authenticated: boolean,    // when true, LITERAL+ (RFC 7888) is honored under
                               strict; pre-AUTH literal+ is refused per RFC 7888 §1
}

Validate a single IMAP command line (without its CRLF terminator — the listener strips that before calling this). Returns { tag, verb, args, literalSize, literalNonSync } on success; throws GuardImapCommandError on any refusal. args is the rest of the line after the verb, unparsed: this guard checks the line's shape and leaves each command's argument grammar to its consumer. literalSize is the pending-literal byte count when the line ends in {n}; null otherwise. literalNonSync is true for RFC 7888 LITERAL+ ({n+}).

var parsed = b.guardImapCommand.validate("A001 LOGIN alice secret");
// → { tag: "A001", verb: "LOGIN", args: "alice secret", literalSize: null, literalNonSync: false }

var pending = b.guardImapCommand.validate("A002 APPEND INBOX {1024}");
// → { tag: "A002", verb: "APPEND", args: "INBOX {1024}", literalSize: 1024, literalNonSync: false }

b.guardImapCommand.detectLiteralSmuggling(line) #

stable0.9.49

Return true when the input line contains a literal opener {n} or {n+} that is NOT at the end of the line — the smuggling-shape per RFC 9051 §2.2.2.

b.guardImapCommand.detectLiteralSmuggling("A001 APPEND INBOX {10} hostile");  // → true
b.guardImapCommand.detectLiteralSmuggling("A001 APPEND INBOX {10}");          // → false (well-formed)

b.guardImapCommand.compliancePosture(name) #

stable0.9.49hipaapci-dssgdprsoc2

Return the effective profile NAME for a compliance posture, or null for a name this parser does not map. Unlike the content-guard variant this returns the resolved profile string (every line-protocol parser composes gateContract.ALL_STRICT_POSTURES, so "hipaa" / "pci-dss" / "gdpr" / "soc2" all resolve to "strict") and never throws — the parser shape carries no overlay-clone, no buildProfile, and no loadRulePack. Wired by gateContract.defineParser.

b.guardImapCommand.compliancePosture("hipaa");                   // → "strict"
b.guardImapCommand.compliancePosture("not-a-regime");            // → null

Last updated 2026-10-12T14:21:56.879Z by seeder.