Repository navigation
Description
What's wrong
LockListRefresher.ProbeAsync decides admission from the status code alone (LockListRefresher.cs L60-L66):
return response.IsSuccessStatusCode
? LockRefreshResult.Succeeded(new LockSnapshot([], timeProvider.GetUtcNow()))
: LockRefreshResult.Refused(response.StatusCode);LockListService.ProbeAsync then admits the credential for AdmissionTtl and serves the current snapshot. That snapshot was built by a different caller's credential.
The full walk in RefreshAsync is stricter. If a 2xx body does not parse as a lock page, the walk returns Unusable and the request is relayed. So the probe, which stands in for the walk, applies a weaker standard than the walk does.
The README says "an admission only ever exists because a real upstream call succeeded", and ICredentialAdmission says the same. A sign-in page is not a successful lock listing.
Reproduction (run against c6923c8, .NET 10, ProxyFixture)
- A valid credential (
Basic dXNlcjp0b2tlbg==) callsGET /github/owner/repo.git/info/lfs/locks. That caches a snapshot containingContent/secret.uasset. - The stub upstream switches to answering
GET .../lockswith203 Non-Authoritative Informationand a body that is not a lock page. - A request with
Authorization: Basic Ym9ndXM6Ym9ndXM=(bogus:bogus) gets:The bogus credential is also admitted for200 {"locks":[{"id":"0","path":"Content/secret.uasset","owner":{"name":"someone"}}]}AdmissionTtl.
There is a second route to the same outcome, which I traced but did not run (the stub cannot redirect). The upstream HttpClient is registered with the default primary handler (GitLfsCacheServiceCollectionExtensions.cs L116). That handler has AllowAutoRedirect = true and drops Authorization on the redirect hop. So if the upstream redirects a bad credential to an HTML login page, ProbeAsync ends up looking at the login page's 200 and admits the credential.
Why it matters
Behind a forge or SSO front end that answers bad credentials with a 203 sign-in page or a redirect to a login page, any non-empty Authorization header gets admitted. Azure DevOps is known to return 203 sign-in pages for unauthenticated API calls. The caller then sees:
- lock paths
- lock ids, which are enough to target a force-unlock attempt
- owner names
All of it comes from snapshots that other users populated. #81 handled the same SSO-page shape for batch calls. This is the lock-admission counterpart.
GitHub, GitLab and Gitea answer bad credentials with 401, so they are unaffected.
Suggested fix
- In
ProbeAsync, admit only when the status is exactly 200 and the body parses withLockListParser.TryParsePage, the checkRefreshAsyncalready uses.- Treat any other 2xx, or an unparseable body, as
Unusable, which relays the request. Do not treat it as success. - Keep 401, 403 and 404 as
Refused.
- Treat any other 2xx, or an unparseable body, as
- Disable automatic redirects on the upstream client, at least for lock calls (
ConfigurePrimaryHttpMessageHandler(() => new SocketsHttpHandler { AllowAutoRedirect = false })). A redirect then surfaces as a 3xx, which is relayed or refused, instead of the login page's 200. - Update the "body is irrelevant" comment at L50-L51 to describe the new check.
Acceptance criteria
- A probe answered with 203, or with a 200 that is not a lock page, does not admit the credential and serves no snapshot.
- A test covers the 203 case above, and a test covers a 302 to a 200 HTML page.
- Existing admission tests still pass.
Activity
matt-edmondson commentedon Nov 3, 2026
Triage
Authorizationheader can read lock paths, ids and owner names cached from another user's credential. This only happens behind an upstream that answers bad credentials with a non-401 2xx (Azure DevOps 203 sign-in pages) or with a redirect to a login page. GitHub, GitLab and Gitea answer with 401 and are unaffected, which is why this is High and not Critical.GitLfsCache/Locks:LockListRefresher.ProbeAsyncandLockListServiceadmission. The redirect route is in the upstreamHttpClientregistration (GitLfsCacheServiceCollectionExtensions.cs).locks/batchunlock-by-path reads the snapshot with no admission check at all. Here admission exists, but the probe is too weak. Both should land before lock caching is relied on behind an SSO front end.LockListParser.TryParsePage.Unusable.AllowAutoRedirect = falseon the upstream handler.Generated by Claude Code