Streamed MoveNext and DisposeAsync are reachable by an unattributed caller that holds the enumeration Guid. Every other gate in OwnerBoundCallFilter is identity-bound; this one is not.
Why it exists
Orleans dispatches IAsyncEnumerable<T> grain methods through AsyncEnumeratorProxy<T> as
IAsyncEnumerableGrainExtension.StartEnumeration followed by repeated MoveNext. StartEnumeration
carries an IAsyncEnumerableRequest<T> argument, so the real contract method can be resolved and
authorized — that is what #55 fixed. MoveNext and DisposeAsync carry only a bare Guid, so they
resolve to no contract method and must pass through the filter for streaming to work past the first
batch.
Fixing StartEnumeration alone is not an option: a 250-element stream is then refused at the second
hop with NeuronAuthorizationException : 'MoveNext' is not a client entry point, so client streaming
works for short replies and breaks for long ones.
The exposure
Orleans keeps _enumerators as a Dictionary<Guid, EnumeratorState> with no caller identity
recorded. Consequently, for a caller holding a live enumeration id:
MoveNext(guid) advances the enumerator and returns the elements to that caller. The legitimate
consumer never sees those elements and receives no error — disclosure plus silent theft.
DisposeAsync(guid) cancels the enumeration's token with no caller check — an unauthenticated kill
of any in-flight stream.
There is no second gate: CapabilityInvocation.ContractMethod returns null for MoveNext, so
IncomingReificationFilter falls straight through as well.
Why it is not currently exploitable
Verified against the Orleans source rather than assumed:
Guid.NewGuid() is crypto-RNG with 122 bits of entropy (AsyncEnumerableRequest.cs:239).
- Orleans'
ActivityPropagationGrainCallFilter tags only rpc.system, rpc.service, rpc.method,
rpc.target_id and rpc.source_id — never arguments.
SynapseTelemetry emits no enumeration id.
- Repository-wide, the enumeration id appears only as a dictionary key. It is never logged, journaled,
or placed on a span.
So the identifier is genuinely unguessable and unobservable today. That property is undocumented
and pinned by no test: a single future diagnostic log line that prints an enumeration id converts this
into a live vulnerability, with nothing in the suite to catch it.
Proposed fix
Bind the enumeration to its initiator:
- When
StartEnumeration is authorized, record enumerationId -> initiator GrainId.
- On
MoveNext and DisposeAsync, require context.SourceId to match the recorded initiator, and
refuse with NeuronAuthorizationException otherwise.
- Release the binding on terminal status or dispose, with the same exactly-once discipline the
streamed capability registry already uses.
Rejected as insufficient: requiring only that the target expose some [ClientEntryPoint] contract.
That is a no-op for exactly the neurons worth attacking — ILLM and chat both carry it — and does
nothing cross-owner.
Acceptance
- An unattributed caller that did not initiate an enumeration is refused on
MoveNext and on
DisposeAsync, each pinned by a test that fails without the binding.
- A cross-owner caller holding a valid enumeration id is refused.
- The legitimate consumer still receives every element in order, including across a real
MoveNext
hop — cover with a >100-element stream, since ScriptedChatClient yields exactly one update per
reply and can never span a batch.
- Owner isolation and the existing
StartEnumeration target guard are unchanged.
Context
Found during #55 while making streaming the primary response path. The owner's decision was to ship
the minimal fix — which closes the real bug and is strictly better than the previous state — and track
this separately rather than bury it.
Streamed
MoveNextandDisposeAsyncare reachable by an unattributed caller that holds the enumerationGuid. Every other gate inOwnerBoundCallFilteris identity-bound; this one is not.Why it exists
Orleans dispatches
IAsyncEnumerable<T>grain methods throughAsyncEnumeratorProxy<T>asIAsyncEnumerableGrainExtension.StartEnumerationfollowed by repeatedMoveNext.StartEnumerationcarries an
IAsyncEnumerableRequest<T>argument, so the real contract method can be resolved andauthorized — that is what #55 fixed.
MoveNextandDisposeAsynccarry only a bareGuid, so theyresolve to no contract method and must pass through the filter for streaming to work past the first
batch.
Fixing
StartEnumerationalone is not an option: a 250-element stream is then refused at the secondhop with
NeuronAuthorizationException : 'MoveNext' is not a client entry point, so client streamingworks for short replies and breaks for long ones.
The exposure
Orleans keeps
_enumeratorsas aDictionary<Guid, EnumeratorState>with no caller identityrecorded. Consequently, for a caller holding a live enumeration id:
MoveNext(guid)advances the enumerator and returns the elements to that caller. The legitimateconsumer never sees those elements and receives no error — disclosure plus silent theft.
DisposeAsync(guid)cancels the enumeration's token with no caller check — an unauthenticated killof any in-flight stream.
There is no second gate:
CapabilityInvocation.ContractMethodreturns null forMoveNext, soIncomingReificationFilterfalls straight through as well.Why it is not currently exploitable
Verified against the Orleans source rather than assumed:
Guid.NewGuid()is crypto-RNG with 122 bits of entropy (AsyncEnumerableRequest.cs:239).ActivityPropagationGrainCallFiltertags onlyrpc.system,rpc.service,rpc.method,rpc.target_idandrpc.source_id— never arguments.SynapseTelemetryemits no enumeration id.or placed on a span.
So the identifier is genuinely unguessable and unobservable today. That property is undocumented
and pinned by no test: a single future diagnostic log line that prints an enumeration id converts this
into a live vulnerability, with nothing in the suite to catch it.
Proposed fix
Bind the enumeration to its initiator:
StartEnumerationis authorized, recordenumerationId -> initiator GrainId.MoveNextandDisposeAsync, requirecontext.SourceIdto match the recorded initiator, andrefuse with
NeuronAuthorizationExceptionotherwise.streamed capability registry already uses.
Rejected as insufficient: requiring only that the target expose some
[ClientEntryPoint]contract.That is a no-op for exactly the neurons worth attacking —
ILLMand chat both carry it — and doesnothing cross-owner.
Acceptance
MoveNextand onDisposeAsync, each pinned by a test that fails without the binding.MoveNexthop — cover with a >100-element stream, since
ScriptedChatClientyields exactly one update perreply and can never span a batch.
StartEnumerationtarget guard are unchanged.Context
Found during #55 while making streaming the primary response path. The owner's decision was to ship
the minimal fix — which closes the real bug and is strictly better than the previous state — and track
this separately rather than bury it.