Share strict X-Forwarded-Proto parsing and reject empty Host ports
ci/woodpecker/pr/pre-commit Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/build Pipeline was successful

This commit is contained in:
2026-10-17 12:56:29 +11:00
parent d0c30e145e
commit fe10a50d27
6 changed files with 105 additions and 24 deletions
+17 -3
View File
@@ -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) {
+59
View File
@@ -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)
}
}
+2 -8
View File
@@ -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
}
+11 -5
View File
@@ -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
+1 -1
View File
@@ -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<script>", strings.Repeat("a", 64) + ".example"} {
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<script>", strings.Repeat("a", 64) + ".example"} {
if validHost(h) {
t.Errorf("validHost(%q) = true, want false", h)
}
+15 -7
View File
@@ -7,20 +7,28 @@ import (
)
func TestScheme(t *testing.T) {
if got := scheme(&http.Request{TLS: &tls.ConnectionState{}}); got != "https" {
if got := Scheme(&http.Request{TLS: &tls.ConnectionState{}}); got != "https" {
t.Errorf("TLS request scheme = %q, want https", got)
}
r := &http.Request{Header: http.Header{"X-Forwarded-Proto": {"https"}}}
if got := scheme(r); got != "https" {
if got := Scheme(r); got != "https" {
t.Errorf("X-Forwarded-Proto scheme = %q, want https", got)
}
for _, bogus := range []string{"javascript", "HTTPS", "https, http", "ftp"} {
r := &http.Request{Header: http.Header{"X-Forwarded-Proto": {bogus}}}
if got := scheme(r); got != "http" {
t.Errorf("X-Forwarded-Proto %q scheme = %q, want http", bogus, got)
for fwd, want := range map[string]string{
"https, http": "https",
"http, https": "http",
"HTTPS": "https",
" https ": "https",
"javascript": "http",
"ftp": "http",
"https;evil": "http",
} {
r := &http.Request{Header: http.Header{"X-Forwarded-Proto": {fwd}}}
if got := Scheme(r); got != want {
t.Errorf("X-Forwarded-Proto %q scheme = %q, want %s", fwd, got, want)
}
}
if got := scheme(&http.Request{Header: http.Header{}}); got != "http" {
if got := Scheme(&http.Request{Header: http.Header{}}); got != "http" {
t.Errorf("default scheme = %q, want http", got)
}
}