From d9feac54dc35a61fc96e6fdaf91d15ad01d733e2 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Wed, 24 Jun 2026 21:57:43 +0000 Subject: [PATCH] fix(http): harden BaseURL proxy scheme/host detection Split comma-separated X-Forwarded-Proto and honor the RFC 7239 Forwarded header so generated links use https behind common reverse-proxy setups. Refs #10482 Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto --- core/http/middleware/baseurl.go | 49 ++++++++++++++++++++++++++-- core/http/middleware/baseurl_test.go | 45 +++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 2 deletions(-) diff --git a/core/http/middleware/baseurl.go b/core/http/middleware/baseurl.go index a1e1844ae..4ad04d46e 100644 --- a/core/http/middleware/baseurl.go +++ b/core/http/middleware/baseurl.go @@ -55,17 +55,62 @@ func BasePathPrefix(c echo.Context) string { // The returned URL is guaranteed to end with `/`. // The method should be used in conjunction with the StripPathPrefix middleware. func BaseURL(c echo.Context) string { + fwdProto, fwdHost := parseForwarded(c.Request().Header.Get("Forwarded")) + scheme := "http" - if c.Request().Header.Get("X-Forwarded-Proto") == "https" { + switch { + case c.Request().TLS != nil: scheme = "https" - } else if c.Request().TLS != nil { + case strings.EqualFold(firstToken(c.Request().Header.Get("X-Forwarded-Proto")), "https"): + scheme = "https" + case strings.EqualFold(fwdProto, "https"): scheme = "https" } host := c.Request().Host if forwardedHost := c.Request().Header.Get("X-Forwarded-Host"); forwardedHost != "" { host = forwardedHost + } else if fwdHost != "" { + host = fwdHost } return scheme + "://" + host + BasePathPrefix(c) } + +// firstToken returns the first comma-separated token of v, trimmed of spaces. +// Reverse-proxy chains can emit X-Forwarded-Proto as "https,http"; only the +// first hop (closest to the client) is meaningful for scheme detection. +func firstToken(v string) string { + if i := strings.IndexByte(v, ','); i >= 0 { + v = v[:i] + } + return strings.TrimSpace(v) +} + +// parseForwarded extracts the proto and host directives from the first element +// of an RFC 7239 Forwarded header (e.g. `for=x;proto=https;host=h, for=y`). +// Values may be quoted. Returns empty strings when absent or malformed so the +// caller can fall through to other signals. +func parseForwarded(header string) (proto, host string) { + if header == "" { + return "", "" + } + // Only the first element (closest proxy to the client) matters here. + if i := strings.IndexByte(header, ','); i >= 0 { + header = header[:i] + } + for _, directive := range strings.Split(header, ";") { + key, value, ok := strings.Cut(strings.TrimSpace(directive), "=") + if !ok { + continue + } + value = strings.Trim(strings.TrimSpace(value), `"`) + switch strings.ToLower(strings.TrimSpace(key)) { + case "proto": + proto = value + case "host": + host = value + } + } + return proto, host +} diff --git a/core/http/middleware/baseurl_test.go b/core/http/middleware/baseurl_test.go index 4f6dbb1d1..72569aa67 100644 --- a/core/http/middleware/baseurl_test.go +++ b/core/http/middleware/baseurl_test.go @@ -135,4 +135,49 @@ var _ = Describe("BaseURL", func() { Entry("missing leading slash", "evil"), ) }) + + Context("scheme detection hardening", func() { + It("treats comma-separated X-Forwarded-Proto as https when first token is https", func() { + app := echo.New() + actualURL := "" + app.GET("/x", func(c echo.Context) error { + actualURL = BaseURL(c) + return nil + }) + req := httptest.NewRequest("GET", "/x", nil) + req.Header.Set("X-Forwarded-Proto", "https,http") + rec := httptest.NewRecorder() + app.ServeHTTP(rec, req) + Expect(actualURL).To(Equal("https://example.com/")) + }) + + It("derives https from the RFC 7239 Forwarded proto directive", func() { + app := echo.New() + actualURL := "" + app.GET("/x", func(c echo.Context) error { + actualURL = BaseURL(c) + return nil + }) + req := httptest.NewRequest("GET", "/x", nil) + req.Header.Set("Forwarded", "for=192.0.2.1;proto=https;host=proxy.example") + rec := httptest.NewRecorder() + app.ServeHTTP(rec, req) + Expect(actualURL).To(Equal("https://proxy.example/")) + }) + + It("prefers X-Forwarded-Host over the Forwarded host directive", func() { + app := echo.New() + actualURL := "" + app.GET("/x", func(c echo.Context) error { + actualURL = BaseURL(c) + return nil + }) + req := httptest.NewRequest("GET", "/x", nil) + req.Header.Set("X-Forwarded-Host", "xfh.example") + req.Header.Set("Forwarded", "host=fwd.example;proto=https") + rec := httptest.NewRecorder() + app.ServeHTTP(rec, req) + Expect(actualURL).To(Equal("https://xfh.example/")) + }) + }) })