diff --git a/docs/nuget/remote.md b/docs/nuget/remote.md index ba0109a..7e6ed99 100644 --- a/docs/nuget/remote.md +++ b/docs/nuget/remote.md @@ -4,4 +4,4 @@ Shared classification, TTL and eviction rules: [caching](../common/caching.md). | Type | Mutable (auto-detected) | Immutable (auto-detected) | |---|---|---| -| `nuget` | `index.json` (service index), registration and flat container indexes | `.nupkg`, `.nuspec` | +| `nuget` | `index.json` (service index), registration and flat container indexes | `.nupkg`, `.snupkg`, `.nuspec`, `readme`, `icon` | diff --git a/e2e/head_test.go b/e2e/head_test.go index 9c43be3..e222551 100644 --- a/e2e/head_test.go +++ b/e2e/head_test.go @@ -40,8 +40,10 @@ func assertHeadMatchesGet(t *testing.T, url string) { if len(hb) != 0 { t.Errorf("HEAD returned a %d-byte body", len(hb)) } - if head.Header.Get("Content-Type") != get.Header.Get("Content-Type") { - t.Errorf("HEAD Content-Type %q, GET %q", head.Header.Get("Content-Type"), get.Header.Get("Content-Type")) + for _, h := range []string{"Content-Type", "Cache-Control", "Vary"} { + if head.Header.Get(h) != get.Header.Get(h) { + t.Errorf("HEAD %s %q, GET %q", h, head.Header.Get(h), get.Header.Get(h)) + } } if head.ContentLength != int64(len(body)) { t.Errorf("HEAD Content-Length %d, GET body %d bytes", head.ContentLength, len(body)) @@ -61,7 +63,19 @@ func TestProxyHeadNuGetRewritten(t *testing.T) { createRemote(t, mustFormat(`{"name": "head-nuget", "package_type": "nuget", "base_url": %q}`, upstream.URL+"/v3/index.json")) defer deleteRemote(t, "head-nuget") - assertHeadMatchesGet(t, apiURL("/api/v1/remote/head-nuget/index.json")) + url := apiURL("/api/v1/remote/head-nuget/index.json") + assertHeadMatchesGet(t, url) + + req, _ := http.NewRequest(http.MethodHead, url, nil) + req.Host = "evil.example:" + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusBadRequest { + t.Errorf("HEAD with invalid Host: got %d, want 400", resp.StatusCode) + } } func TestProxyHeadGitHubRPMRepodata(t *testing.T) { diff --git a/e2e/scheme_test.go b/e2e/scheme_test.go new file mode 100644 index 0000000..4ab636f --- /dev/null +++ b/e2e/scheme_test.go @@ -0,0 +1,59 @@ +//go:build e2e + +package e2e + +import ( + "encoding/json" + "net/http" + "strings" + "testing" +) + +func getForwardedHTTPS(t *testing.T, url string) *http.Response { + t.Helper() + req, _ := http.NewRequest(http.MethodGet, url, nil) + req.Header.Set("X-Forwarded-Proto", "https") + client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + resp, err := client.Do(req) + if err != nil { + t.Fatalf("GET %s: %v", url, err) + } + return resp +} + +func TestForwardedProtoCargoConfig(t *testing.T) { + createRemote(t, `{"name": "scheme-cargo", "package_type": "cargo", "base_url": "https://index.crates.io"}`) + defer deleteRemote(t, "scheme-cargo") + + resp := getForwardedHTTPS(t, apiURL("/api/v1/remote/scheme-cargo/config.json")) + defer resp.Body.Close() + var cfg map[string]string + if err := json.NewDecoder(resp.Body).Decode(&cfg); err != nil { + t.Fatal(err) + } + if want := "https://" + strings.TrimPrefix(baseURL, "http://") + "/api/v1/remote/scheme-cargo/"; !strings.HasPrefix(cfg["dl"], want) { + t.Errorf("dl = %q, want prefix %q", cfg["dl"], want) + } +} + +func TestForwardedProtoVirtualRedirect(t *testing.T) { + createRemote(t, `{"name": "scheme-rpm", "package_type": "rpm", "base_url": "https://rpm.example.com"}`) + defer deleteRemote(t, "scheme-rpm") + resp, err := http.Post(apiURL("/api/v2/virtuals"), "application/json", + strings.NewReader(`{"name": "scheme-virt", "package_type": "rpm", "members": ["scheme-rpm"]}`)) + if err != nil { + t.Fatal(err) + } + resp.Body.Close() + if resp.StatusCode != http.StatusCreated { + t.Fatalf("create virtual: status %d", resp.StatusCode) + } + defer deleteRequest(t, apiURL("/api/v2/virtuals/scheme-virt")) + + resp = getForwardedHTTPS(t, apiURL("/api/v1/virtual/scheme-virt/scheme-rpm/Packages/a.rpm")) + resp.Body.Close() + loc := resp.Header.Get("Location") + if want := "https://" + strings.TrimPrefix(baseURL, "http://") + "/api/v1/remote/scheme-rpm/Packages/a.rpm"; resp.StatusCode != http.StatusFound || loc != want { + t.Errorf("redirect = %d %q, want 302 %q", resp.StatusCode, loc, want) + } +} diff --git a/internal/api/terraform/registry.go b/internal/api/terraform/registry.go index 7aee9de..cd69aca 100644 --- a/internal/api/terraform/registry.go +++ b/internal/api/terraform/registry.go @@ -13,6 +13,7 @@ import ( "github.com/go-chi/chi/v5" + v1 "git.unkin.net/unkin/artifactapi/internal/api/v1" "git.unkin.net/unkin/artifactapi/internal/database" tfprov "git.unkin.net/unkin/artifactapi/internal/provider/terraform" "git.unkin.net/unkin/artifactapi/internal/tfsign" @@ -290,12 +291,5 @@ func writeJSON(w http.ResponseWriter, v any) { } func baseURL(r *http.Request) string { - scheme := "http" - if r.TLS != nil { - scheme = "https" - } - if fwd := r.Header.Get("X-Forwarded-Proto"); fwd != "" { - scheme = fwd - } - return scheme + "://" + r.Host + return v1.Scheme(r) + "://" + r.Host } diff --git a/internal/api/v1/npm.go b/internal/api/v1/npm.go index 18604cf..e5caf25 100644 --- a/internal/api/v1/npm.go +++ b/internal/api/v1/npm.go @@ -254,7 +254,7 @@ func (h *ProxyHandler) serveNPMPackument(w http.ResponseWriter, r *http.Request, return } read := func(hash string) ([]byte, error) { return h.readLocalBlob(r.Context(), hash) } - tarballBase := fmt.Sprintf("%s://%s/api/v1/local/%s/", scheme(r), r.Host, remote.Name) + tarballBase := fmt.Sprintf("%s://%s/api/v1/local/%s/", Scheme(r), r.Host, remote.Name) doc, err := buildNPMPackument(pkg, files, read, tarballBase) if err != nil { npmError(w, http.StatusInternalServerError, err.Error()) diff --git a/internal/api/v1/proxy.go b/internal/api/v1/proxy.go index c87b9c5..a53e8e6 100644 --- a/internal/api/v1/proxy.go +++ b/internal/api/v1/proxy.go @@ -6,7 +6,10 @@ import ( "fmt" "io" "log/slog" + "net" "net/http" + "strconv" + "strings" "github.com/go-chi/chi/v5" @@ -44,7 +47,6 @@ func (h *ProxyHandler) Routes() chi.Router { r.Get("/remote/{remoteName}/*", h.handleProxy) r.Head("/remote/{remoteName}/*", h.handleProxyHead) r.Get("/local/{localName}/*", h.handleLocal) - r.Put("/local/{localName}/*", h.handleLocalPut) r.Get("/virtual/{virtualName}/*", h.handleVirtual) return r } @@ -92,7 +94,7 @@ func (h *ProxyHandler) remoteFor(w http.ResponseWriter, r *http.Request) (*model // responses and redirect package downloads instead of proxying bytes. func (h *ProxyHandler) serveRemote(w http.ResponseWriter, r *http.Request, remote *models.Remote, prov provider.Provider, path string) bool { rs, ok := prov.(provider.RemoteServer) - return ok && rs.ServeRemote(w, r, *remote, path, fmt.Sprintf("%s://%s", scheme(r), r.Host), h.db) + return ok && rs.ServeRemote(w, r, *remote, path, fmt.Sprintf("%s://%s", Scheme(r), r.Host), h.db) } func (h *ProxyHandler) handleProxy(w http.ResponseWriter, r *http.Request) { @@ -123,12 +125,10 @@ func (h *ProxyHandler) serveFetch(w http.ResponseWriter, r *http.Request, remote var body io.Reader = result.Reader if sr, ok := prov.(provider.ServeRewriter); ok && prov.Classify(path) == provider.Mutable { - raw, err := io.ReadAll(result.Reader) - if err != nil { - http.Error(w, "bad gateway", http.StatusBadGateway) + raw, ok := rewriteServed(w, r, sr, *remote, result.Reader) + if !ok { return } - raw = sr.RewriteServed(raw, *remote, fmt.Sprintf("%s://%s", scheme(r), r.Host)) body, result.Size = bytes.NewReader(raw), int64(len(raw)) } @@ -195,7 +195,7 @@ func (h *ProxyHandler) handleVirtual(w http.ResponseWriter, r *http.Request) { return } - proxyBaseURL := fmt.Sprintf("%s://%s", scheme(r), r.Host) + proxyBaseURL := fmt.Sprintf("%s://%s", Scheme(r), r.Host) loc, ok, err := h.virtualEngine.MemberRedirect(r.Context(), *virt, path, r.URL.RawQuery, proxyBaseURL) if err != nil { @@ -234,13 +234,6 @@ func (h *ProxyHandler) handleLocal(w http.ResponseWriter, r *http.Request) { return } - if remote.PackageType == models.PackageNPM { - if pkg := npmPackageName(path); pkg != "" { - h.serveNPMPackument(w, r, remote, pkg) - return - } - } - prov, _ := provider.Get(remote.PackageType) if indexer, ok := prov.(provider.LocalIndexer); ok { if indexer.ServeLocalIndex(w, r, h.db, remote.Name, path) { @@ -279,12 +272,68 @@ func (h *ProxyHandler) serveLocalFile(w http.ResponseWriter, r *http.Request, re io.Copy(w, reader) } -func scheme(r *http.Request) string { +// Scheme returns the client-facing scheme: TLS, else the first +// X-Forwarded-Proto value when it is http or https, else http. +func Scheme(r *http.Request) string { if r.TLS != nil { return "https" } - if fwd := r.Header.Get("X-Forwarded-Proto"); fwd != "" { + fwd, _, _ := strings.Cut(r.Header.Get("X-Forwarded-Proto"), ",") + if fwd = strings.ToLower(strings.TrimSpace(fwd)); fwd == "https" { return fwd } return "http" } + +// rewriteServed rewrites a cached body for the requesting host and marks the +// response uncacheable by shared caches, since it varies per Host. +func rewriteServed(w http.ResponseWriter, r *http.Request, sr provider.ServeRewriter, remote models.Remote, src io.Reader) ([]byte, bool) { + if !validHost(r.Host) { + http.Error(w, "invalid host header", http.StatusBadRequest) + return nil, false + } + raw, err := io.ReadAll(src) + if err != nil { + http.Error(w, "bad gateway", http.StatusBadGateway) + return nil, false + } + raw = sr.RewriteServed(raw, remote, fmt.Sprintf("%s://%s", Scheme(r), r.Host)) + w.Header().Set("Cache-Control", "no-store") + w.Header().Set("Vary", "Host, X-Forwarded-Proto") + w.Header().Set("Content-Length", strconv.Itoa(len(raw))) + return raw, true +} + +// validHost accepts host[:port] where host is a DNS name or IP literal. +func validHost(hostport string) bool { + host, port := hostport, "" + if h, p, err := net.SplitHostPort(hostport); err == nil { + if p == "" { + return false + } + host, port = h, p + } else if strings.HasPrefix(hostport, "[") { + return strings.HasSuffix(hostport, "]") && net.ParseIP(hostport[1:len(hostport)-1]) != nil + } + if port != "" { + if n, err := strconv.Atoi(port); err != nil || n < 1 || n > 65535 { + return false + } + } + if net.ParseIP(host) != nil { + return true + } + if strings.HasPrefix(hostport, "[") { + return false + } + if host == "" || len(host) > 253 { + return false + } + for _, label := range strings.Split(strings.TrimSuffix(host, "."), ".") { + if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' || + strings.Trim(strings.ToLower(label), "abcdefghijklmnopqrstuvwxyz0123456789-_") != "" { + return false + } + } + return true +} diff --git a/internal/api/v1/rewrite_test.go b/internal/api/v1/rewrite_test.go new file mode 100644 index 0000000..24fd732 --- /dev/null +++ b/internal/api/v1/rewrite_test.go @@ -0,0 +1,74 @@ +package v1 + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.unkin.net/unkin/artifactapi/internal/provider/nuget" + "git.unkin.net/unkin/artifactapi/pkg/models" +) + +func TestValidHost(t *testing.T) { + for _, h := range []string{"aa.example", "aa.example:8443", "artifactapi.k8s.syd1.au.unkin.net", "10.0.0.1:80", "[::1]:8080", "[::1]", "::1", "localhost", "my_host.local"} { + if !validHost(h) { + t.Errorf("validHost(%q) = false, want true", h) + } + } + for _, h := range []string{"", "evil\".example", "a b", "aa.example:0", "aa.example:99999", "aa.example:x", "aa.example:", ":8080", "-bad.example", "a..b", "[::1", "[nope]:80", "a/b", "a