Skip to content

Commit 348557a

Browse files
committed
fix(http): gate X-Forwarded-Prefix through SafeForwardedPrefix in BasePathPrefix
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 <base href>. 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 <mudler@localai.io> Assisted-by: claude-code:claude-opus-4-7-1m [Read] [Edit] [Bash]
1 parent cfe2435 commit 348557a

4 files changed

Lines changed: 74 additions & 10 deletions

File tree

core/http/app.go

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -448,10 +448,18 @@ func API(application *application.Application) (*echo.Echo, error) {
448448
// and would bypass the reverse-proxy prefix. Rewrite the internal
449449
// path-absolute references emitted by the build so the browser
450450
// requests them through the proxy under the prefix.
451+
//
452+
// HTML-escape the prefix before interpolating it into attributes:
453+
// BasePathPrefix already gates X-Forwarded-Prefix via
454+
// SafeForwardedPrefix, but the validator only blocks open-redirect
455+
// shapes (// prefix, backslashes, control chars), not attribute
456+
// breakout characters like `"`. Escaping makes this resilient
457+
// even if the validator ever loosens.
451458
if prefix := httpMiddleware.BasePathPrefix(c); prefix != "/" {
459+
safePrefix := httpMiddleware.SecureBaseHref(prefix)
452460
html := string(indexHTML)
453-
html = strings.ReplaceAll(html, `="/assets/`, `="`+prefix+`assets/`)
454-
html = strings.ReplaceAll(html, `="/favicon.svg"`, `="`+prefix+`favicon.svg"`)
461+
html = strings.ReplaceAll(html, `="/assets/`, `="`+safePrefix+`assets/`)
462+
html = strings.ReplaceAll(html, `="/favicon.svg"`, `="`+safePrefix+`favicon.svg"`)
455463
indexHTML = []byte(html)
456464
}
457465
return c.HTMLBlob(http.StatusOK, indexHTML)

core/http/app_test.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,6 +465,23 @@ var _ = Describe("API test", func() {
465465
Expect(string(body)).ToNot(ContainSubstring(`="/assets/`), "asset URLs must include the prefix")
466466
Expect(string(body)).ToNot(ContainSubstring(`="/favicon.svg"`), "favicon URL must include the prefix")
467467
})
468+
469+
// X-Forwarded-Prefix is attacker controllable on misconfigured
470+
// proxy chains. A value like "//evil.com" would otherwise turn the
471+
// asset URL rewrite into a protocol-relative URL that loads JS
472+
// from a foreign origin. BasePathPrefix must reject these via
473+
// SafeForwardedPrefix and fall back to "/".
474+
It("Should ignore an unsafe X-Forwarded-Prefix and not poison asset URLs", func() {
475+
err, sc, body := getRequest("http://127.0.0.1:9090/app", http.Header{
476+
"X-Forwarded-Proto": {"https"},
477+
"X-Forwarded-Host": {"example.org"},
478+
"X-Forwarded-Prefix": {"//evil.com"},
479+
})
480+
Expect(err).To(BeNil(), "error")
481+
Expect(sc).To(Equal(200), "status code")
482+
Expect(string(body)).ToNot(ContainSubstring("evil.com"), "unsafe prefix must not leak into the response")
483+
Expect(string(body)).ToNot(ContainSubstring(`="//`), "asset URLs must not become protocol-relative")
484+
})
468485
})
469486

470487
Context("Applying models", func() {

core/http/middleware/baseurl.go

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,13 @@ import (
1414
// the prefix in the URL), then falls back to the X-Forwarded-Prefix header
1515
// (when the proxy strips the prefix before forwarding, e.g. Caddy's
1616
// handle_path).
17+
//
18+
// The header fallback is gated through SafeForwardedPrefix because the value
19+
// flows into the SPA HTML response (both <base href> and the path-absolute
20+
// asset URL rewrite in serveIndex). X-Forwarded-Prefix is attacker
21+
// controllable on misconfigured proxy chains; without that gate a value like
22+
// "//evil.com" turns the asset rewrite into a protocol-relative URL that
23+
// loads JS from a foreign origin.
1724
func BasePathPrefix(c echo.Context) string {
1825
path := c.Path()
1926
origPath := c.Request().URL.Path
@@ -24,7 +31,7 @@ func BasePathPrefix(c echo.Context) string {
2431

2532
if path != origPath && strings.HasSuffix(origPath, path) && len(path) > 0 {
2633
prefixLen := len(origPath) - len(path)
27-
if prefixLen > 0 && prefixLen <= len(origPath) {
34+
if prefixLen > 0 {
2835
pathPrefix := origPath[:prefixLen]
2936
if !strings.HasSuffix(pathPrefix, "/") {
3037
pathPrefix += "/"
@@ -33,14 +40,11 @@ func BasePathPrefix(c echo.Context) string {
3340
}
3441
}
3542

36-
if forwardedPrefix := c.Request().Header.Get("X-Forwarded-Prefix"); forwardedPrefix != "" {
37-
if !strings.HasPrefix(forwardedPrefix, "/") {
38-
forwardedPrefix = "/" + forwardedPrefix
39-
}
40-
if !strings.HasSuffix(forwardedPrefix, "/") {
41-
forwardedPrefix += "/"
43+
if validated, ok := SafeForwardedPrefix(c.Request().Header.Get("X-Forwarded-Prefix")); ok {
44+
if !strings.HasSuffix(validated, "/") {
45+
validated += "/"
4246
}
43-
return forwardedPrefix
47+
return validated
4448
}
4549

4650
return "/"

core/http/middleware/baseurl_test.go

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,4 +100,39 @@ var _ = Describe("BaseURL", func() {
100100
Expect(actualURL).To(Equal("http://example.com/localai/"), "base URL")
101101
})
102102
})
103+
104+
// X-Forwarded-Prefix is attacker controllable on misconfigured proxy
105+
// chains, and the value flows into the SPA HTML response (<base href>
106+
// and asset URLs). BasePathPrefix must gate the header through
107+
// SafeForwardedPrefix so values that turn the prefix into an open
108+
// redirect or a protocol-relative URL are ignored and the base falls
109+
// back to "/".
110+
Context("with unsafe X-Forwarded-Prefix header", func() {
111+
DescribeTable("falls back to / when the header is unsafe",
112+
func(header string) {
113+
app := echo.New()
114+
actualURL := ""
115+
116+
app.GET("/app", func(c echo.Context) error {
117+
actualURL = BaseURL(c)
118+
return nil
119+
})
120+
121+
req := httptest.NewRequest("GET", "/app", nil)
122+
req.Header.Set("X-Forwarded-Prefix", header)
123+
rec := httptest.NewRecorder()
124+
app.ServeHTTP(rec, req)
125+
126+
Expect(rec.Code).To(Equal(200), "response status code")
127+
Expect(actualURL).To(Equal("http://example.com/"), "base URL")
128+
},
129+
Entry("protocol-relative URL", "//evil.com"),
130+
Entry("protocol-relative URL with path", "//evil.com/assets"),
131+
Entry("backslash path", `/foo\bar`),
132+
Entry("embedded NUL", "/foo\x00bar"),
133+
Entry("CR injection", "/foo\rbar"),
134+
Entry("LF injection", "/foo\nbar"),
135+
Entry("missing leading slash", "evil"),
136+
)
137+
})
103138
})

0 commit comments

Comments
 (0)