Repository navigation
A backslash in the request path escapes the upstream base-URL prefix and the repository allow-list #73
Description
What's wrong
The only containment guard is in LfsRouteParser.TryParse (GitLfsCache/Endpoints/LfsRouteParser.cs:57). It splits the path on / and refuses any segment that is exactly . or ...
System.Uri treats \ as / in http/https URLs before it removes dot segments. UpstreamRequests.Combine builds every upstream URL as new Uri($"{base}/{path}"). So a segment such as myproject\..\..\otherorg passes the parser, because splitting on / produces no . or .. segment. Uri then turns it into a climb above the configured base URL. Kestrel hands both a raw \ and an encoded %5C through to Request.Path.Value as a literal backslash. It does not normalise them the way it normalises /../.
The allow-list does not catch this either. RepositoryAllowList.IsAllowed matches the raw RelayPath string (called from GitLfsCacheHandler.cs:248). myproject/** compiles to ^myproject/.*$, and that pattern matches myproject\..\..\otherorg/....
This breaks the guarantee in the locks spec's "Path containment" section (docs/superpowers/specs/2026-08-19-locks-subsystem-design.md), which calls the base-URL prefix "a tenancy boundary in practice". The comment at LfsRouteParser.cs:50-55 makes the same claim.
Failure scenario (verified in a scratch Kestrel app on .NET 10)
Config: Upstreams:ado:BaseUrl=https://dev.azure.com/myorg, Repositories=["myproject/**"]
POST /ado/myproject%5C..%5C..%5Cotherorg/proj/_git/repo/info/lfs/objects/batch
The parser's logic plus Combine produce:
path=/ado/myproject\..\..\otherorg/proj/_git/repo/info/lfs/objects/batch dotsegs=False
combined=https://dev.azure.com/otherorg/proj/_git/repo/info/lfs/objects/batch
- A raw
\(curl --path-as-is) behaves the same. - The lock routes escape the same way.
%2Fis not a problem: Kestrel keeps it encoded andUridoes not collapse it.- On GitHub (
https://github.comwithstudio/**),studio\..\anyorg/repo.git/info/lfs/...reaches any repository.
Upstream still authorises each call with the client's own credential, so no access is granted. The escape does defeat the resource controls:
- Batch responses for disallowed repositories are rewritten and cached. They consume the byte budget and evict the working set, which the allow-list exists to prevent.
- Lock routes build snapshots for arbitrary repositories. The spec relies on the allow-list to keep snapshot memory bounded.
- The relay route passes everything else through to URLs outside the prefix.
Suggested fix
- In
LfsRouteParser.TryParse, refuse any path that contains\. Git and forge repository paths never contain one, so a 404 is correct. - As defence in depth, after
Combinebuilds the upstream URI, check that itsAbsolutePathstill starts with the base URL's path. Refuse the request if it does not. - Mention backslashes in the spec's "Path containment" paragraph.
Acceptance criteria
UpstreamContainmentTestscovers a raw\and%5Cclimbing out of a prefixed base URL on the batch, relay and locks routes. Each returns 404 and makes no upstream request.- An allow-list test shows that
studio\..\other/...is not allowed understudio/**. - The existing dot-segment tests still pass.
Activity
matt-edmondson commentedon Sep 28, 2026
Triage
\or%5Csegment gets past the base-URL tenancy prefix and the repository allow-list. Unapproved repositories can then consume the cache's byte budget and lock-snapshot memory, and relay traffic can reach URLs outside the configured prefix. The prefix is documented as a tenancy boundary.Endpoints/LfsRouteParser.cs,UpstreamRequests.Combine,RepositoryAllowList. @matt-edmondsonUpstreamContainmentTests, but not the same issue.Notes: Do both halves of the fix. Rejecting
\in the parser is the primary fix. The post-Combinecheck thatAbsolutePathstill starts with the base path is the defence in depth that stops the next encoding trick. The sameCombinepath is shared by batch, relay and locks, so the acceptance tests should cover all three routes.Generated by Claude Code