From d2c65879965a4fa82972029ee413d00174f5af47 Mon Sep 17 00:00:00 2001 From: Simon Chrzanowski Date: Sat, 24 Oct 2026 08:02:07 +0200 Subject: [PATCH 1/3] Hash path-based metadata cache keys Conan, CRAN, Conda, RPM, the main Debian archive and the default Maven repository keyed cached metadata by the request path with '/' replaced by '_'. Names and versions may contain '_', so foo/bar_1.0 and foo_bar/1.0 shared an entry, and with cache_metadata one reference's recipe files were served for the other. Conan also appended the query with '_', so a path without a query could collide with a shorter path plus one. Hash the upstream URL, the path and, for Conan, the query instead, as the APK, Arch, generic and Helm handlers already do. Each part is length-prefixed so no two inputs share a key. --- internal/handler/cache_key.go | 22 ++++++++ internal/handler/cache_key_test.go | 74 +++++++++++++++++++++++++ internal/handler/conan.go | 6 +- internal/handler/conda.go | 7 ++- internal/handler/conda_encoding_test.go | 10 ++-- internal/handler/conda_shards.go | 8 +-- internal/handler/cran.go | 9 ++- internal/handler/debian.go | 6 +- internal/handler/debian_test.go | 17 +++--- internal/handler/maven.go | 8 +-- internal/handler/rpm.go | 8 ++- 11 files changed, 137 insertions(+), 38 deletions(-) create mode 100644 internal/handler/cache_key.go create mode 100644 internal/handler/cache_key_test.go diff --git a/internal/handler/cache_key.go b/internal/handler/cache_key.go new file mode 100644 index 00000000..684c5d76 --- /dev/null +++ b/internal/handler/cache_key.go @@ -0,0 +1,22 @@ +package handler + +import ( + "crypto/sha256" + "encoding/hex" + "strconv" +) + +// hashedCacheKey derives a metadata cache key from parts such as the +// upstream URL, the request path and the query. Each part is length-prefixed +// before hashing, so no two different part lists share a key, whatever bytes +// the parts contain. Keys built by replacing '/' with '_' were ambiguous: +// foo/bar_1.0 and foo_bar/1.0 mapped to the same entry. +func hashedCacheKey(parts ...string) string { + h := sha256.New() + for _, part := range parts { + _, _ = h.Write([]byte(strconv.Itoa(len(part)))) + _, _ = h.Write([]byte{':'}) + _, _ = h.Write([]byte(part)) + } + return hex.EncodeToString(h.Sum(nil)) +} diff --git a/internal/handler/cache_key_test.go b/internal/handler/cache_key_test.go new file mode 100644 index 00000000..8af47a2e --- /dev/null +++ b/internal/handler/cache_key_test.go @@ -0,0 +1,74 @@ +package handler + +import ( + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// TestConanMetadataKeysKeepReferencesApart checks that Conan references whose +// paths only differ in where '/' and '_' fall, or in a query, are cached +// apart. Names and versions may contain '_'. +func TestConanMetadataKeysKeepReferencesApart(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.WriteString(w, r.URL.Path+"?"+r.URL.RawQuery) + })) + defer upstream.Close() + + proxy, _, _, _ := setupTestProxy(t) + proxy.CacheMetadata = true + proxy.MetadataTTL = time.Hour + proxy.HTTPClient = upstream.Client() + srv := httptest.NewServer(NewConanHandlerWithUpstream(proxy, testProxyURL, upstream.URL).Routes()) + defer srv.Close() + + for _, pair := range [][2]string{ + {"/v2/conans/foo/bar_1.0/_/_/latest", "/v2/conans/foo_bar/1.0/_/_/latest"}, + {"/v2/conans/foo/bar_1.0/_/_/revisions/r1/files/conanfile.py", "/v2/conans/foo_bar/1.0/_/_/revisions/r1/files/conanfile.py"}, + {"/v2/conans/search?q=zlib", "/v2/conans/search_q=zlib"}, + } { + for _, target := range pair { + resp, err := http.Get(srv.URL + target) + if err != nil { + t.Fatal(err) + } + body, _ := io.ReadAll(resp.Body) + _ = resp.Body.Close() + want := target + if !strings.Contains(want, "?") { + want += "?" + } + if string(body) != want { + t.Errorf("GET %s served %q, want %q", target, body, want) + } + } + } +} + +// TestMetadataCacheKeysUnambiguous checks that the path-based metadata cache +// keys of the other ecosystems keep paths apart that differ only in where +// '/' and '_' fall. +func TestMetadataCacheKeysUnambiguous(t *testing.T) { + const a, b = "x/foo/bar_1.0/file", "x/foo_bar/1.0/file" + + maven := mavenSource{upstreamURL: "https://repo.test"} + debian := NewDebianHandler(nil, "http://proxy.test", "https://deb.test", nil) + rpm := NewRPMHandlerWithUpstream(nil, "http://proxy.test", "https://rpm.test") + cran := NewCRANHandlerWithUpstream(nil, "http://proxy.test", "https://cran.test") + conda := NewCondaHandlerWithUpstream(nil, "http://proxy.test", "https://conda.test") + + for name, key := range map[string]func(string) string{ + "maven": maven.metadataCacheKey, + "debian": func(p string) string { return debian.metadataCacheKeyFor("", debian.upstreamURL, p) }, + "rpm": func(p string) string { return rpm.metadataCacheKey(p) }, + "cran": func(p string) string { return cran.metadataCacheKey("/" + p) }, + "conda": func(p string) string { return conda.metadataCacheKey("/" + p) }, + } { + if key(a) == key(b) { + t.Errorf("%s: %s and %s share metadata cache key %q", name, a, b, key(a)) + } + } +} diff --git a/internal/handler/conan.go b/internal/handler/conan.go index 4208be1f..dddd79f4 100644 --- a/internal/handler/conan.go +++ b/internal/handler/conan.go @@ -162,11 +162,7 @@ func (h *ConanHandler) shouldCacheFile(filename string) bool { // proxyCached forwards a request with metadata caching. func (h *ConanHandler) proxyCached(w http.ResponseWriter, r *http.Request) { - cacheKey := strings.TrimPrefix(r.URL.Path, "/") - cacheKey = strings.ReplaceAll(cacheKey, "/", "_") - if r.URL.RawQuery != "" { - cacheKey += "_" + r.URL.RawQuery - } + cacheKey := hashedCacheKey(h.upstreamURL, r.URL.Path, r.URL.RawQuery) upstreamURL := h.upstreamURL + r.URL.Path if r.URL.RawQuery != "" { upstreamURL += "?" + r.URL.RawQuery diff --git a/internal/handler/conda.go b/internal/handler/conda.go index 0f229fcd..1142ccf2 100644 --- a/internal/handler/conda.go +++ b/internal/handler/conda.go @@ -320,9 +320,14 @@ func (h *CondaHandler) recordInCooldown(filename, name, version string, timestam return true } +// metadataCacheKey is the metadata cache key of a request path. +func (h *CondaHandler) metadataCacheKey(urlPath string) string { + return hashedCacheKey(h.upstreamURL, urlPath) +} + // proxyCached forwards a metadata request with caching. func (h *CondaHandler) proxyCached(w http.ResponseWriter, r *http.Request) { - cacheKey := condaCacheKey(r.URL.Path) + cacheKey := h.metadataCacheKey(r.URL.Path) // Large JSON indexes can exceed the metadata limit uncompressed. Conda // clients decode Content-Encoding, so cache and relay gzip verbatim. The // already-compressed repodata.json.bz2 route must stay on identity. diff --git a/internal/handler/conda_encoding_test.go b/internal/handler/conda_encoding_test.go index 4aa59518..a8f3a79f 100644 --- a/internal/handler/conda_encoding_test.go +++ b/internal/handler/conda_encoding_test.go @@ -40,7 +40,8 @@ func TestCondaRepodataGzip(t *testing.T) { if tt.mode == "cached" { proxy.MetadataTTL = time.Hour } - handler := NewCondaHandlerWithUpstream(proxy, "http://proxy.local", upstream.URL).Routes() + h := NewCondaHandlerWithUpstream(proxy, "http://proxy.local", upstream.URL) + handler := h.Routes() serve := func() *httptest.ResponseRecorder { w := httptest.NewRecorder() handler.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/conda-forge/linux-64/"+tt.filename, nil)) @@ -53,7 +54,7 @@ func TestCondaRepodataGzip(t *testing.T) { if tt.mode == "stream" { return } - entry, err := db.GetMetadataCache("conda", "conda-forge_linux-64_"+tt.filename) + entry, err := db.GetMetadataCache("conda", h.metadataCacheKey("/conda-forge/linux-64/"+tt.filename)) if err != nil { t.Fatal(err) } @@ -122,7 +123,8 @@ func TestCondaRepodataCacheSizeLimit(t *testing.T) { proxy.HTTPClient = upstream.Client() proxy.CacheMetadata = true proxy.MetadataMaxSize = tt.limit - handler := NewCondaHandlerWithUpstream(proxy, "http://proxy.local", upstream.URL).Routes() + h := NewCondaHandlerWithUpstream(proxy, "http://proxy.local", upstream.URL) + handler := h.Routes() w := httptest.NewRecorder() handler.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/conda-forge/linux-64/repodata.json", nil)) if w.Code != tt.status { @@ -132,7 +134,7 @@ func TestCondaRepodataCacheSizeLimit(t *testing.T) { if !bytes.Equal(w.Body.Bytes(), plain) || w.Header().Get(headerContentEncoding) != "" { t.Fatal("identity response was changed or mislabeled as gzip") } - } else if entry, _ := db.GetMetadataCache("conda", "conda-forge_linux-64_repodata.json"); entry != nil { + } else if entry, _ := db.GetMetadataCache("conda", h.metadataCacheKey("/conda-forge/linux-64/repodata.json")); entry != nil { t.Fatal("oversized response was cached") } }) diff --git a/internal/handler/conda_shards.go b/internal/handler/conda_shards.go index f2de4758..c7c7a00f 100644 --- a/internal/handler/conda_shards.go +++ b/internal/handler/conda_shards.go @@ -56,7 +56,7 @@ func (h *CondaHandler) cooldownEnabled() bool { func (h *CondaHandler) handleShardIndex(w http.ResponseWriter, r *http.Request) { channel, arch := r.PathValue("channel"), r.PathValue("arch") upstreamURL := h.upstreamURL + r.URL.Path - cacheKey := condaCacheKey(r.URL.Path) + cacheKey := h.metadataCacheKey(r.URL.Path) body, contentType, contentEncoding, err := h.proxy.fetchOrCacheMetadata(r.Context(), "conda", cacheKey, upstreamURL, "identity", nil, "*/*") if err != nil { @@ -121,7 +121,7 @@ func (h *CondaHandler) handleCooldownShard(w http.ResponseWriter, r *http.Reques } upstreamPath := "/" + channel + "/" + arch + "/" + dir + hex.EncodeToString(masked) + condaShardSuffix - body, _, _, err := h.proxy.fetchOrCacheMetadata(r.Context(), "conda", condaCacheKey(upstreamPath), h.upstreamURL+upstreamPath, "identity", nil, "*/*") + body, _, _, err := h.proxy.fetchOrCacheMetadata(r.Context(), "conda", h.metadataCacheKey(upstreamPath), h.upstreamURL+upstreamPath, "identity", nil, "*/*") if err != nil { h.proxy.writeMetadataFetchError(w, err) return @@ -455,7 +455,3 @@ func msgpackNumber(v any) float64 { } } -// condaCacheKey is the metadata cache key for a request path. -func condaCacheKey(urlPath string) string { - return strings.ReplaceAll(strings.TrimPrefix(urlPath, "/"), "/", "_") -} diff --git a/internal/handler/cran.go b/internal/handler/cran.go index d0dbd52e..803bd4bb 100644 --- a/internal/handler/cran.go +++ b/internal/handler/cran.go @@ -165,11 +165,14 @@ func (h *CRANHandler) isBinaryPackage(filename string) bool { return strings.HasSuffix(filename, ".zip") || strings.HasSuffix(filename, ".tgz") } +// metadataCacheKey is the metadata cache key of a request path. +func (h *CRANHandler) metadataCacheKey(urlPath string) string { + return hashedCacheKey(h.upstreamURL, urlPath) +} + // proxyCached forwards a metadata request with caching. func (h *CRANHandler) proxyCached(w http.ResponseWriter, r *http.Request) { - cacheKey := strings.TrimPrefix(r.URL.Path, "/") - cacheKey = strings.ReplaceAll(cacheKey, "/", "_") - h.proxy.ProxyCached(w, r, h.upstreamURL+r.URL.Path, "cran", cacheKey, "*/*") + h.proxy.ProxyCached(w, r, h.upstreamURL+r.URL.Path, "cran", h.metadataCacheKey(r.URL.Path), "*/*") } // proxyUpstream forwards a request to CRAN without caching. diff --git a/internal/handler/debian.go b/internal/handler/debian.go index dccb8239..a1965479 100644 --- a/internal/handler/debian.go +++ b/internal/handler/debian.go @@ -203,11 +203,11 @@ func (h *DebianHandler) artifactCacheFilename(repository, filename string) strin } // metadataCacheKeyFor returns the metadata cache key for a request. The main -// archive keeps its separator-based key so existing cache entries stay valid; -// a named repository hashes its identity as APKHandler.metadataCacheKey does. +// archive hashes its upstream URL and the path; a named repository hashes its +// identity as APKHandler.metadataCacheKey does. func (h *DebianHandler) metadataCacheKeyFor(repository, upstreamURL, path string) string { if repository == "" { - return strings.ReplaceAll(path, "/", "_") + return hashedCacheKey(upstreamURL, path) } identity := repository + "\x00" + upstreamURL + "\x00" + path digest := sha256.Sum256([]byte(identity)) diff --git a/internal/handler/debian_test.go b/internal/handler/debian_test.go index b94919fa..f31202fa 100644 --- a/internal/handler/debian_test.go +++ b/internal/handler/debian_test.go @@ -35,9 +35,11 @@ func TestDebianHandler_Routes(t *testing.T) { assertRoutesBasics(t, h.Routes(), "/dists/stable/Release", "/pool/../../../etc/passwd") } -// TestDebianHandler_LegacyCacheKeysUnchanged pins the cache identities the -// main archive used before named repositories existed. Deployments carry warm -// caches across upgrades, so a changed key here silently discards them. +// TestDebianHandler_LegacyCacheKeysUnchanged pins the artifact cache identity +// the main archive used before named repositories existed. Deployments carry +// warm caches across upgrades, so a changed key here silently discards them. +// Metadata is cheap to refetch; its keys are covered by +// TestMetadataCacheKeysUnambiguous. func TestDebianHandler_LegacyCacheKeysUnchanged(t *testing.T) { const ( poolPath = "pool/main/h/hello/hello_2.10-3_amd64.deb" @@ -54,17 +56,12 @@ func TestDebianHandler_LegacyCacheKeysUnchanged(t *testing.T) { t.Errorf("legacy artifact cache filename = %q, want %q", got, "hello_2.10-3_amd64.deb") } - // Metadata cache: path separators replaced with underscores. - if got := h.metadataCacheKeyFor("", h.upstreamURL, distPath); got != "dists_trixie_InRelease" { - t.Errorf("legacy metadata cache key = %q, want %q", got, "dists_trixie_InRelease") - } - // A named repository must not reuse either identity. if got := h.artifactCacheFilename("security", filename); got == "hello_2.10-3_amd64.deb" { t.Error("named repository reused the legacy artifact cache filename") } - if got := h.metadataCacheKeyFor("security", "https://security.test", distPath); got == "dists_trixie_InRelease" { - t.Error("named repository reused the legacy metadata cache key") + if h.metadataCacheKeyFor("security", "https://security.test", distPath) == h.metadataCacheKeyFor("", h.upstreamURL, distPath) { + t.Error("named repository reused the main archive's metadata cache key") } } diff --git a/internal/handler/maven.go b/internal/handler/maven.go index c201e74b..90c4ce5a 100644 --- a/internal/handler/maven.go +++ b/internal/handler/maven.go @@ -146,12 +146,12 @@ func (h *MavenHandler) handleRequest(w http.ResponseWriter, r *http.Request) { } // metadataCacheKey is the metadata cache key of urlPath. The default -// repository keeps its original keys. A named repository's keys are hashed -// from its name, upstream URL and the path, so repositories never share -// entries and repointing one drops what it cached. +// repository's keys are hashed from its upstream URLs and the path. A named +// repository's keys are hashed from its name, upstream URL and the path, so +// repositories never share entries and repointing one drops what it cached. func (s mavenSource) metadataCacheKey(urlPath string) string { if s.name == "" { - return strings.ReplaceAll(urlPath, "/", "_") + return hashedCacheKey(s.upstreamURL, s.fallbackURL, urlPath) } digest := sha256.Sum256([]byte(s.name + "\x00" + s.upstreamURL + "\x00" + urlPath)) return "repo_" + hex.EncodeToString(digest[:]) diff --git a/internal/handler/rpm.go b/internal/handler/rpm.go index 3321ccc6..8a3a7629 100644 --- a/internal/handler/rpm.go +++ b/internal/handler/rpm.go @@ -122,8 +122,12 @@ func (h *RPMHandler) handlePackageDownload(w http.ResponseWriter, r *http.Reques // handleMetadata proxies repository metadata files (repomd.xml, primary.xml.gz, etc.). // These change frequently so we don't cache them. func (h *RPMHandler) handleMetadata(w http.ResponseWriter, r *http.Request, path string) { - cacheKey := strings.ReplaceAll(path, "/", "_") - h.proxy.ProxyCached(w, r, fmt.Sprintf("%s/%s", h.upstreamURL, path), "rpm", cacheKey, "*/*") + h.proxy.ProxyCached(w, r, fmt.Sprintf("%s/%s", h.upstreamURL, path), "rpm", h.metadataCacheKey(path), "*/*") +} + +// metadataCacheKey is the metadata cache key of a repository path. +func (h *RPMHandler) metadataCacheKey(path string) string { + return hashedCacheKey(h.upstreamURL, path) } // proxyFile proxies any file directly without caching. From d64bfc6d365a710bf26bee15642bd3ae863db405 Mon Sep 17 00:00:00 2001 From: Simon Chrzanowski Date: Sat, 24 Oct 2026 08:03:45 +0200 Subject: [PATCH 2/3] Refuse '?' and '#' in paths in more handlers Debian, RPM, APK, PyPI, CRAN and Maven build upstream URLs from the decoded request path, where a '?' or '#' would end the path and cache one file under another's name. Only the server middleware refused them; Arch and FreeBSD also check in the handler. Check in these handlers too, so they stay safe when mounted without the middleware. --- internal/handler/apk.go | 4 +- internal/handler/conda_shards.go | 1 - internal/handler/cran.go | 2 +- internal/handler/debian.go | 4 +- internal/handler/handler.go | 15 ++++++ internal/handler/maven.go | 2 +- internal/handler/pypi.go | 2 +- internal/handler/query_in_path_test.go | 71 ++++++++++++++++++++++++++ internal/handler/rpm.go | 4 +- 9 files changed, 98 insertions(+), 7 deletions(-) create mode 100644 internal/handler/query_in_path_test.go diff --git a/internal/handler/apk.go b/internal/handler/apk.go index 6e18e742..f915c306 100644 --- a/internal/handler/apk.go +++ b/internal/handler/apk.go @@ -82,7 +82,9 @@ func (h *APKHandler) Routes() http.Handler { path := strings.TrimPrefix(r.URL.Path, "/") - if containsPathTraversal(path) { + // A decoded '?' or '#' would cut the upstream path short and cache one + // file under another file's name. + if containsPathTraversal(path) || strings.ContainsAny(path, "?#") { http.Error(w, "invalid path", http.StatusBadRequest) return } diff --git a/internal/handler/conda_shards.go b/internal/handler/conda_shards.go index c7c7a00f..01434e48 100644 --- a/internal/handler/conda_shards.go +++ b/internal/handler/conda_shards.go @@ -454,4 +454,3 @@ func msgpackNumber(v any) float64 { return 0 } } - diff --git a/internal/handler/cran.go b/internal/handler/cran.go index 803bd4bb..7cba9f47 100644 --- a/internal/handler/cran.go +++ b/internal/handler/cran.go @@ -63,7 +63,7 @@ func (h *CRANHandler) Routes() http.Handler { // Binary package downloads mux.HandleFunc("GET /bin/{platform}/contrib/{rversion}/{filename}", h.handleBinaryDownload) - return mux + return rejectQueryChars(mux) } // handleSourceDownload serves a source package, fetching and caching from upstream. diff --git a/internal/handler/debian.go b/internal/handler/debian.go index a1965479..94c423c2 100644 --- a/internal/handler/debian.go +++ b/internal/handler/debian.go @@ -83,7 +83,9 @@ func (h *DebianHandler) Routes() http.Handler { path := strings.TrimPrefix(r.URL.Path, "/") - if containsPathTraversal(path) { + // A decoded '?' or '#' would cut the upstream path short and cache one + // file under another file's name. + if containsPathTraversal(path) || strings.ContainsAny(path, "?#") { http.Error(w, "invalid path", http.StatusBadRequest) return } diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 060474cc..0abd777a 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -44,6 +44,21 @@ func containsPathTraversal(path string) bool { return false } +// rejectQueryChars refuses requests whose decoded path contains '?' or '#', +// for handlers that build upstream URLs from the decoded path: there either +// would cut the upstream path short and cache one file under another file's +// name. The server refuses these paths too; this keeps the handlers safe on +// their own. +func rejectQueryChars(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if strings.ContainsAny(r.URL.Path, "?#") { + http.Error(w, "invalid path", http.StatusBadRequest) + return + } + next.ServeHTTP(w, r) + }) +} + func hasDotDotSegment(path string) bool { path = strings.ReplaceAll(path, "\\", "/") for segment := range strings.SplitSeq(path, "/") { diff --git a/internal/handler/maven.go b/internal/handler/maven.go index 90c4ce5a..7bb035e9 100644 --- a/internal/handler/maven.go +++ b/internal/handler/maven.go @@ -103,7 +103,7 @@ func (h *MavenHandler) Routes() http.Handler { // e.g., /com/google/guava/guava/32.1.3-jre/guava-32.1.3-jre.jar mux.HandleFunc("GET /", h.handleRequest) - return mux + return rejectQueryChars(mux) } // handleRequest routes Maven requests based on the path. diff --git a/internal/handler/pypi.go b/internal/handler/pypi.go index e2ca4dbf..021150f6 100644 --- a/internal/handler/pypi.go +++ b/internal/handler/pypi.go @@ -84,7 +84,7 @@ func (h *PyPIHandler) Routes() http.Handler { // Package downloads (cache these) mux.HandleFunc("GET /packages/{path...}", h.handleDownload) - return mux + return rejectQueryChars(mux) } // handleSimpleIndex serves the simple API index. diff --git a/internal/handler/query_in_path_test.go b/internal/handler/query_in_path_test.go new file mode 100644 index 00000000..d9f412f2 --- /dev/null +++ b/internal/handler/query_in_path_test.go @@ -0,0 +1,71 @@ +package handler + +import ( + "errors" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" +) + +// TestHandlersRejectQueryCharsInPath checks that handlers which build upstream +// URLs from the decoded path refuse an encoded '?' or '#' themselves, without +// relying on the server middleware, and never contact the upstream or fetch +// an artifact. +func TestHandlersRejectQueryCharsInPath(t *testing.T) { + var requests atomic.Int32 + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests.Add(1) + _, _ = w.Write([]byte("upstream")) + })) + defer upstream.Close() + + proxy, _, _, fetcher := setupTestProxy(t) + proxy.HTTPClient = upstream.Client() + fetcher.fetchErr = errors.New("artifact fetched") + + handlers := map[string]struct { + routes http.Handler + paths []string + }{ + "debian": {NewDebianHandler(proxy, testProxyURL, upstream.URL, nil).Routes(), []string{ + "/pool/main/h/hello/hello_2.10-3_amd64.deb%3F/foo_9.9_amd64.deb", + "/dists/trixie/InRelease%23x", + }}, + "rpm": {NewRPMHandlerWithUpstream(proxy, testProxyURL, upstream.URL).Routes(), []string{ + "/Packages/n/nginx-1.24.0-2.fc39.x86_64.rpm%3F/foo-9.9-1.fc39.x86_64.rpm", + "/repodata/repomd.xml%3Fx", + }}, + "apk": {NewAPKHandler(proxy, testProxyURL, map[string]string{"alpine": upstream.URL}).Routes(), []string{ + "/alpine/v3.20/main/x86_64/busybox-1.36.1-r29.apk%3F/foo-9.9-r0.apk", + "/alpine/v3.20/main/x86_64/APKINDEX.tar.gz%23x", + }}, + "pypi": {NewPyPIHandlerWithUpstreams(proxy, testProxyURL, upstream.URL, upstream.URL).Routes(), []string{ + "/packages/aa/bb/requests-2.31.0.tar.gz%3F/foo-9.9.tar.gz", + "/simple/requests%3Fx/", + }}, + "cran": {NewCRANHandlerWithUpstream(proxy, testProxyURL, upstream.URL).Routes(), []string{ + "/src/contrib/ggplot2_3.4.4.tar.gz%3Ffoo_9.9.tar.gz", + "/bin/macosx%23x/contrib/4.3/PACKAGES", + }}, + "maven": {NewMavenHandler(proxy, testProxyURL, upstream.URL, upstream.URL).Routes(), []string{ + "/com/example/lib/1.0/lib-1.0.jar%3F/com/example/foo/9.9/foo-9.9.jar", + "/com/example/lib/maven-metadata.xml%23x", + }}, + } + for name, tc := range handlers { + for _, target := range tc.paths { + w := httptest.NewRecorder() + tc.routes.ServeHTTP(w, httptest.NewRequest(http.MethodGet, target, nil)) + if w.Code != http.StatusBadRequest { + t.Errorf("%s: GET %s = %d, want %d", name, target, w.Code, http.StatusBadRequest) + } + } + } + if got := requests.Load(); got != 0 { + t.Errorf("upstream requests = %d, want 0", got) + } + if fetcher.fetchCalled { + t.Errorf("artifact fetched from %s", fetcher.fetchedURL) + } +} diff --git a/internal/handler/rpm.go b/internal/handler/rpm.go index 8a3a7629..3794692a 100644 --- a/internal/handler/rpm.go +++ b/internal/handler/rpm.go @@ -69,7 +69,9 @@ func (h *RPMHandler) Routes() http.Handler { path := strings.TrimPrefix(r.URL.Path, "/") - if containsPathTraversal(path) { + // A decoded '?' or '#' would cut the upstream path short and cache one + // file under another file's name. + if containsPathTraversal(path) || strings.ContainsAny(path, "?#") { http.Error(w, "invalid path", http.StatusBadRequest) return } From fcac1d5faee8eb53d31dad3acda93882ad127703 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Sun, 25 Oct 2026 09:55:14 +0000 Subject: [PATCH 3/3] Refuse '?' and '#' through rejectQueryChars in APK, Debian and RPM --- internal/handler/apk.go | 8 +++----- internal/handler/cache_key.go | 3 +-- internal/handler/debian.go | 8 +++----- internal/handler/handler.go | 7 ++----- internal/handler/rpm.go | 8 +++----- 5 files changed, 12 insertions(+), 22 deletions(-) diff --git a/internal/handler/apk.go b/internal/handler/apk.go index f915c306..9024fe0e 100644 --- a/internal/handler/apk.go +++ b/internal/handler/apk.go @@ -74,7 +74,7 @@ func NewAPKHandler(proxy *Proxy, proxyURL string, repositories map[string]string // Routes returns the HTTP handler for APK requests. // Mount this at /apk on your router. func (h *APKHandler) Routes() http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + return rejectQueryChars(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodHead { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return @@ -82,9 +82,7 @@ func (h *APKHandler) Routes() http.Handler { path := strings.TrimPrefix(r.URL.Path, "/") - // A decoded '?' or '#' would cut the upstream path short and cache one - // file under another file's name. - if containsPathTraversal(path) || strings.ContainsAny(path, "?#") { + if containsPathTraversal(path) { http.Error(w, "invalid path", http.StatusBadRequest) return } @@ -109,7 +107,7 @@ func (h *APKHandler) Routes() http.Handler { // Other files - proxy directly. h.proxyFile(w, r, upstreamURL, rest) } - }) + })) } // isAPKIndex reports whether the path names a repository index: diff --git a/internal/handler/cache_key.go b/internal/handler/cache_key.go index 684c5d76..6c2e8a56 100644 --- a/internal/handler/cache_key.go +++ b/internal/handler/cache_key.go @@ -9,8 +9,7 @@ import ( // hashedCacheKey derives a metadata cache key from parts such as the // upstream URL, the request path and the query. Each part is length-prefixed // before hashing, so no two different part lists share a key, whatever bytes -// the parts contain. Keys built by replacing '/' with '_' were ambiguous: -// foo/bar_1.0 and foo_bar/1.0 mapped to the same entry. +// the parts contain. func hashedCacheKey(parts ...string) string { h := sha256.New() for _, part := range parts { diff --git a/internal/handler/debian.go b/internal/handler/debian.go index 94c423c2..b2419f62 100644 --- a/internal/handler/debian.go +++ b/internal/handler/debian.go @@ -75,7 +75,7 @@ func NewDebianHandler( // Routes returns the HTTP handler for Debian requests. // Mount this at /debian on your router. func (h *DebianHandler) Routes() http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + return rejectQueryChars(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodHead { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return @@ -83,9 +83,7 @@ func (h *DebianHandler) Routes() http.Handler { path := strings.TrimPrefix(r.URL.Path, "/") - // A decoded '?' or '#' would cut the upstream path short and cache one - // file under another file's name. - if containsPathTraversal(path) || strings.ContainsAny(path, "?#") { + if containsPathTraversal(path) { http.Error(w, "invalid path", http.StatusBadRequest) return } @@ -122,7 +120,7 @@ func (h *DebianHandler) Routes() http.Handler { // Other files (like README, etc.) - proxy directly h.proxyFile(w, r, upstreamURL, rest) } - }) + })) } // repositoryNotFound answers a request addressed to a repository that is not diff --git a/internal/handler/handler.go b/internal/handler/handler.go index 0abd777a..c06ec2d4 100644 --- a/internal/handler/handler.go +++ b/internal/handler/handler.go @@ -44,11 +44,8 @@ func containsPathTraversal(path string) bool { return false } -// rejectQueryChars refuses requests whose decoded path contains '?' or '#', -// for handlers that build upstream URLs from the decoded path: there either -// would cut the upstream path short and cache one file under another file's -// name. The server refuses these paths too; this keeps the handlers safe on -// their own. +// rejectQueryChars refuses a decoded path containing '?' or '#', which would cut +// the upstream URL short and cache one file under another's name. func rejectQueryChars(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if strings.ContainsAny(r.URL.Path, "?#") { diff --git a/internal/handler/rpm.go b/internal/handler/rpm.go index 3794692a..aaac6f58 100644 --- a/internal/handler/rpm.go +++ b/internal/handler/rpm.go @@ -61,7 +61,7 @@ func NewRPMHandlerWithUpstream(proxy *Proxy, proxyURL, upstreamURL string) *RPMH // Routes returns the HTTP handler for RPM requests. // Mount this at /rpm on your router. func (h *RPMHandler) Routes() http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + return rejectQueryChars(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodGet && r.Method != http.MethodHead { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return @@ -69,9 +69,7 @@ func (h *RPMHandler) Routes() http.Handler { path := strings.TrimPrefix(r.URL.Path, "/") - // A decoded '?' or '#' would cut the upstream path short and cache one - // file under another file's name. - if containsPathTraversal(path) || strings.ContainsAny(path, "?#") { + if containsPathTraversal(path) { http.Error(w, "invalid path", http.StatusBadRequest) return } @@ -88,7 +86,7 @@ func (h *RPMHandler) Routes() http.Handler { // Other files - proxy directly h.proxyFile(w, r, path) } - }) + })) } // handlePackageDownload fetches and caches .rpm packages.