Skip to content

Commit 834e876

Browse files
Dennisadiraclaude
andcommitted
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. `<base href>` 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. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent ad74273 commit 834e876

4 files changed

Lines changed: 114 additions & 20 deletions

File tree

core/http/app.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -415,6 +415,17 @@ func API(application *application.Application) (*echo.Echo, error) {
415415
baseTag := `<base href="` + baseURL + `" />`
416416
indexHTML = []byte(strings.Replace(string(indexHTML), "<head>", "<head>\n "+baseTag, 1))
417417
}
418+
// <base href> only changes how relative URLs resolve; path-absolute
419+
// URLs (those starting with `/`) still resolve against the origin
420+
// and would bypass the reverse-proxy prefix. Rewrite the internal
421+
// path-absolute references emitted by the build so the browser
422+
// requests them through the proxy under the prefix.
423+
if prefix := httpMiddleware.BasePathPrefix(c); prefix != "/" {
424+
html := string(indexHTML)
425+
html = strings.ReplaceAll(html, `="/assets/`, `="`+prefix+`assets/`)
426+
html = strings.ReplaceAll(html, `="/favicon.svg"`, `="`+prefix+`favicon.svg"`)
427+
indexHTML = []byte(html)
428+
}
418429
return c.HTMLBlob(http.StatusOK, indexHTML)
419430
}
420431

core/http/app_test.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -446,6 +446,25 @@ var _ = Describe("API test", func() {
446446
Expect(sc).To(Equal(200), "status code")
447447
Expect(string(body)).To(ContainSubstring(`<base href="https://example.org/myprefix/" />`), "body")
448448
})
449+
450+
// Caddy's `handle_path` (and similar directives) strip the matched
451+
// prefix before forwarding upstream, so LocalAI receives the
452+
// already-stripped path together with X-Forwarded-Prefix. The base
453+
// href and asset URLs must still include the prefix so the browser
454+
// requests them through the proxy.
455+
It("Should support reverse-proxy when prefix is stripped by the proxy", func() {
456+
457+
err, sc, body := getRequest("http://127.0.0.1:9090/app", http.Header{
458+
"X-Forwarded-Proto": {"https"},
459+
"X-Forwarded-Host": {"example.org"},
460+
"X-Forwarded-Prefix": {"/myprefix"},
461+
})
462+
Expect(err).To(BeNil(), "error")
463+
Expect(sc).To(Equal(200), "status code")
464+
Expect(string(body)).To(ContainSubstring(`<base href="https://example.org/myprefix/" />`), "body")
465+
Expect(string(body)).ToNot(ContainSubstring(`="/assets/`), "asset URLs must include the prefix")
466+
Expect(string(body)).ToNot(ContainSubstring(`="/favicon.svg"`), "favicon URL must include the prefix")
467+
})
449468
})
450469

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

core/http/middleware/baseurl.go

Lines changed: 39 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -6,43 +6,62 @@ import (
66
"github.com/labstack/echo/v4"
77
)
88

