Skip to content

Cached lock listings never send the client's refspec upstream, so a snapshot keyed by ref holds (and authorizes against) the no-ref answer #47

Description

@matt-edmondson

What's wrong

The lock snapshot is keyed by (upstream, repositoryPath, ref) (LockSnapshotKey), and LockRouteHandler.ListAsync fills Ref from the client's ?refspec=. The design doc (docs/superpowers/specs/2026-08-19-locks-subsystem-design.md, "The snapshot") gives the reason:

ref is part of the key because the specification states the locking API's ref property is for authentication only, which means two callers presenting different refs may legitimately receive different answers and must not share an entry.

The requests that actually fill that entry never send the ref, though. UpstreamRequests.BuildLockListRequest(upstreamBase, repositoryPath, cursor, limit, authorization) has no refspec parameter, and neither caller passes key.Ref:

  • LockListRefresher.RefreshAsync, the page walk that builds the snapshot
  • LockListRefresher.ProbeAsync, the one-page credential check used to admit a caller to an existing snapshot

So the entry stored under refspec=refs/heads/feature holds upstream's answer to an unscoped GET .../locks, and the credential check behind it is also unscoped. Keying by ref adds nothing here: every ref gets the same content, fetched separately.

Batched lock/unlock is handled correctly. LockFanOut does forward request.Ref in the body it sends upstream.

Why it matters

  • Answers depend on cache state. With Locks:Enabled=false, or whenever the handler falls back to relaying, GET .../locks?refspec=X goes upstream verbatim with the refspec (UpstreamRelay forwards QueryString). With the cache on, the same request gets the unscoped answer. A forge that scopes listing by ref returns different lock sets for the same client request depending on whether a snapshot happened to be warm.
  • Authorization isn't checked against the ref the client presented. The design doc treats ref as an authentication input, and ProbeAsync exists so that upstream stays the authority on whether this client may read these locks. Because the probe omits the ref, a forge that would refuse a credential for refs/heads/feature but accept it unscoped will let that caller through, and it will be served the cached listing.

Against a forge that ignores refspec (probably GitHub), the only effect is duplicate snapshots per ref. Against one that honours it, the cache gives wrong answers.

Suggested fix

  • Add a string? refspec parameter to BuildLockListRequest and append refspec=<escaped> when it's non-null.
  • Pass key.Ref from both RefreshAsync and ProbeAsync.
  • Check whether CredentialAdmission's key should include the ref too, so a credential admitted for one ref isn't treated as admitted for another.

Acceptance: an integration test with StubUpstream that records the query string shows that a cached GET /locks?refspec=refs/heads/main produces upstream list and probe requests that carry refspec=refs%2Fheads%2Fmain, and that a request with no refspec produces upstream requests without one.

Related but separate: #46 covers invalidation after create/unlock using the wrong key. This issue is about what the snapshot is filled with.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Labels

readyFully specified; implement as written

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions