Skip to content

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

Description

@jeremydmiller

Found while reviewing #3407 (Kafka raw-JSON mapper copying broker record headers onto the envelope). That PR is where I noticed it, but the hazard is not Kafka-specific and exists on main today.

The mechanism

EnvelopeSerializer.writeHeaders (src/Wolverine/Runtime/Serialization/EnvelopeSerializer.cs:287) writes the typed envelope properties first, then appends every env.Headers entry verbatim, with no reserved-key filter — and the appended entries come last:

writer.WriteProp(ref count, EnvelopeConstants.SagaIdKey, env.SagaId);
writer.WriteProp(ref count, EnvelopeConstants.TenantIdKey, env.TenantId);
writer.WriteProp(ref count, EnvelopeConstants.IdKey, env.Id);
...
foreach (var pair in env.Headers)   // unfiltered
{
    count++;
    writer.Write(pair.Key);
    writer.Write(pair.Value);
}

The reader switches reserved keys straight back into typed properties, in order:

case EnvelopeConstants.TenantIdKey:  env.TenantId = value; break;
case EnvelopeConstants.SagaIdKey:    env.SagaId   = value; break;
case EnvelopeConstants.IdKey:        if (Guid.TryParse(value, out var id)) env.Id = id; break;
case EnvelopeConstants.MessageTypeKey: env.MessageType = value; break;

So an entry sitting in env.Headers under a reserved key overwrites the typed property on the next read — the plain-header copy wins over the real one.

Why it matters

A value in envelope.Headers["tenant-id"] is inert while the envelope is in memory (nothing promotes Headers → typed props on the receive path). It stops being inert the moment the envelope crosses EnvelopeSerializer — i.e. any durable listener, the inbox/outbox, or the scheduled-message store:

  1. Something puts tenant-id: acme into envelope.Headers — a custom IEnvelopeMapper copying raw broker headers, or plain user code.
  2. Durable inbox persists the envelope; writeHeaders appends tenant-id = acme after the (possibly null) typed prop.
  3. 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, which is the inbox's dedupe identity. Where the header value originates with an untrusted external producer — exactly the raw-JSON interop scenario — this is a privilege boundary, not a cosmetic bug.

Proposed fix

Filter reserved keys in writeHeaders (and mirror it wherever else Headers is bulk-written to the wire format — EnvelopeSerializer has more than one path). A static readonly FrozenSet<string> of the EnvelopeConstants key names, skipped in the foreach, keeps the typed property authoritative and makes a reserved key in Headers a no-op rather than a silent override.

Open question worth deciding deliberately: skip, or throw? Silently dropping is safe and backward-compatible. Throwing would surface user code that is (probably unintentionally) stuffing reserved keys into Headers. My instinct is skip-and-don't-write, since the failure mode of throwing on a durable persist is much worse than the failure mode of ignoring a header nobody should have set.

Needs a regression test at the EnvelopeSerializer level: an envelope with Headers["tenant-id"]/["saga-id"]/["id"] set must round-trip with its typed TenantId/SagaId/Id unchanged.

Refs #3407.

Activity

  1. jeremydmiller commented on Jul 13, 2026

    @jeremydmiller
    MemberAuthor

    Two things surfaced while fixing this (PR #3411) that change the picture, both verified.

    1. This is already live on main — MassTransit interop, not just Kafka

    I filed this off the back of #3407 (Kafka), which is still unmerged, so the framing above reads as "latent hazard that a pending PR would make reachable." That was wrong. MassTransitEnvelope.TransferData (src/Wolverine/Runtime/Interop/MassTransit/MassTransitEnvelope.cs:82) already does exactly the same thing today:

    foreach (var header in Headers) envelope.Headers[header.Key] = header.Value?.ToString();

    Unfiltered, and by assignment rather than TryAdd, so it doesn't even lose to an existing value. Any Wolverine app doing MassTransit interop over a durable listener has the escalation path open right now: an MT producer sets tenant-id, it lands in envelope.Headers, and the first trip through the inbox promotes it to envelope.TenantId.

    So this is a fix for shipped behavior, not a pre-emptive guard, and it should be called out as such in the 6.18.0 notes. The write-side filter closes MassTransit and Kafka with the same change.

    2. Do NOT build the reserved set from every EnvelopeConstants constant

    The obvious implementation — reflect over EnvelopeConstants and filter them all — would have silently broken Marten's causation chain.

    DeliveryOptions.cs:154 deliberately stashes CausationIdKey into envelope.Headers, because Wolverine's native causation is the Guid-typed ConversationId and the string causation id would otherwise collapse into it. Wolverine.Marten's OutboxedSessionFactory (OutboxedSessionFactory.cs:150) then reads it back out of Headers to set session.CausationId. It has no reader case in EnvelopeSerializer, so it is not promotable and was never part of this hazard — filtering it would just have deleted it in transit.

    The correct reserved set is precisely the keys ReadDataElement promotes, which is 23 of them — not all of EnvelopeConstants. SentAtKey (binary preamble, no reader case) and JsonContentType (a content-type value, not a header key) are likewise excluded.

    To get the "a future constant can't be forgotten" guarantee without that over-filtering, the guard is a test rather than the set: it reflects over EnvelopeConstants, pushes each key through ReadDataElement, and asserts that anything the reader promotes appears in the write-side filter. That pins the actual invariant instead of a hand-maintained list.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions