Skip to content

GH-3408: filter reserved keys when writing Envelope.Headers to the wire format#3411

Merged
jeremydmiller merged 1 commit into
mainfrom
gh-3408-envelope-serializer-reserved-key-filter
Jul 13, 2026
Merged

GH-3408: filter reserved keys when writing Envelope.Headers to the wire format#3411
jeremydmiller merged 1 commit into
mainfrom
gh-3408-envelope-serializer-reserved-key-filter

Conversation

@jeremydmiller

Copy link
Copy Markdown
Member

Fixes #3408.

The escalation path

EnvelopeSerializer.writeHeaders wrote the typed envelope properties first, then appended every env.Headers entry verbatim with no reserved-key filter — and the appended entries came last. The reader (ReadDataElement) switches reserved keys straight back into the typed properties, in order. So an entry sitting in env.Headers under a reserved key overwrote the real typed property on the next read.

Concretely:

  1. Something puts tenant-id: acme into envelope.Headers — a custom IEnvelopeMapper copying raw broker headers (this is how Set Kafka timestamps and expose record headers #3407 surfaced it), MassTransitEnvelope.TransferData copying incoming MT headers, or plain user code.
  2. The value is inert while the envelope is in memory — nothing promotes Headers → typed props on the receive path.
  3. It stops being inert the instant the envelope crosses EnvelopeSerializer: a durable listener, the inbox/outbox, or the scheduled-message store. writeHeaders appends tenant-id = acme after the (possibly null) typed prop.
  4. On read back, env.TenantId = "acme".

The envelope now carries a TenantId it was never receive-mapped with. saga-id reaches another saga's state, and id rewrites Envelope.Id — the inbox's dedupe identity. Where the header value originates with an untrusted external producer (raw-JSON broker interop), that is a privilege boundary, not a cosmetic bug.

The fix

A static readonly FrozenSet<string> of the reserved keys (ordinal), skipped in the bulk foreach over env.Headers. The typed property stays authoritative; a reserved key sitting in Headers becomes a no-op rather than a silent override.

Skip, not throw. The failure mode of throwing during a durable persist is far worse than the failure mode of ignoring a header nobody should have set — a throw would turn a stray header into a poisoned message on a write path with no good recovery. Skipping is also backward-compatible for everyone who isn't colliding.

writeHeaders is the only bulk Headers-to-wire write path in the file (both Serialize overloads funnel through writeSinglewriteHeaders); the read side is ReadDataElement, reached both from readEnvelopeBody and directly from the NATS/MQTT mappers. The read-side promotion is left alone on purpose — that's the intended behavior for genuine Wolverine headers coming off the wire. The fix belongs on the write side, where the ambiguity is introduced.

The reserved set is not every EnvelopeConstants member

The set is exactly the keys ReadDataElement promotes into a typed property. That distinction is load-bearing:

  • EnvelopeConstants.CausationIdKey is deliberately carried in Headers (DeliveryOptions.cs:154 stashes it there precisely because Wolverine's native causation chain is the Guid-typed ConversationId, and Wolverine.Marten's OutboxedSessionFactory reads it back out as a header). It has no reader case, so it is not promotable and must keep round-tripping as an ordinary header. Filtering it would have broken it.
  • SentAtKey likewise has no reader case (SentAt is written separately in the binary preamble), and JsonContentType is a content-type value, not a header key.

To keep a future constant from being forgotten, every_key_promoted_by_the_reader_is_in_the_reserved_set reflects over the public string constants on EnvelopeConstants, feeds each through ReadDataElement, and asserts that any key the reader promotes (i.e. one that does not land in Headers) is present in the write-side filter. That tests the invariant directly rather than trusting a hand-maintained list.

No existing caller relied on the old promotion

I swept every place Headers is bulk-copied (MassTransitEnvelope.TransferData, FanoutMessageHandler, the Marten/Polecat outbox batches, and each transport's IEnvelopeMapper). None of them depend on a reserved key in Headers being promoted to a typed property — they either write to transport headers (a different dictionary) or, in the MassTransit case, map tenancy explicitly through the supported MapTenantIdFrom hook. MassTransitEnvelope.TransferData copying an untrusted external tenant-id into envelope.Headers is an instance of the hazard, not a legitimate use of it.

Tests

New src/Testing/CoreTests/Serialization/reserved_header_key_filtering.cs: Headers["tenant-id"] / ["saga-id"] / ["id"] / ["message-type"] (individually and all reserved keys at once) round-trip with the typed TenantId / SagaId / Id / MessageType unchanged; non-reserved custom headers still round-trip; causation-id is explicitly not filtered; plus the reflection guard above.

5 of the 8 new tests fail without the write-side skip, and pass with it. Full CoreTests suite: 1930 passed, 0 failed, 2 skipped. Full wolverine.slnx builds clean in Release.

🤖 Generated with Claude Code

EnvelopeSerializer.writeHeaders wrote the typed envelope properties first and
then appended every env.Headers entry verbatim, with no reserved-key filter --
and the appended entries came last. The reader (ReadDataElement) switches
reserved keys straight back into the typed properties, so an entry sitting in
env.Headers under a reserved key silently overwrote the real typed property on
the next read.

That made a value in envelope.Headers["tenant-id"] inert in memory but live the
moment the envelope crossed EnvelopeSerializer -- any durable listener, the
inbox/outbox, or the scheduled message store. Same for "saga-id" (reaches
another saga's state) and "id" (rewrites Envelope.Id, the inbox dedupe
identity). Where the header value originates with an untrusted external
producer, that is a privilege boundary.

Skip reserved keys in the bulk Headers write instead of throwing: the failure
mode of throwing during a durable persist is far worse than ignoring a header
nobody should have set. The typed property stays authoritative and a reserved
key in Headers becomes a no-op.

The reserved set is exactly the keys ReadDataElement promotes into a typed
property -- deliberately NOT every EnvelopeConstants member. "causation-id" is
intentionally carried in Headers by DeliveryOptions and is never promoted by
the reader, so filtering it would have broken it. A reflection-driven guard
test asserts the invariant directly (every key the reader promotes must be in
the write-side filter), so a future constant can't be forgotten.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@jeremydmiller
jeremydmiller merged commit 66ebc23 into main Jul 13, 2026
26 of 27 checks passed
This was referenced Jul 14, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

EnvelopeSerializer round-trips Headers entries into typed properties — a reserved-key collision escalates through the durable inbox

1 participant