From cfe2435ba55d67683919ae7f214b1bdae53b94db Mon Sep 17 00:00:00 2001 From: Adira Denis Muhando Date: Wed, 29 Apr 2026 15:46:17 +0300 Subject: [PATCH 1/2] fix(http): honor X-Forwarded-Prefix when proxy strips the prefix Closes #9145. Two related issues kept the React UI from loading when a reverse proxy rewrites a sub-path with prefix-stripping (e.g. Caddy `handle_path`): 1. `BaseURL` only computed a prefix from the path StripPathPrefix had removed, so when the proxy strips the prefix before forwarding, the request arrives without it and the base URL was returned without a prefix. Extract a `BasePathPrefix` helper and add an `X-Forwarded-Prefix` header fallback so the prefix is recovered. 2. `` only changes how relative URLs resolve; the build emits path-absolute references like `/assets/...` and `/favicon.svg`, which still resolve against the origin and bypass the proxy prefix. Rewrite those references in the served `index.html` so the browser requests them through the proxy. Adds unit coverage for `BaseURL` with a pre-stripped path and an end-to-end test for the proxy-stripped scenario. Assisted-by: Claude:claude-opus-4-7 --- core/http/app.go | 11 ++++++ core/http/app_test.go | 19 +++++++++ core/http/middleware/baseurl.go | 59 ++++++++++++++++++---------- core/http/middleware/baseurl_test.go | 45 +++++++++++++++++++++ 4 files changed, 114 insertions(+), 20 deletions(-) diff --git a/core/http/app.go b/core/http/app.go index f713fec4da18..f216d55dd0b0 100644 --- a/core/http/app.go +++ b/core/http/app.go @@ -443,6 +443,17 @@ func API(application *application.Application) (*echo.Echo, error) { baseTag := `` indexHTML = []byte(strings.Replace(string(indexHTML), "", "\n "+baseTag, 1)) } + // only changes how relative URLs resolve; path-absolute + // URLs (those starting with `/`) still resolve against the origin + // and would bypass the reverse-proxy prefix. Rewrite the internal + // path-absolute references emitted by the build so the browser + // requests them through the proxy under the prefix. + if prefix := httpMiddleware.BasePathPrefix(c); prefix != "/" { + html := string(indexHTML) + html = strings.ReplaceAll(html, `="/assets/`, `="`+prefix+`assets/`) + html = strings.ReplaceAll(html, `="/favicon.svg"`, `="`+prefix+`favicon.svg"`) + indexHTML = []byte(html) + } return c.HTMLBlob(http.StatusOK, indexHTML) } diff --git a/core/http/app_test.go b/core/http/app_test.go index 31c6c5a5553e..e6c00d23e19a 100644 --- a/core/http/app_test.go +++ b/core/http/app_test.go @@ -446,6 +446,25 @@ var _ = Describe("API test", func() { Expect(sc).To(Equal(200), "status code") Expect(string(body)).To(ContainSubstring(``), "body") }) + + // Caddy's `handle_path` (and similar directives) strip the matched + // prefix before forwarding upstream, so LocalAI receives the + // already-stripped path together with X-Forwarded-Prefix. The base + // href and asset URLs must still include the prefix so the browser + // requests them through the proxy. + It("Should support reverse-proxy when prefix is stripped by the proxy", func() { + + err, sc, body := getRequest("http://127.0.0.1:9090/app", http.Header{ + "X-Forwarded-Proto": {"https"}, + "X-Forwarded-Host": {"example.org"}, + "X-Forwarded-Prefix": {"/myprefix"}, + }) + Expect(err).To(BeNil(), "error") + Expect(sc).To(Equal(200), "status code") + Expect(string(body)).To(ContainSubstring(``), "body") + Expect(string(body)).ToNot(ContainSubstring(`="/assets/`), "asset URLs must include the prefix") + Expect(string(body)).ToNot(ContainSubstring(`="/favicon.svg"`), "favicon URL must include the prefix") + }) }) Context("Applying models", func() { diff --git a/core/http/middleware/baseurl.go b/core/http/middleware/baseurl.go index 78a59289a81f..1607991e7640 100644 --- a/core/http/middleware/baseurl.go +++ b/core/http/middleware/baseurl.go @@ -6,20 +6,51 @@ import ( "github.com/labstack/echo/v4" ) -// BaseURL returns the base URL for the given HTTP request context. -// It takes into account that the app may be exposed by a reverse-proxy under a different protocol, host and path. -// 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 { +// BasePathPrefix returns the URL path prefix that the request was reached +// under (e.g. "/myprefix/"). It always returns a value that starts and ends +// with `/`, defaulting to "/" when the app is not behind a path prefix. +// +// It first looks at the path StripPathPrefix removed (when the proxy forwards +// the prefix in the URL), then falls back to the X-Forwarded-Prefix header +// (when the proxy strips the prefix before forwarding, e.g. Caddy's +// handle_path). +func BasePathPrefix(c echo.Context) string { path := c.Path() origPath := c.Request().URL.Path - // Check if StripPathPrefix middleware stored the original path if storedPath, ok := c.Get("_original_path").(string); ok && storedPath != "" { origPath = storedPath } - // Check X-Forwarded-Proto for scheme + if path != origPath && strings.HasSuffix(origPath, path) && len(path) > 0 { + prefixLen := len(origPath) - len(path) + if prefixLen > 0 && prefixLen <= len(origPath) { + pathPrefix := origPath[:prefixLen] + if !strings.HasSuffix(pathPrefix, "/") { + pathPrefix += "/" + } + return pathPrefix + } + } + + if forwardedPrefix := c.Request().Header.Get("X-Forwarded-Prefix"); forwardedPrefix != "" { + if !strings.HasPrefix(forwardedPrefix, "/") { + forwardedPrefix = "/" + forwardedPrefix + } + if !strings.HasSuffix(forwardedPrefix, "/") { + forwardedPrefix += "/" + } + return forwardedPrefix + } + + return "/" +} + +// BaseURL returns the base URL for the given HTTP request context. +// It takes into account that the app may be exposed by a reverse-proxy under a different protocol, host and path. +// 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 { scheme := "http" if c.Request().Header.Get("X-Forwarded-Proto") == "https" { scheme = "https" @@ -27,22 +58,10 @@ func BaseURL(c echo.Context) string { scheme = "https" } - // Check X-Forwarded-Host for host host := c.Request().Host if forwardedHost := c.Request().Header.Get("X-Forwarded-Host"); forwardedHost != "" { host = forwardedHost } - if path != origPath && strings.HasSuffix(origPath, path) && len(path) > 0 { - prefixLen := len(origPath) - len(path) - if prefixLen > 0 && prefixLen <= len(origPath) { - pathPrefix := origPath[:prefixLen] - if !strings.HasSuffix(pathPrefix, "/") { - pathPrefix += "/" - } - return scheme + "://" + host + pathPrefix - } - } - - return scheme + "://" + host + "/" + return scheme + "://" + host + BasePathPrefix(c) } diff --git a/core/http/middleware/baseurl_test.go b/core/http/middleware/baseurl_test.go index b0770b8eae41..c9eefa525cec 100644 --- a/core/http/middleware/baseurl_test.go +++ b/core/http/middleware/baseurl_test.go @@ -55,4 +55,49 @@ var _ = Describe("BaseURL", func() { Expect(actualURL).To(Equal("http://example.com/myprefix/"), "base URL") }) }) + + // Caddy's handle_path (and similar reverse-proxy directives) strips the + // matched prefix before forwarding upstream, so LocalAI receives the + // already-stripped path together with X-Forwarded-Prefix. In that case + // StripPathPrefix never stores _original_path, but BaseURL must still + // honor the header so that and asset URLs include the prefix. + Context("with X-Forwarded-Prefix header but pre-stripped path", func() { + It("should return base URL with prefix from header", func() { + app := echo.New() + actualURL := "" + + routePath := "/app" + app.GET(routePath, func(c echo.Context) error { + actualURL = BaseURL(c) + return nil + }) + + req := httptest.NewRequest("GET", "/app", nil) + req.Header.Set("X-Forwarded-Prefix", "/localai") + rec := httptest.NewRecorder() + app.ServeHTTP(rec, req) + + Expect(rec.Code).To(Equal(200), "response status code") + Expect(actualURL).To(Equal("http://example.com/localai/"), "base URL") + }) + + It("should normalize a prefix that already ends with a slash", func() { + app := echo.New() + actualURL := "" + + routePath := "/app" + app.GET(routePath, func(c echo.Context) error { + actualURL = BaseURL(c) + return nil + }) + + req := httptest.NewRequest("GET", "/app", nil) + req.Header.Set("X-Forwarded-Prefix", "/localai/") + rec := httptest.NewRecorder() + app.ServeHTTP(rec, req) + + Expect(rec.Code).To(Equal(200), "response status code") + Expect(actualURL).To(Equal("http://example.com/localai/"), "base URL") + }) + }) }) From 348557aa3ae09a708002d5393169fbb3539befb0 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Wed, 13 May 2026 19:36:30 +0000 Subject: [PATCH 2/2] fix(http): gate X-Forwarded-Prefix through SafeForwardedPrefix in BasePathPrefix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BasePathPrefix consumed X-Forwarded-Prefix directly, so a value the codebase elsewhere rejects (e.g. "//evil.com") slipped through and was interpolated into the SPA index.html — both into the path-absolute asset URL rewrite in serveIndex (turning "/assets/..." into "//evil.com/assets/...", a protocol-relative URL that loads JS from a foreign origin) and into . Route the header through the existing SafeForwardedPrefix validator that StripPathPrefix and prefixRedirect already use, and HTML-escape the prefix before injecting it into the asset rewrite as defense in depth against attribute breakout. Tests cover //evil.com, backslashes, control chars, CR/LF and a missing leading slash; the integration test asserts an unsafe prefix can't poison asset URLs. Signed-off-by: Ettore Di Giacinto Assisted-by: claude-code:claude-opus-4-7-1m [Read] [Edit] [Bash] --- core/http/app.go | 12 ++++++++-- core/http/app_test.go | 17 ++++++++++++++ core/http/middleware/baseurl.go | 20 +++++++++------- core/http/middleware/baseurl_test.go | 35 ++++++++++++++++++++++++++++ 4 files changed, 74 insertions(+), 10 deletions(-) diff --git a/core/http/app.go b/core/http/app.go index f216d55dd0b0..99d11bd69c5c 100644 --- a/core/http/app.go +++ b/core/http/app.go @@ -448,10 +448,18 @@ func API(application *application.Application) (*echo.Echo, error) { // and would bypass the reverse-proxy prefix. Rewrite the internal // path-absolute references emitted by the build so the browser // requests them through the proxy under the prefix. + // + // HTML-escape the prefix before interpolating it into attributes: + // BasePathPrefix already gates X-Forwarded-Prefix via + // SafeForwardedPrefix, but the validator only blocks open-redirect + // shapes (// prefix, backslashes, control chars), not attribute + // breakout characters like `"`. Escaping makes this resilient + // even if the validator ever loosens. if prefix := httpMiddleware.BasePathPrefix(c); prefix != "/" { + safePrefix := httpMiddleware.SecureBaseHref(prefix) html := string(indexHTML) - html = strings.ReplaceAll(html, `="/assets/`, `="`+prefix+`assets/`) - html = strings.ReplaceAll(html, `="/favicon.svg"`, `="`+prefix+`favicon.svg"`) + html = strings.ReplaceAll(html, `="/assets/`, `="`+safePrefix+`assets/`) + html = strings.ReplaceAll(html, `="/favicon.svg"`, `="`+safePrefix+`favicon.svg"`) indexHTML = []byte(html) } return c.HTMLBlob(http.StatusOK, indexHTML) diff --git a/core/http/app_test.go b/core/http/app_test.go index e6c00d23e19a..bd7fa501ef63 100644 --- a/core/http/app_test.go +++ b/core/http/app_test.go @@ -465,6 +465,23 @@ var _ = Describe("API test", func() { Expect(string(body)).ToNot(ContainSubstring(`="/assets/`), "asset URLs must include the prefix") Expect(string(body)).ToNot(ContainSubstring(`="/favicon.svg"`), "favicon URL must include the prefix") }) + + // X-Forwarded-Prefix is attacker controllable on misconfigured + // proxy chains. A value like "//evil.com" would otherwise turn the + // asset URL rewrite into a protocol-relative URL that loads JS + // from a foreign origin. BasePathPrefix must reject these via + // SafeForwardedPrefix and fall back to "/". + It("Should ignore an unsafe X-Forwarded-Prefix and not poison asset URLs", func() { + err, sc, body := getRequest("http://127.0.0.1:9090/app", http.Header{ + "X-Forwarded-Proto": {"https"}, + "X-Forwarded-Host": {"example.org"}, + "X-Forwarded-Prefix": {"//evil.com"}, + }) + Expect(err).To(BeNil(), "error") + Expect(sc).To(Equal(200), "status code") + Expect(string(body)).ToNot(ContainSubstring("evil.com"), "unsafe prefix must not leak into the response") + Expect(string(body)).ToNot(ContainSubstring(`="//`), "asset URLs must not become protocol-relative") + }) }) Context("Applying models", func() { diff --git a/core/http/middleware/baseurl.go b/core/http/middleware/baseurl.go index 1607991e7640..a1e1844aebda 100644 --- a/core/http/middleware/baseurl.go +++ b/core/http/middleware/baseurl.go @@ -14,6 +14,13 @@ import ( // the prefix in the URL), then falls back to the X-Forwarded-Prefix header // (when the proxy strips the prefix before forwarding, e.g. Caddy's // handle_path). +// +// The header fallback is gated through SafeForwardedPrefix because the value +// flows into the SPA HTML response (both and the path-absolute +// asset URL rewrite in serveIndex). X-Forwarded-Prefix is attacker +// controllable on misconfigured proxy chains; without that gate a value like +// "//evil.com" turns the asset rewrite into a protocol-relative URL that +// loads JS from a foreign origin. func BasePathPrefix(c echo.Context) string { path := c.Path() origPath := c.Request().URL.Path @@ -24,7 +31,7 @@ func BasePathPrefix(c echo.Context) string { if path != origPath && strings.HasSuffix(origPath, path) && len(path) > 0 { prefixLen := len(origPath) - len(path) - if prefixLen > 0 && prefixLen <= len(origPath) { + if prefixLen > 0 { pathPrefix := origPath[:prefixLen] if !strings.HasSuffix(pathPrefix, "/") { pathPrefix += "/" @@ -33,14 +40,11 @@ func BasePathPrefix(c echo.Context) string { } } - if forwardedPrefix := c.Request().Header.Get("X-Forwarded-Prefix"); forwardedPrefix != "" { - if !strings.HasPrefix(forwardedPrefix, "/") { - forwardedPrefix = "/" + forwardedPrefix - } - if !strings.HasSuffix(forwardedPrefix, "/") { - forwardedPrefix += "/" + if validated, ok := SafeForwardedPrefix(c.Request().Header.Get("X-Forwarded-Prefix")); ok { + if !strings.HasSuffix(validated, "/") { + validated += "/" } - return forwardedPrefix + return validated } return "/" diff --git a/core/http/middleware/baseurl_test.go b/core/http/middleware/baseurl_test.go index c9eefa525cec..4f6dbb1d1ed9 100644 --- a/core/http/middleware/baseurl_test.go +++ b/core/http/middleware/baseurl_test.go @@ -100,4 +100,39 @@ var _ = Describe("BaseURL", func() { Expect(actualURL).To(Equal("http://example.com/localai/"), "base URL") }) }) + + // X-Forwarded-Prefix is attacker controllable on misconfigured proxy + // chains, and the value flows into the SPA HTML response ( + // and asset URLs). BasePathPrefix must gate the header through + // SafeForwardedPrefix so values that turn the prefix into an open + // redirect or a protocol-relative URL are ignored and the base falls + // back to "/". + Context("with unsafe X-Forwarded-Prefix header", func() { + DescribeTable("falls back to / when the header is unsafe", + func(header string) { + app := echo.New() + actualURL := "" + + app.GET("/app", func(c echo.Context) error { + actualURL = BaseURL(c) + return nil + }) + + req := httptest.NewRequest("GET", "/app", nil) + req.Header.Set("X-Forwarded-Prefix", header) + rec := httptest.NewRecorder() + app.ServeHTTP(rec, req) + + Expect(rec.Code).To(Equal(200), "response status code") + Expect(actualURL).To(Equal("http://example.com/"), "base URL") + }, + Entry("protocol-relative URL", "//evil.com"), + Entry("protocol-relative URL with path", "//evil.com/assets"), + Entry("backslash path", `/foo\bar`), + Entry("embedded NUL", "/foo\x00bar"), + Entry("CR injection", "/foo\rbar"), + Entry("LF injection", "/foo\nbar"), + Entry("missing leading slash", "evil"), + ) + }) })