Repository navigation
GraphQL taskEvents subscription streams agent-task metadata for tasks the #268 read gate withholds #329
Description
Activity
- addedkind:securityVulnerability fix or hardeningVulnerability fix or hardeningcrate:nodegitlawb-node — the serving node and REST APIgitlawb-node — the serving node and REST APIsubsystem:visibilityPath-scoped visibility and content withholdingPath-scoped visibility and content withholdingsubsystem:apiNode REST API request/response surfaceNode REST API request/response surfacesev:highMajor break or real security/trust risk, no easy workaroundMajor break or real security/trust risk, no easy workaround
on Aug 13, 2026 Cross-referencing a second, smaller problem on the same route, found while closing a rate-limit gap on #327.
/graphql/wsalso carries no per-IP brake, and it servesQueryRootas well asSubscriptionRoot. A client can open one socket and send{ tasks { items { id } } }repeatedly, reachingcollect_visible_tasksandget_visible_taskwith 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/wsis registered after theoptional_signaturelayer), 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_ipontask_read_routescoversGET /api/v1/tasksandGET /api/v1/tasks/{id}4e5adbe—rate_limit::TaskReadBrakerides as GraphQL request data and is debited by thetasksandtaskresolvers, coveringPOST /graphql. It is carried as data rather than a router layer because/graphqlis 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))incrates/gitlawb-node/src/server.rswith aWebSocketUpgradehandler that resolves the client key at upgrade time through the existingclient_key/PeerAddr/push_limiter_trustpath and passes the brake in viaGraphQLWebSocket::with_data.axum'swsfeature is already enabled, so no new dependency for the fix itself. Testing it does need a websocket client, which is not currently incrates/gitlawb-node/Cargo.tomldev-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_txfixes the disclosure and leaves the query-cost lane exactly as it is.
#268 gated the agent-task query surfaces (REST
GET /api/v1/tasks,GET /api/v1/tasks/{id}, and the GraphQLtasks/taskresolvers) behindtask_visible. The same task data is also emitted through a third surface that fix did not cover: the GraphQLtaskEventssubscription 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
refUpdatesquery and REST feeds were gated and the broadcast fan-out emitter was missed; the fix was write-side, putting theref_update_tx.sendbehind theannounceboolean the push handler already computed.task_event_txnever got an equivalent.Where
crates/gitlawb-node/src/graphql/subscription.rs:49,async fn task_events. The resolver never readsAuthenticatedDidand applies no visibility filter; it relays whatever enters the channel, filtered only by an optional client-suppliedtask_id.crates/gitlawb-node/src/server.rs:67./graphql/wsis registered after theoptional_signaturelayer, and the layer covers only routes added before it, so the subscription carries no caller identity at all and cannot be gated read-side.crates/gitlawb-node/src/api/tasks.rs:382(claim),:436(complete),:490(fail), and the GraphQL twins atcrates/gitlawb-node/src/graphql/mutation.rs:80,:122,:164.The sibling
ref_updatesresolver directly above documents this exact contract: its safety rests entirely on the write side, because the resolver has no caller to gate against.task_eventsinherited 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.
payloadanducan_tokenare 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 onidandstatus='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-gateatc4a36e56(the head of #327, which carries the current gate), a throwaway test invisible_tasks_teststhat drives both halves: seed a repo-less taskt1delegated 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/t1returns 404 and anonymousGET /api/v1/tasksreturnscount: 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_updatescomment 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 questionannounceanswers for a push. The two lookups it needs are the onescollect_visible_tasksalready uses,Db::list_repos_deduped_by_idsandDb::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.