Skip to content

GraphQL taskEvents subscription streams agent-task metadata for tasks the #268 read gate withholds #329

Description

@beardthelion

#268 gated the agent-task query surfaces (REST GET /api/v1/tasks, GET /api/v1/tasks/{id}, and the GraphQL tasks/task resolvers) behind task_visible. The same task data is also emitted through a third surface that fix did not cover: the GraphQL taskEvents subscription over /graphql/ws. An anonymous websocket subscriber still observes the task id, its status transitions, the DID of the actor, and the timestamp, for tasks the gated read path correctly withholds from that same caller.

This is the same defect as #112/#114 one surface over. There, the refUpdates query and REST feeds were gated and the broadcast fan-out emitter was missed; the fix was write-side, putting the ref_update_tx.send behind the announce boolean the push handler already computed. task_event_tx never got an equivalent.

Where

  • crates/gitlawb-node/src/graphql/subscription.rs:49, async fn task_events. The resolver never reads AuthenticatedDid and applies no visibility filter; it relays whatever enters the channel, filtered only by an optional client-supplied task_id.
  • crates/gitlawb-node/src/server.rs:67. /graphql/ws is registered after the optional_signature layer, and the layer covers only routes added before it, so the subscription carries no caller identity at all and cannot be gated read-side.
  • Six unconditional sends into the channel, every one of them after a successful status transition, with no visibility check: crates/gitlawb-node/src/api/tasks.rs:382 (claim), :436 (complete), :490 (fail), and the GraphQL twins at crates/gitlawb-node/src/graphql/mutation.rs:80, :122, :164.

The sibling ref_updates resolver directly above documents this exact contract: its safety rests entirely on the write side, because the resolver has no caller to gate against. task_events inherited the shape without inheriting the gate.

Impact

Anyone who can open a websocket to the node observes the lifecycle of every agent task on it, including tasks against private repos and repo-less tasks belonging to other parties. That discloses the task's existence, its id, its status transitions, and the DID of whoever claimed, completed, or failed it. payload and ucan_token are not on this channel, so the credential leak #268 found is genuinely closed; what remains is task existence, activity, and actor identity, for tasks the read path answers with a 404.

Worth noting because it widens who can reach it: db.claim_task (crates/gitlawb-node/src/db/mod.rs) matches on id and status='pending' with no assignee condition, so any signed caller can claim any pending task and thereby generate the event themselves rather than waiting for one. (#275 is open against that handler.)

Verified by execution

Against fix/task-read-auth-gate at c4a36e56 (the head of #327, which carries the current gate), a throwaway test in visible_tasks_tests that drives both halves: seed a repo-less task t1 delegated by another party, confirm the gated query surface withholds it from an anonymous caller, then have an anonymous subscriber on the real schema watch while a stranger claims it through the real handler.

The control half passes: anonymous GET /api/v1/tasks/t1 returns 404 and anonymous GET /api/v1/tasks returns count: 0.

The anonymous subscriber then receives:

{"taskEvents":{"taskId":"t1","oldStatus":"pending","newStatus":"claimed","byDid":"did:key:z6MkStranger","at":"2026-08-13T04:53:18.672482288+00:00"}}

Same task, same anonymous caller, one surface withholds it and the other streams it.

Suggested remediation

Gate the emitter, not the reader, for the reason the ref_updates comment already gives: an unauthenticated fan-out channel has no per-subscriber identity to gate against, so the only place to make it safe is what enters the channel.

There is no announce-equivalent boolean for tasks today, but the analogue is available: task_visible(&task, None, &repos_by_id, &rules_by_repo) (crates/gitlawb-node/src/api/tasks.rs:137) is exactly "would an anonymous caller be allowed to read this task", the same question announce answers for a push. The two lookups it needs are the ones collect_visible_tasks already uses, Db::list_repos_deduped_by_ids and Db::list_visibility_rules_for_repos, and every one of the six send sites has database access in scope. Under that gate a repo-less task never broadcasts (its delegator and assignee already hold it and can read it through the gated surface) and a repo-scoped task broadcasts only while its repo is anonymously readable.

Whatever shape it takes, the invariant is that the subscription is only as safe as the set of senders, so it is worth pinning with a test that subscribes anonymously and asserts a withheld task's transition never arrives, rather than only asserting the sender behaves at one call site.

Activity

  1. added
    kind:securityVulnerability fix or hardening
    crate:nodegitlawb-node — the serving node and REST API
    subsystem:visibilityPath-scoped visibility and content withholding
    subsystem:apiNode REST API request/response surface
    sev:highMajor break or real security/trust risk, no easy workaround
    on Aug 13, 2026
  2. euxaristia commented on Aug 22, 2026

    @euxaristia
    Contributor

    Cross-referencing a second, smaller problem on the same route, found while closing a rate-limit gap on #327.

    /graphql/ws also carries no per-IP brake, and it serves QueryRoot as well as SubscriptionRoot. A client can open one socket and send { tasks { items { id } } } repeatedly, reaching collect_visible_tasks and get_visible_task with nothing debiting a bucket.

    That is a cost problem, not a disclosure one, and it is strictly the lesser half of what this issue is about: over the query root the caller is always anonymous (for the reason recorded above, /graphql/ws is registered after the optional_signature layer), so it sees only what an anonymous caller may see. The gate holds; what it does not do is stop a prober making the node run the gate's task lookup plus deduped-repo and visibility-rule queries on every message, for free.

    For context on why this route is now the odd one out, #327 put that brake on the other two surfaces:

    • 4e5adbe — rate_limit_by_ip on task_read_routes covers GET /api/v1/tasks and GET /api/v1/tasks/{id}
    • 4e5adbe — rate_limit::TaskReadBrake rides as GraphQL request data and is debited by the tasks and task resolvers, covering POST /graphql. It is carried as data rather than a router layer because /graphql is one endpoint for every operation, so a layer would charge unrelated queries and every mutation against the task-read bucket.

    The resolver-level debit means a fix here needs no resolver changes at all: the brake just has to reach the ws execution context. That is replacing route_service("/graphql/ws", GraphQLSubscription::new(schema)) in crates/gitlawb-node/src/server.rs with a WebSocketUpgrade handler that resolves the client key at upgrade time through the existing client_key / PeerAddr / push_limiter_trust path and passes the brake in via GraphQLWebSocket::with_data. axum's ws feature is already enabled, so no new dependency for the fix itself. Testing it does need a websocket client, which is not currently in crates/gitlawb-node/Cargo.toml dev-dependencies.

    Noting it here rather than opening a separate issue because it is the same route and the write-side emitter gate proposed above will put someone in this code anyway. It is independent of that gate, though: gating what enters task_event_tx fixes the disclosure and leaves the query-cost lane exactly as it is.

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

    crate:nodegitlawb-node — the serving node and REST APIkind:securityVulnerability fix or hardeningsev:highMajor break or real security/trust risk, no easy workaroundsubsystem:apiNode REST API request/response surfacesubsystem:visibilityPath-scoped visibility and content withholding

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions