From 787b76dfe7d503e19de0dc0cfd9736ab471386ac Mon Sep 17 00:00:00 2001 From: Philipp Dieter Date: Tue, 30 Jun 2026 13:25:49 +0200 Subject: [PATCH 01/12] Fix Composer 404 on minified metadata and add debug logging - Expand minified Composer v2 metadata in findDownloadURLFromMetadata so versions that inherit dist from a previous entry resolve correctly - Add Debug()-level log statements in the download resolution path (handleDownload, findDownloadURLFromMetadata) so -log-level debug produces meaningful output --- internal/handler/composer.go | 39 +++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/internal/handler/composer.go b/internal/handler/composer.go index e4dec41..bc3bc1d 100644 --- a/internal/handler/composer.go +++ b/internal/handler/composer.go @@ -316,6 +316,10 @@ func (h *ComposerHandler) handleDownload(w http.ResponseWriter, r *http.Request) // stable and dev versions resolve correctly. metaURLs := h.metadataURLsForVersion(vendor, pkg, version) + h.proxy.Logger.Debug("resolving download URL", + "package", packageName, "version", version, + "metadata_urls", metaURLs) + var downloadURL string for _, metaURL := range metaURLs { url, err := h.findDownloadURLFromMetadata(r.Context(), metaURL, packageName, version) @@ -331,10 +335,17 @@ func (h *ComposerHandler) handleDownload(w http.ResponseWriter, r *http.Request) } if downloadURL == "" { + h.proxy.Logger.Debug("version not found in any metadata source", + "package", packageName, "version", version, + "tried_urls", metaURLs) http.Error(w, "version not found", http.StatusNotFound) return } + h.proxy.Logger.Debug("resolved download URL", + "package", packageName, "version", version, + "download_url", downloadURL) + result, err := h.proxy.GetOrFetchArtifactFromURL(r.Context(), "composer", packageName, version, filename, downloadURL) if err != nil { h.proxy.Logger.Error("failed to get artifact", "error", err) @@ -372,6 +383,9 @@ func (h *ComposerHandler) metadataURLsForVersion(vendor, pkg, version string) [] // An error is returned only on transport failure; a missing document (non-200) // or a missing version both yield an empty string so the caller can fall back. func (h *ComposerHandler) findDownloadURLFromMetadata(ctx context.Context, metaURL, packageName, version string) (string, error) { + h.proxy.Logger.Debug("fetching upstream metadata for download lookup", + "url", metaURL, "package", packageName, "version", version) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, metaURL, nil) if err != nil { return "", err @@ -383,6 +397,9 @@ func (h *ComposerHandler) findDownloadURLFromMetadata(ctx context.Context, metaU } defer func() { _ = resp.Body.Close() }() + h.proxy.Logger.Debug("upstream metadata response", + "url", metaURL, "status", resp.StatusCode) + if resp.StatusCode != http.StatusOK { return "", nil } @@ -392,7 +409,27 @@ func (h *ComposerHandler) findDownloadURLFromMetadata(ctx context.Context, metaU return "", err } - return h.findDownloadURL(metadata, packageName, version), nil + // Expand minified Composer v2 format so that inherited fields (including + // dist) are present on every version entry. Without this, versions that + // inherit dist from a previous entry will appear to have no download URL. + if metadata["minified"] == "composer/2.0" { + h.proxy.Logger.Debug("expanding minified metadata", "url", metaURL) + if packages, ok := metadata["packages"].(map[string]any); ok { + for pkgName, versions := range packages { + versionList, ok := versions.([]any) + if !ok { + continue + } + packages[pkgName] = expandMinifiedVersions(versionList) + } + } + } + + url := h.findDownloadURL(metadata, packageName, version) + h.proxy.Logger.Debug("download URL lookup result", + "url", metaURL, "package", packageName, "version", version, + "download_url", url) + return url, nil } // findDownloadURL finds the dist URL for a specific version in metadata. From 81d19b7632bbc0cbb5a24cdc29f1ad6534cc1e1e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:13:19 +0000 Subject: [PATCH 02/12] Bump zizmorcore/zizmor-action from 0.5.6 to 0.5.7 Bumps [zizmorcore/zizmor-action](https://github.com/zizmorcore/zizmor-action) from 0.5.6 to 0.5.7. - [Release notes](https://github.com/zizmorcore/zizmor-action/releases) - [Commits](https://github.com/zizmorcore/zizmor-action/compare/5f14fd08f7cf1cb1609c1e344975f152c7ee938d...192e21d79ab29983730a13d1382995c2307fbcaa) --- updated-dependencies: - dependency-name: zizmorcore/zizmor-action dependency-version: 0.5.7 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/zizmor.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/zizmor.yml b/.github/workflows/zizmor.yml index b404599..cce6e4f 100644 --- a/.github/workflows/zizmor.yml +++ b/.github/workflows/zizmor.yml @@ -26,4 +26,4 @@ jobs: persist-credentials: false - name: Run zizmor - uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6 + uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7 From a8c0562c97fe20619e75b5d0295889f36178055b Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 2 Jul 2026 15:13:48 +0000 Subject: [PATCH 03/12] Bump actions/setup-go from 6.4.0 to 6.5.0 Bumps [actions/setup-go](https://github.com/actions/setup-go) from 6.4.0 to 6.5.0. - [Release notes](https://github.com/actions/setup-go/releases) - [Commits](https://github.com/actions/setup-go/compare/4a3601121dd01d1626a1e23e37211e3254c1c06c...924ae3a1cded613372ab5595356fb5720e22ba16) --- updated-dependencies: - dependency-name: actions/setup-go dependency-version: 6.5.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/ci.yml | 4 ++-- .github/workflows/release.yml | 2 +- .github/workflows/swagger.yml | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c1a2abb..700f28e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -22,7 +22,7 @@ jobs: persist-credentials: false - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version: ${{ matrix.go-version }} @@ -40,7 +40,7 @@ jobs: persist-credentials: false - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version: '1.25' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index a7d8ae5..f7833fb 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,7 +22,7 @@ jobs: - uses: sigstore/cosign-installer@6f9f17788090df1f26f669e9d70d6ae9567deba6 # v4.1.2 - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version-file: go.mod cache: false diff --git a/.github/workflows/swagger.yml b/.github/workflows/swagger.yml index b6e086a..f947c7e 100644 --- a/.github/workflows/swagger.yml +++ b/.github/workflows/swagger.yml @@ -17,7 +17,7 @@ jobs: persist-credentials: false - name: Set up Go - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6.4.0 + uses: actions/setup-go@924ae3a1cded613372ab5595356fb5720e22ba16 # v6.5.0 with: go-version: '1.25' From 0ae63066e22c3991cecb4f58954a2f89f07c36fa Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Tue, 7 Jul 2026 17:48:36 +0100 Subject: [PATCH 04/12] Make upstream HTTP timeout configurable Add http_timeout config option (and PROXY_HTTP_TIMEOUT env var) to set the per-request timeout on the shared HTTP client used by protocol handlers for upstream metadata fetches and pass-through file requests. Defaults to the previous hardcoded 30s; "0" disables the timeout. Fixes #187 --- config.example.yaml | 5 +++ docs/configuration.md | 12 +++++++ internal/config/config.go | 44 ++++++++++++++++++++++++ internal/config/config_test.go | 63 ++++++++++++++++++++++++++++++++++ internal/server/server.go | 1 + 5 files changed, 125 insertions(+) diff --git a/config.example.yaml b/config.example.yaml index 62b4105..d1ed9a1 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -9,6 +9,11 @@ listen: ":8080" # so users know what to point their package manager at. base_url: "http://localhost:8080" +# Timeout for individual upstream HTTP requests made by protocol handlers +# (metadata fetches, pass-through file requests). Uses Go duration syntax. +# Set to "0" to disable the timeout. Default: "30s". +# http_timeout: "30s" + # Public URL where the web UI is reached. Defaults to base_url when unset. # Set this separately when the UI is served on a different hostname than the # package endpoints — for example, the UI on a public domain behind auth while diff --git a/docs/configuration.md b/docs/configuration.md index f220279..dcdec24 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -276,6 +276,18 @@ metadata_max_size: "100MB" # default Or via environment variable: `PROXY_METADATA_MAX_SIZE=250MB`. +## Upstream HTTP timeout + +Protocol handlers use a shared HTTP client for upstream requests such as metadata fetches and pass-through file downloads. `http_timeout` sets that client's per-request timeout. Raise it if slow upstreams or large metadata responses cause `context deadline exceeded` errors. + +```yaml +http_timeout: "30s" # default +``` + +Or via environment variable: `PROXY_HTTP_TIMEOUT=2m`. + +Set to `"0"` to disable the timeout entirely (requests then rely only on the server's write timeout). + ## Mirror API The `/api/mirror` endpoints are disabled by default. Enable them to allow starting mirror jobs via HTTP: diff --git a/internal/config/config.go b/internal/config/config.go index 16928dc..2d275c7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -110,6 +110,12 @@ type Config struct { // size return ErrMetadataTooLarge. Default: "100MB". MetadataMaxSize string `json:"metadata_max_size" yaml:"metadata_max_size"` + // HTTPTimeout is the timeout for individual upstream HTTP requests made + // by protocol handlers (metadata fetches, pass-through file requests). + // Uses Go duration syntax (e.g. "30s", "2m"). Default: "30s". + // Set to "0" to disable the timeout entirely. + HTTPTimeout string `json:"http_timeout" yaml:"http_timeout"` + // MirrorAPI enables the /api/mirror endpoints for starting mirror jobs via HTTP. // Disabled by default to prevent unauthenticated users from triggering downloads. MirrorAPI bool `json:"mirror_api" yaml:"mirror_api"` @@ -460,6 +466,9 @@ func (c *Config) LoadFromEnv() { if v := os.Getenv("PROXY_METADATA_MAX_SIZE"); v != "" { c.MetadataMaxSize = v } + if v := os.Getenv("PROXY_HTTP_TIMEOUT"); v != "" { + c.HTTPTimeout = v + } if v := os.Getenv("PROXY_GRADLE_BUILD_CACHE_READ_ONLY"); v != "" { c.Gradle.BuildCache.ReadOnly = v == "true" || v == "1" } @@ -567,6 +576,10 @@ func (c *Config) Validate() error { return err } + if err := validateHTTPTimeout(c.HTTPTimeout); err != nil { + return err + } + if err := c.Health.Validate(); err != nil { return err } @@ -636,6 +649,7 @@ func (g *GradleBuildCacheConfig) Validate() error { const ( defaultMetadataTTL = 5 * time.Minute //nolint:mnd // sensible default defaultDirectServeTTL = 15 * time.Minute //nolint:mnd // sensible default + defaultHTTPTimeout = 30 * time.Second //nolint:mnd // sensible default defaultMetadataMaxSize = 100 << 20 defaultGradleBuildCacheMaxUploadSize = 100 << 20 defaultGradleBuildCacheSweepInterval = 10 * time.Minute @@ -656,6 +670,20 @@ func (c *Config) ParseMaxSize() int64 { return size } +func validateHTTPTimeout(s string) error { + if s == "" || s == "0" { + return nil + } + d, err := time.ParseDuration(s) + if err != nil { + return fmt.Errorf("invalid http_timeout %q: %w", s, err) + } + if d < 0 { + return fmt.Errorf("invalid http_timeout %q: must be non-negative", s) + } + return nil +} + func validateMetadataMaxSize(s string) error { if s == "" { return nil @@ -683,6 +711,22 @@ func (c *Config) ParseMetadataMaxSize() int64 { return size } +// ParseHTTPTimeout returns the upstream HTTP client timeout. +// Returns 30s if unset, 0 (no timeout) if explicitly set to "0". +func (c *Config) ParseHTTPTimeout() time.Duration { + if c.HTTPTimeout == "" { + return defaultHTTPTimeout + } + if c.HTTPTimeout == "0" { + return 0 + } + d, err := time.ParseDuration(c.HTTPTimeout) + if err != nil || d < 0 { + return defaultHTTPTimeout + } + return d +} + // ParseMetadataTTL returns the metadata TTL duration. // Returns 5 minutes if unset, 0 if explicitly disabled. func (c *Config) ParseMetadataTTL() time.Duration { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index bb3ec74..9c34023 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -524,6 +524,69 @@ func TestValidateHealthStorageProbeInterval(t *testing.T) { } } +func TestParseHTTPTimeout(t *testing.T) { + tests := []struct { + name string + timeout string + want time.Duration + }{ + {"empty defaults to 30s", "", 30 * time.Second}, + {"explicit zero disables", "0", 0}, + {"2 minutes", "2m", 2 * time.Minute}, + {"90 seconds", "90s", 90 * time.Second}, + {"invalid defaults to 30s", "not-a-duration", 30 * time.Second}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := Default() + cfg.HTTPTimeout = tt.timeout + got := cfg.ParseHTTPTimeout() + if got != tt.want { + t.Errorf("ParseHTTPTimeout() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestValidateHTTPTimeout(t *testing.T) { + cfg := Default() + cfg.HTTPTimeout = "not-a-duration" + if err := cfg.Validate(); err == nil { + t.Error("expected validation error for invalid http_timeout") + } + + cfg.HTTPTimeout = "-5s" + if err := cfg.Validate(); err == nil { + t.Error("expected validation error for negative http_timeout") + } + + cfg.HTTPTimeout = "2m" + if err := cfg.Validate(); err != nil { + t.Errorf("unexpected error for valid http_timeout: %v", err) + } + + cfg.HTTPTimeout = "0" + if err := cfg.Validate(); err != nil { + t.Errorf("unexpected error for zero http_timeout: %v", err) + } + + cfg.HTTPTimeout = "" + if err := cfg.Validate(); err != nil { + t.Errorf("unexpected error for empty http_timeout: %v", err) + } +} + +func TestLoadHTTPTimeoutFromEnv(t *testing.T) { + cfg := Default() + t.Setenv("PROXY_HTTP_TIMEOUT", "90s") + cfg.LoadFromEnv() + + if cfg.HTTPTimeout != "90s" { + t.Errorf("HTTPTimeout = %q, want %q", cfg.HTTPTimeout, "90s") + } +} + func TestLoadMetadataTTLFromEnv(t *testing.T) { cfg := Default() t.Setenv("PROXY_METADATA_TTL", "10m") diff --git a/internal/server/server.go b/internal/server/server.go index 0a46a37..5aac4db 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -166,6 +166,7 @@ func (s *Server) Start() error { Packages: s.cfg.Cooldown.Packages, } proxy := handler.NewProxy(s.db, s.storage, fetcher, resolver, s.logger) + proxy.HTTPClient.Timeout = s.cfg.ParseHTTPTimeout() proxy.Cooldown = cd proxy.CacheMetadata = s.cfg.CacheMetadata proxy.MetadataTTL = s.cfg.ParseMetadataTTL() From 1a18dd4635f3eb6289992ef54783579a4b51199c Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Tue, 7 Jul 2026 17:53:35 +0100 Subject: [PATCH 05/12] Bump golang.org/x/net to v0.56.0 Clears GHSA-5cv4-jp36-h3mw / CVE-2026-25680 (HTML parser DoS, only reachable via golangci-lint tooling deps) and GO-2026-5026 (idna Punycode validation, reachable via http.Client.Do). Also pulls x/crypto, x/sys and x/text forward as required by x/net. --- go.mod | 8 ++++---- go.sum | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/go.mod b/go.mod index 471ab53..9576fda 100644 --- a/go.mod +++ b/go.mod @@ -291,14 +291,14 @@ require ( go.uber.org/zap v1.27.1 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect - golang.org/x/crypto v0.51.0 // indirect + golang.org/x/crypto v0.53.0 // indirect golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa // indirect golang.org/x/exp/typeparams v0.0.0-20260209203927-2842357ff358 // indirect golang.org/x/mod v0.36.0 // indirect - golang.org/x/net v0.54.0 // indirect + golang.org/x/net v0.56.0 // indirect golang.org/x/oauth2 v0.36.0 // indirect - golang.org/x/sys v0.45.0 // indirect - golang.org/x/text v0.37.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect golang.org/x/tools v0.45.0 // indirect golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect google.golang.org/api v0.272.0 // indirect diff --git a/go.sum b/go.sum index 8d8c96d..93bed13 100644 --- a/go.sum +++ b/go.sum @@ -738,8 +738,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= -golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= -golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa h1:Zt3DZoOFFYkKhDT3v7Lm9FDMEV06GpzjG2jrqW+QTE0= golang.org/x/exp v0.0.0-20260218203240-3dfff04db8fa/go.mod h1:K79w1Vqn7PoiZn+TkNpx3BUWUQksGO3JcVX6qIjytmA= golang.org/x/exp/typeparams v0.0.0-20220428152302-39d4317da171/go.mod h1:AbB0pIl9nAr9wVwH+Z2ZpaocVmF5I4GyWCDIsVjR0bk= @@ -771,8 +771,8 @@ golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= golang.org/x/net v0.16.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= -golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= -golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -807,8 +807,8 @@ golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= -golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= @@ -823,8 +823,8 @@ golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= -golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= -golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= From df3ad9184821b60efcbb5198d7b65b6dd2949d1f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:13:56 +0000 Subject: [PATCH 06/12] Bump docker/build-push-action from 7.2.0 to 7.3.0 Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 7.2.0 to 7.3.0. - [Release notes](https://github.com/docker/build-push-action/releases) - [Commits](https://github.com/docker/build-push-action/compare/f9f3042f7e2789586610d6e8b85c8f03e5195baf...53b7df96c91f9c12dcc8a07bcb9ccacbed38856a) --- updated-dependencies: - dependency-name: docker/build-push-action dependency-version: 7.3.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 93b0a1e..a4e105d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -38,7 +38,7 @@ jobs: images: ghcr.io/${{ github.repository }} - name: Build and push Docker image - uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a with: context: . push: true From f7b5dd2028e80f420cdf1284ccb74f3b5ce4f6b4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:14:04 +0000 Subject: [PATCH 07/12] Bump golang.org/x/sync from 0.21.0 to 0.22.0 Bumps [golang.org/x/sync](https://github.com/golang/sync) from 0.21.0 to 0.22.0. - [Commits](https://github.com/golang/sync/compare/v0.21.0...v0.22.0) --- updated-dependencies: - dependency-name: golang.org/x/sync dependency-version: 0.22.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 9576fda..b063dba 100644 --- a/go.mod +++ b/go.mod @@ -21,7 +21,7 @@ require ( github.com/spdx/tools-golang v0.5.7 github.com/swaggo/swag v1.16.6 gocloud.dev v0.46.0 - golang.org/x/sync v0.21.0 + golang.org/x/sync v0.22.0 google.golang.org/protobuf v1.36.11 gopkg.in/yaml.v3 v3.0.1 modernc.org/sqlite v1.53.0 diff --git a/go.sum b/go.sum index 93bed13..b1d35f6 100644 --- a/go.sum +++ b/go.sum @@ -784,8 +784,8 @@ golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= -golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= -golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= From ea4eea4b113c41523a8a48f40158f0d87ba577a3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:14:07 +0000 Subject: [PATCH 08/12] Bump docker/login-action from 4.2.0 to 4.3.0 Bumps [docker/login-action](https://github.com/docker/login-action) from 4.2.0 to 4.3.0. - [Release notes](https://github.com/docker/login-action/releases) - [Commits](https://github.com/docker/login-action/compare/650006c6eb7dba73a995cc03b0b2d7f5ca915bee...c99871dec2022cc055c062a10cc1a1310835ceb4) --- updated-dependencies: - dependency-name: docker/login-action dependency-version: 4.3.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 93b0a1e..f9c32a5 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -25,7 +25,7 @@ jobs: persist-credentials: false - name: Log in to the Container registry - uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee + uses: docker/login-action@c99871dec2022cc055c062a10cc1a1310835ceb4 with: registry: ghcr.io username: ${{ github.actor }} From defe1ec96db676d7833dabb65e5540ebf539000f Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:14:16 +0000 Subject: [PATCH 09/12] Bump goreleaser/goreleaser-action from 7.2.2 to 7.2.3 Bumps [goreleaser/goreleaser-action](https://github.com/goreleaser/goreleaser-action) from 7.2.2 to 7.2.3. - [Release notes](https://github.com/goreleaser/goreleaser-action/releases) - [Commits](https://github.com/goreleaser/goreleaser-action/compare/5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89...f06c13b6b1a9625abc9e6e439d9c05a8f2190e94) --- updated-dependencies: - dependency-name: goreleaser/goreleaser-action dependency-version: 7.2.3 dependency-type: direct:production update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f7833fb..4d4d0b5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,7 +27,7 @@ jobs: go-version-file: go.mod cache: false - - uses: goreleaser/goreleaser-action@5daf1e915a5f0af01ddbcd89a43b8061ff4f1a89 # v7.2.2 + - uses: goreleaser/goreleaser-action@f06c13b6b1a9625abc9e6e439d9c05a8f2190e94 # v7.2.3 with: version: "~> v2" args: release --clean From 383d2089cf30e313d46f656c49c4fcddc573759e Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Thu, 9 Jul 2026 15:14:26 +0000 Subject: [PATCH 10/12] Bump docker/metadata-action from 6.1.0 to 6.2.0 Bumps [docker/metadata-action](https://github.com/docker/metadata-action) from 6.1.0 to 6.2.0. - [Release notes](https://github.com/docker/metadata-action/releases) - [Commits](https://github.com/docker/metadata-action/compare/80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9...dc802804100637a589fabce1cb79ff13a1411302) --- updated-dependencies: - dependency-name: docker/metadata-action dependency-version: 6.2.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/publish.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 93b0a1e..b2288be 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -33,7 +33,7 @@ jobs: - name: Extract metadata (tags, labels) for Docker id: meta - uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 with: images: ghcr.io/${{ github.repository }} From 41465968cbaf3e52cd2b552b9c7610000224a54d Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Thu, 9 Jul 2026 17:36:18 +0100 Subject: [PATCH 11/12] Validate filesystem storage paths --- internal/storage/filesystem.go | 30 +++++++++++--- internal/storage/filesystem_test.go | 62 +++++++++++++++++++++++++---- 2 files changed, 79 insertions(+), 13 deletions(-) diff --git a/internal/storage/filesystem.go b/internal/storage/filesystem.go index 1e5a24f..d509e9d 100644 --- a/internal/storage/filesystem.go +++ b/internal/storage/filesystem.go @@ -34,11 +34,31 @@ func NewFilesystem(root string) (*Filesystem, error) { } func (fs *Filesystem) fullPath(path string) (string, error) { - full := filepath.Clean(filepath.Join(fs.root, filepath.FromSlash(path))) - if full != fs.root && !strings.HasPrefix(full, fs.root+string(filepath.Separator)) { - return "", fmt.Errorf("%w: path escapes storage root", ErrNotFound) + localPath, err := cleanStoragePath(path) + if err != nil { + return "", err } - return full, nil + return filepath.Join(fs.root, localPath), nil +} + +func (fs *Filesystem) prefixPath(prefix string) (string, error) { + if prefix == "" { + return fs.root, nil + } + return fs.fullPath(prefix) +} + +func cleanStoragePath(path string) (string, error) { + if path == "." || strings.Contains(path, `\`) || !filepath.IsLocal(path) { + return "", fmt.Errorf("%w: invalid storage path", ErrNotFound) + } + + localPath, err := filepath.Localize(path) + if err != nil || localPath == "." || !filepath.IsLocal(localPath) { + return "", fmt.Errorf("%w: invalid storage path", ErrNotFound) + } + + return localPath, nil } func (fs *Filesystem) Store(ctx context.Context, path string, r io.Reader) (int64, string, error) { @@ -190,7 +210,7 @@ func (fs *Filesystem) UsedSpace(ctx context.Context) (int64, error) { // ListPrefix returns object metadata for paths under a prefix. func (fs *Filesystem) ListPrefix(ctx context.Context, prefix string) ([]ObjectInfo, error) { - searchRoot, err := fs.fullPath(prefix) + searchRoot, err := fs.prefixPath(prefix) if err != nil { return nil, err } diff --git a/internal/storage/filesystem_test.go b/internal/storage/filesystem_test.go index 332dfbf..10d073b 100644 --- a/internal/storage/filesystem_test.go +++ b/internal/storage/filesystem_test.go @@ -243,19 +243,65 @@ func TestFilesystemLargeFile(t *testing.T) { assertLargeFileRoundTrip(t, createTestFilesystem(t)) } -func TestFilesystemRejectsTraversal(t *testing.T) { +func TestFilesystemRejectsInvalidPaths(t *testing.T) { tmp := t.TempDir() fs, err := NewFilesystem(tmp) if err != nil { t.Fatal(err) } - for _, p := range []string{"../etc/passwd", "../../etc/passwd", "a/../../etc/passwd"} { - if _, err := fs.Open(context.Background(), p); err == nil { - t.Errorf("Open(%q) should reject traversal", p) - } - if _, _, err := fs.Store(context.Background(), p, strings.NewReader("x")); err == nil { - t.Errorf("Store(%q) should reject traversal", p) - } + for _, p := range []string{ + "", + ".", + "../etc/passwd", + "../../etc/passwd", + "a/../../etc/passwd", + "/etc/passwd", + "test//file.txt", + "test/./file.txt", + "test/../file.txt", + `test\..\file.txt`, + } { + t.Run(p, func(t *testing.T) { + ctx := context.Background() + + if _, err := fs.FullPath(p); !errors.Is(err, ErrNotFound) { + t.Errorf("FullPath(%q) = %v, want ErrNotFound", p, err) + } + if _, err := fs.Open(ctx, p); err == nil { + t.Errorf("Open(%q) should reject invalid path", p) + } + if _, _, err := fs.Store(ctx, p, strings.NewReader("x")); err == nil { + t.Errorf("Store(%q) should reject invalid path", p) + } + if _, err := fs.Exists(ctx, p); err == nil { + t.Errorf("Exists(%q) should reject invalid path", p) + } + if err := fs.Delete(ctx, p); err == nil { + t.Errorf("Delete(%q) should reject invalid path", p) + } + if _, err := fs.Size(ctx, p); err == nil { + t.Errorf("Size(%q) should reject invalid path", p) + } + if _, err := fs.ListPrefix(ctx, p); p != "" && err == nil { + t.Errorf("ListPrefix(%q) should reject invalid path", p) + } + }) + } +} + +func TestFilesystemListPrefixAllowsEmptyPrefix(t *testing.T) { + fs := createTestFilesystem(t) + ctx := context.Background() + + _, _, _ = fs.Store(ctx, "a.txt", strings.NewReader("aaaa")) + _, _, _ = fs.Store(ctx, "c/d.txt", strings.NewReader("ccccc")) + + objects, err := fs.ListPrefix(ctx, "") + if err != nil { + t.Fatalf("ListPrefix empty prefix failed: %v", err) + } + if len(objects) != 2 { + t.Fatalf("ListPrefix empty prefix returned %d objects, want 2", len(objects)) } } From 98150ea576a32792e27fbf2adf2bbe596fff7573 Mon Sep 17 00:00:00 2001 From: Andrew Nesbitt Date: Thu, 9 Jul 2026 17:41:05 +0100 Subject: [PATCH 12/12] Name empty path validation subtest --- internal/storage/filesystem_test.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/internal/storage/filesystem_test.go b/internal/storage/filesystem_test.go index 10d073b..de0e418 100644 --- a/internal/storage/filesystem_test.go +++ b/internal/storage/filesystem_test.go @@ -261,7 +261,12 @@ func TestFilesystemRejectsInvalidPaths(t *testing.T) { "test/../file.txt", `test\..\file.txt`, } { - t.Run(p, func(t *testing.T) { + name := p + if name == "" { + name = "empty" + } + + t.Run(name, func(t *testing.T) { ctx := context.Background() if _, err := fs.FullPath(p); !errors.Is(err, ErrNotFound) {