Skip to content

Bind streamed enumerations to their initiator: MoveNext/DisposeAsync accept any caller holding the enumeration Guid #56

Description

@LeftTwixWand

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:

  1. When StartEnumeration is authorized, record enumerationId -> initiator GrainId.
  2. On MoveNext and DisposeAsync, require context.SourceId to match the recorded initiator, and
    refuse with NeuronAuthorizationException otherwise.
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:uiDigitalbrain ui work

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions