diff --git a/e2e/head_test.go b/e2e/head_test.go index f7b9ac7..487a909 100644 --- a/e2e/head_test.go +++ b/e2e/head_test.go @@ -39,8 +39,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)) @@ -60,7 +62,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/proxy.go b/internal/api/v1/proxy.go index 8a6e81e..9015105 100644 --- a/internal/api/v1/proxy.go +++ b/internal/api/v1/proxy.go @@ -94,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) { @@ -189,7 +189,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 { @@ -266,11 +266,14 @@ 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 == "http" || fwd == "https" { + fwd, _, _ := strings.Cut(r.Header.Get("X-Forwarded-Proto"), ",") + if fwd = strings.ToLower(strings.TrimSpace(fwd)); fwd == "https" { return fwd } return "http" @@ -288,7 +291,7 @@ func rewriteServed(w http.ResponseWriter, r *http.Request, sr provider.ServeRewr http.Error(w, "bad gateway", http.StatusBadGateway) return nil, false } - raw = sr.RewriteServed(raw, remote, fmt.Sprintf("%s://%s", scheme(r), r.Host)) + 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))) @@ -299,6 +302,9 @@ func rewriteServed(w http.ResponseWriter, r *http.Request, sr provider.ServeRewr 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 diff --git a/internal/api/v1/rewrite_test.go b/internal/api/v1/rewrite_test.go index c2c30a9..24fd732 100644 --- a/internal/api/v1/rewrite_test.go +++ b/internal/api/v1/rewrite_test.go @@ -16,7 +16,7 @@ func TestValidHost(t *testing.T) { 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", "-bad.example", "a..b", "[::1", "[nope]:80", "a/b", "a