GH-3408: filter reserved keys when writing Envelope.Headers to the wire format#3411
Merged
jeremydmiller merged 1 commit intoJul 13, 2026
Merged
Conversation
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>
This was referenced Jul 14, 2026
This was referenced Jul 18, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #3408.
The escalation path
EnvelopeSerializer.writeHeaderswrote the typed envelope properties first, then appended everyenv.Headersentry 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 inenv.Headersunder a reserved key overwrote the real typed property on the next read.Concretely:
tenant-id: acmeintoenvelope.Headers— a customIEnvelopeMappercopying raw broker headers (this is how Set Kafka timestamps and expose record headers #3407 surfaced it),MassTransitEnvelope.TransferDatacopying incoming MT headers, or plain user code.Headers→ typed props on the receive path.EnvelopeSerializer: a durable listener, the inbox/outbox, or the scheduled-message store.writeHeadersappendstenant-id = acmeafter the (possibly null) typed prop.env.TenantId = "acme".The envelope now carries a TenantId it was never receive-mapped with.
saga-idreaches another saga's state, andidrewritesEnvelope.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 bulkforeachoverenv.Headers. The typed property stays authoritative; a reserved key sitting inHeadersbecomes 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.
writeHeadersis the only bulkHeaders-to-wire write path in the file (bothSerializeoverloads funnel throughwriteSingle→writeHeaders); the read side isReadDataElement, reached both fromreadEnvelopeBodyand 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
EnvelopeConstantsmemberThe set is exactly the keys
ReadDataElementpromotes into a typed property. That distinction is load-bearing:EnvelopeConstants.CausationIdKeyis deliberately carried inHeaders(DeliveryOptions.cs:154stashes it there precisely because Wolverine's native causation chain is the Guid-typedConversationId, andWolverine.Marten'sOutboxedSessionFactoryreads 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.SentAtKeylikewise has no reader case (SentAtis written separately in the binary preamble), andJsonContentTypeis 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_setreflects over the public string constants onEnvelopeConstants, feeds each throughReadDataElement, and asserts that any key the reader promotes (i.e. one that does not land inHeaders) 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
Headersis bulk-copied (MassTransitEnvelope.TransferData,FanoutMessageHandler, the Marten/Polecat outbox batches, and each transport'sIEnvelopeMapper). None of them depend on a reserved key inHeadersbeing promoted to a typed property — they either write to transport headers (a different dictionary) or, in the MassTransit case, map tenancy explicitly through the supportedMapTenantIdFromhook.MassTransitEnvelope.TransferDatacopying an untrusted externaltenant-idintoenvelope.Headersis 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 typedTenantId/SagaId/Id/MessageTypeunchanged; non-reserved custom headers still round-trip;causation-idis 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
CoreTestssuite: 1930 passed, 0 failed, 2 skipped. Fullwolverine.slnxbuilds clean in Release.🤖 Generated with Claude Code