9-
// BaseURL returns the base URL for the given HTTP request context.
10-
// It takes into account that the app may be exposed by a reverse-proxy under a different protocol, host and path.
11-
// The returned URL is guaranteed to end with `/`.
12-
// The method should be used in conjunction with the StripPathPrefix middleware.
13-
func BaseURL(c echo.Context) string {
9+
// BasePathPrefix returns the URL path prefix that the request was reached
10+
// under (e.g. "/myprefix/"). It always returns a value that starts and ends
11+
// with `/`, defaulting to "/" when the app is not behind a path prefix.
12+
//
13+
// It first looks at the path StripPathPrefix removed (when the proxy forwards
14+
// the prefix in the URL), then falls back to the X-Forwarded-Prefix header
15+
// (when the proxy strips the prefix before forwarding, e.g. Caddy's
16+
// handle_path).
17+
func BasePathPrefix(c echo.Context) string {
1418
path := c.Path()
1519
origPath := c.Request().URL.Path
1620

17-
// Check if StripPathPrefix middleware stored the original path
1821
if storedPath, ok := c.Get("_original_path").(string); ok && storedPath != "" {
1922
origPath = storedPath
2023
}
2124

22-
// Check X-Forwarded-Proto for scheme
25+
if path != origPath && strings.HasSuffix(origPath, path) && len(path) > 0 {
26+
prefixLen := len(origPath) - len(path)
27+
if prefixLen > 0 && prefixLen <= len(origPath) {
28+
pathPrefix := origPath[:prefixLen]
29+
if !strings.HasSuffix(pathPrefix, "/") {
30+
pathPrefix += "/"
31+
}
32+
return pathPrefix
33+
}
34+
}
35+
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 += "/"
42+
}
43+
return forwardedPrefix
44+
}
45+
46+
return "/"
47+
}
48+
49+
// BaseURL returns the base URL for the given HTTP request context.
50+
// It takes into account that the app may be exposed by a reverse-proxy under a different protocol, host and path.
51+
// The returned URL is guaranteed to end with `/`.
52+
// The method should be used in conjunction with the StripPathPrefix middleware.
53+
func BaseURL(c echo.Context) string {
2354
scheme := "http"
2455
if c.Request().Header.Get("X-Forwarded-Proto") == "https" {
2556
scheme = "https"
2657
} else if c.Request().TLS != nil {
2758
scheme = "https"
2859
}
2960

30-
// Check X-Forwarded-Host for host
3161
host := c.Request().Host
3262
if forwardedHost := c.Request().Header.Get("X-Forwarded-Host"); forwardedHost != "" {
3363
host = forwardedHost
3464
}
3565

36-
if path != origPath && strings.HasSuffix(origPath, path) && len(path) > 0 {
37-
prefixLen := len(origPath) - len(path)
38-
if prefixLen > 0 && prefixLen <= len(origPath) {
39-
pathPrefix := origPath[:prefixLen]
40-
if !strings.HasSuffix(pathPrefix, "/") {
41-
pathPrefix += "/"
42-
}
43-
return scheme + "://" + host + pathPrefix
44-
}
45-
}
46-
47-
return scheme + "://" + host + "/"
66+
return scheme + "://" + host + BasePathPrefix(c)
4867
}

core/http/middleware/baseurl_test.go

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,49 @@ var _ = Describe("BaseURL", func() {
5555
Expect(actualURL).To(Equal("http://example.com/myprefix/"), "base URL")
5656
})
5757
})
58+
59+
// Caddy's handle_path (and similar reverse-proxy directives) strips the
60+
// matched prefix before forwarding upstream, so LocalAI receives the
61+
// already-stripped path together with X-Forwarded-Prefix. In that case
62+
// StripPathPrefix never stores _original_path, but BaseURL must still
63+
// honor the header so that <base href> and asset URLs include the prefix.
64+
Context("with X-Forwarded-Prefix header but pre-stripped path", func() {
65+
It("should return base URL with prefix from header", func() {
66+
app := echo.New()
67+
actualURL := ""
68+
69+
routePath := "/app"
70+
app.GET(routePath, func(c echo.Context) error {
71+
actualURL = BaseURL(c)
72+
return nil
73+
})
74+
75+
req := httptest.NewRequest("GET", "/app", nil)
76+
req.Header.Set("X-Forwarded-Prefix", "/localai")
77+
rec := httptest.NewRecorder()
78+
app.ServeHTTP(rec, req)
79+
80+
Expect(rec.Code).To(Equal(200), "response status code")
81+
Expect(actualURL).To(Equal("http://example.com/localai/"), "base URL")
82+
})
83+
84+
It("should normalize a prefix that already ends with a slash", func() {
85+
app := echo.New()
86+
actualURL := ""
87+
88+
routePath := "/app"
89+
app.GET(routePath, func(c echo.Context) error {
90+
actualURL = BaseURL(c)
91+
return nil
92+
})
93+
94+
req := httptest.NewRequest("GET", "/app", nil)
95+
req.Header.Set("X-Forwarded-Prefix", "/localai/")
96+
rec := httptest.NewRecorder()
97+
app.ServeHTTP(rec, req)
98+
99+
Expect(rec.Code).To(Equal(200), "response status code")
100+
Expect(actualURL).To(Equal("http://example.com/localai/"), "base URL")
101+
})
102+
})
58103
})

0 commit comments

Comments
 (0)