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.
%2F is not a problem: Kestrel keeps it encoded and Uri does not collapse it.
- On GitHub (
https://github.com with studio/**), 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
Combine builds the upstream URI, check that its AbsolutePath still 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
UpstreamContainmentTests covers a raw \ and %5C climbing 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 under studio/**.
- The existing dot-segment tests still pass.
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.Uritreats\as/inhttp/httpsURLs before it removes dot segments.UpstreamRequests.Combinebuilds every upstream URL asnew Uri($"{base}/{path}"). So a segment such asmyproject\..\..\otherorgpasses the parser, because splitting on/produces no.or..segment.Urithen turns it into a climb above the configured base URL. Kestrel hands both a raw\and an encoded%5Cthrough toRequest.Path.Valueas a literal backslash. It does not normalise them the way it normalises/../.The allow-list does not catch this either.
RepositoryAllowList.IsAllowedmatches the rawRelayPathstring (called fromGitLfsCacheHandler.cs:248).myproject/**compiles to^myproject/.*$, and that pattern matchesmyproject\..\..\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 atLfsRouteParser.cs:50-55makes 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/**"]The parser's logic plus
Combineproduce:\(curl --path-as-is) behaves the same.%2Fis not a problem: Kestrel keeps it encoded andUridoes not collapse it.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:
Suggested fix
LfsRouteParser.TryParse, refuse any path that contains\. Git and forge repository paths never contain one, so a 404 is correct.Combinebuilds the upstream URI, check that itsAbsolutePathstill starts with the base URL's path. Refuse the request if it does not.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.studio\..\other/...is not allowed understudio/**.