The Sender Is the Posting Hub

Some hub guards decide by the sender of a delivery. The clearest one is the stamped-field guard: a node's hub admits a write to a stamped field only from itself, and everything arriving from another address is a client write. Such a guard is only as good as the sender it reads.

PostOptions is a record, so posting code can write o => o.WithTarget(x) with { Sender = y }. Until #6553 the delivery left with whatever address the caller wrote: code running in the portal process — a compiled module, a NodeType or Code node the mesh compiled — could name a node's own address as the sender of a patch and pass that node's owner-only guard. No external client could do it (the wire ingresses stamp every delivery they accept, see Participant Ingress), but in-process code could.

The rule

hub.Post stamps the sender itself: it is the address of the hub that posts. A sender the caller wrote that differs is replaced by the posting hub's address, and the hub logs one [ForeignSender] warning per message type and call site, naming the claimed address and the calling code's stack. The key holds only stable data — the claimed address is caller-controlled and may be fresh on every post — and the set is capped per hub, so a misbehaving caller cannot grow it or flood the log. The call site is read off the stack, skipping the hub's own Post/Observe plumbing and Rx, so it names the code that wrote the sender. The post is not refused — it goes out as the hub that actually sent it, so the reply comes back to that hub and a sender-keyed guard judges the real poster.

There is one sanctioned exception, and only the messaging assembly can mark it (PostOptions.CarriedByParent(), internal): a parent carrying a post its hosted hub can no longer send itself — a correlated reply or a release (IReleasesRemoteState) posted while that hub is tearing down. The carried delivery keeps the hosted hub's address (host-qualified on the way up, the same qualification ordinary upward routing applies), because that is who posted it.

Everything else that legitimately moves a delivery under an address other than the current hub's is routing, not posting: hierarchical routing re-qualifies the sender with its host on the way up, the routing services and grains forward a delivery whose sender was stamped where it was posted, and ResponseFor retargets a reply at the request's sender without touching the reply's own sender. None of these go through a caller-written Sender.

Measured

A hand-built envelope (#6619)

MessageDelivery<T> has public constructors that take a sender, and IMessageHub.DeliverMessage / IRoutingService.DeliverMessage accept any delivery: the routing layer, the Orleans grains and the SignalR and gRPC ingresses need exactly that. Code that builds a delivery itself and hands it to one of those does not go through hub.Post, so its sender is not stamped.

For in-mesh code this is closed at compile time, at no cost per hop. The in-mesh impersonation guard's compile-time check reports every way to mint such an envelope and every entry point that routes one without hub.Post:

Under LogOnly these join the inventory; under Enforce the compile is refused unless the NodeType is trusted, so the forged sender never runs. Posting through the hub, reading d.Sender, ForwardTo and an empty envelope are not reported. Tests: InMeshImpersonationReferencesTest.AForgedSenderEnvelope_IsFound and its negative control OrdinaryMessaging_IsNotReported.

Why not a runtime caller check, or platform-only constructors. A caller check on DeliverMessage runs on every hop; the calling-assembly classification measured about 0.3 µs per call. Narrowing the constructors and Sender's init to the platform is free at run time, but it is not a boundary on its own: the JSON converter that every transport (Orleans deep copy, SignalR, gRPC) uses must set the sender, so any in-process code holding the hub's serializer options can deserialize an envelope with any sender, and reflection can call an internal constructor. It would also need the messaging assembly's internals opened to every routing assembly and test project, which changes overload resolution of the internal IMessageHub.Observe(object, …) there (see MessageSizeGuard). The compile-time check sees the sinks, so an envelope minted by any route still has nowhere to go.

Not established