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:
- Something puts
tenant-id: acme into envelope.Headers — a custom IEnvelopeMapper copying raw broker headers, or plain user code.
- Durable inbox persists the envelope;
writeHeaders appends tenant-id = acme after the (possibly null) typed prop.
- 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.
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
maintoday.The mechanism
EnvelopeSerializer.writeHeaders(src/Wolverine/Runtime/Serialization/EnvelopeSerializer.cs:287) writes the typed envelope properties first, then appends everyenv.Headersentry verbatim, with no reserved-key filter — and the appended entries come last:The reader switches reserved keys straight back into typed properties, in order:
So an entry sitting in
env.Headersunder 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 promotesHeaders→ typed props on the receive path). It stops being inert the moment the envelope crossesEnvelopeSerializer— i.e. any durable listener, the inbox/outbox, or the scheduled-message store:tenant-id: acmeintoenvelope.Headers— a customIEnvelopeMappercopying raw broker headers, or plain user code.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, 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 elseHeadersis bulk-written to the wire format —EnvelopeSerializerhas more than one path). Astatic readonly FrozenSet<string>of theEnvelopeConstantskey names, skipped in theforeach, keeps the typed property authoritative and makes a reserved key inHeadersa 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
EnvelopeSerializerlevel: an envelope withHeaders["tenant-id"]/["saga-id"]/["id"]set must round-trip with its typedTenantId/SagaId/Idunchanged.Refs #3407.