Skip to content

Commit fe4f425

Browse files
localai-botmudler
andauthored
fix: correct scheme/host on self-referential URLs behind an HTTPS reverse proxy (#10482) (#10504)
* 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 <mudler@localai.io> * feat(http): honor explicit external base URL in BaseURL When _external_base_url is set in the request context it dictates the origin (scheme+host+port); the proxy path prefix is still appended. Refs #10482 Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * feat(config): generalize LOCALAI_BASE_URL to ExternalBaseURL LOCALAI_BASE_URL now sets a single instance-wide external base URL used for OAuth callbacks and all self-referential links. A Pre middleware stamps it into the request context for middleware.BaseURL. Refs #10482 Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * docs: document LOCALAI_BASE_URL and reverse-proxy headers Refs #10482 Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> * test(http): cover parseForwarded edge cases; clarify base-url flag group Adds direct unit coverage for quoted/malformed/multi-element Forwarded headers and regroups the external base URL flag away from auth-only. Refs #10482 Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io> --------- Signed-off-by: Ettore Di Giacinto <mudler@localai.io> Co-authored-by: Ettore Di Giacinto <mudler@localai.io>
1 parent fae9f63 commit fe4f425

7 files changed

Lines changed: 238 additions & 10 deletions

File tree

core/cli/run.go

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -140,7 +140,7 @@ type RunCMD struct {
140140
OIDCIssuer string `env:"LOCALAI_OIDC_ISSUER" help:"OIDC issuer URL for auto-discovery" group:"auth"`
141141
OIDCClientID string `env:"LOCALAI_OIDC_CLIENT_ID" help:"OIDC Client ID (auto-enables auth)" group:"auth"`
142142
OIDCClientSecret string `env:"LOCALAI_OIDC_CLIENT_SECRET" help:"OIDC Client Secret" group:"auth"`
143-
AuthBaseURL string `env:"LOCALAI_BASE_URL" help:"Base URL for OAuth callbacks (e.g. http://localhost:8080)" group:"auth"`
143+
ExternalBaseURL string `env:"LOCALAI_BASE_URL" help:"External base URL of this instance (e.g. https://localhost:8080). Used for OAuth callbacks and self-referential links (generated images/videos, job status). When unset, derived from X-Forwarded-Proto/Host or Forwarded headers." group:"api"`
144144
AuthAdminEmail string `env:"LOCALAI_ADMIN_EMAIL" help:"Email address to auto-promote to admin role" group:"auth"`
145145
AuthRegistrationMode string `env:"LOCALAI_REGISTRATION_MODE" default:"open" help:"Registration mode: 'open' (default), 'approval', or 'invite' (invite code required)" group:"auth"`
146146
DisableLocalAuth bool `env:"LOCALAI_DISABLE_LOCAL_AUTH" default:"false" help:"Disable local email/password registration and login (use with OAuth/OIDC-only setups)" group:"auth"`
@@ -503,9 +503,6 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error {
503503
opts = append(opts, config.WithAuthOIDCClientID(r.OIDCClientID))
504504
opts = append(opts, config.WithAuthOIDCClientSecret(r.OIDCClientSecret))
505505
}
506-
if r.AuthBaseURL != "" {
507-
opts = append(opts, config.WithAuthBaseURL(r.AuthBaseURL))
508-
}
509506
if r.AuthAdminEmail != "" {
510507
opts = append(opts, config.WithAuthAdminEmail(r.AuthAdminEmail))
511508
}
@@ -523,6 +520,12 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error {
523520
}
524521
}
525522

523+
// Applied unconditionally: the external base URL governs all self-referential
524+
// links (not just OAuth callbacks), so it must take effect even when auth is off.
525+
if r.ExternalBaseURL != "" {
526+
opts = append(opts, config.WithExternalBaseURL(r.ExternalBaseURL))
527+
}
528+
526529
if idleWatchDog || busyWatchDog {
527530
opts = append(opts, config.EnableWatchDog)
528531
if idleWatchDog {

core/config/application_config.go

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,13 @@ type ApplicationConfig struct {
4949
P2PNetworkID string
5050
Federated bool
5151

52+
// ExternalBaseURL is the externally visible base URL of this instance
53+
// (scheme+host[:port]), set via LOCALAI_BASE_URL. When non-empty it is
54+
// authoritative for every self-referential URL LocalAI emits (OAuth
55+
// callbacks, generated image/video links, async job StatusURLs),
56+
// overriding proxy-header detection. Empty = derive from request headers.
57+
ExternalBaseURL string
58+
5259
// DisableStats turns off per-request token tracking. By default the
5360
// routing module's billing recorder runs in every mode (including
5461
// no-auth single-user) so dashboards and `/api/usage` are immediately
@@ -196,7 +203,6 @@ type AuthConfig struct {
196203
OIDCIssuer string // OIDC issuer URL for auto-discovery (e.g. https://accounts.google.com)
197204
OIDCClientID string
198205
OIDCClientSecret string
199-
BaseURL string // for OAuth callback URLs (e.g. "http://localhost:8080")
200206
AdminEmail string // auto-promote to admin on login
201207
RegistrationMode string // "open", "approval" (default when empty), "invite"
202208
DisableLocalAuth bool // disable local email/password registration and login
@@ -950,9 +956,9 @@ func WithAuthGitHubClientSecret(clientSecret string) AppOption {
950956
}
951957
}
952958

953-
func WithAuthBaseURL(baseURL string) AppOption {
959+
func WithExternalBaseURL(url string) AppOption {
954960
return func(o *ApplicationConfig) {
955-
o.Auth.BaseURL = baseURL
961+
o.ExternalBaseURL = url
956962
}
957963
}
958964

core/http/app.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,18 @@ func API(application *application.Application) (*echo.Echo, error) {
149149
// Middleware - StripPathPrefix must be registered early as it uses Rewrite which runs before routing
150150
e.Pre(httpMiddleware.StripPathPrefix())
151151

152+
// Stamp the configured external base URL into each request context so
153+
// middleware.BaseURL can treat it as authoritative for self-referential
154+
// links. Registered as Pre so it runs before routing and handlers.
155+
if extBaseURL := application.ApplicationConfig().ExternalBaseURL; extBaseURL != "" {
156+
e.Pre(func(next echo.HandlerFunc) echo.HandlerFunc {
157+
return func(c echo.Context) error {
158+
c.Set("_external_base_url", extBaseURL)
159+
return next(c)
160+
}
161+
})
162+
}
163+
152164
e.Pre(middleware.RemoveTrailingSlash())
153165

154166
if application.ApplicationConfig().MachineTag != "" {

core/http/middleware/baseurl.go

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -55,17 +55,70 @@ func BasePathPrefix(c echo.Context) string {
5555
// The returned URL is guaranteed to end with `/`.
5656
// The method should be used in conjunction with the StripPathPrefix middleware.
5757
func BaseURL(c echo.Context) string {
58+
// An explicit external base URL (LOCALAI_BASE_URL) is authoritative for
59+
// the origin. The proxy-derived path prefix is still appended so a
60+
// reverse-proxy mount point keeps working. Trailing slashes are
61+
// normalized via BasePathPrefix, which always starts and ends with "/".
62+
if ext, ok := c.Get("_external_base_url").(string); ok && ext != "" {
63+
return strings.TrimRight(ext, "/") + BasePathPrefix(c)
64+
}
65+
66+
fwdProto, fwdHost := parseForwarded(c.Request().Header.Get("Forwarded"))
67+
5868
scheme := "http"
59-
if c.Request().Header.Get("X-Forwarded-Proto") == "https" {
69+
switch {
70+
case c.Request().TLS != nil:
71+
scheme = "https"
72+
case strings.EqualFold(firstToken(c.Request().Header.Get("X-Forwarded-Proto")), "https"):
6073
scheme = "https"
61-
} else if c.Request().TLS != nil {
74+
case strings.EqualFold(fwdProto, "https"):
6275
scheme = "https"
6376
}
6477

6578
host := c.Request().Host
6679
if forwardedHost := c.Request().Header.Get("X-Forwarded-Host"); forwardedHost != "" {
6780
host = forwardedHost
81+
} else if fwdHost != "" {
82+
host = fwdHost
6883
}
6984

7085
return scheme + "://" + host + BasePathPrefix(c)
7186
}
87+
88+
// firstToken returns the first comma-separated token of v, trimmed of spaces.
89+
// Reverse-proxy chains can emit X-Forwarded-Proto as "https,http"; only the
90+
// first hop (closest to the client) is meaningful for scheme detection.
91+
func firstToken(v string) string {
92+
if i := strings.IndexByte(v, ','); i >= 0 {
93+
v = v[:i]
94+
}
95+
return strings.TrimSpace(v)
96+
}
97+
98+
// parseForwarded extracts the proto and host directives from the first element
99+
// of an RFC 7239 Forwarded header (e.g. `for=x;proto=https;host=h, for=y`).
100+
// Values may be quoted. Returns empty strings when absent or malformed so the
101+
// caller can fall through to other signals.
102+
func parseForwarded(header string) (proto, host string) {
103+
if header == "" {
104+
return "", ""
105+
}
106+
// Only the first element (closest proxy to the client) matters here.
107+
if i := strings.IndexByte(header, ','); i >= 0 {
108+
header = header[:i]
109+
}
110+
for _, directive := range strings.Split(header, ";") {
111+
key, value, ok := strings.Cut(strings.TrimSpace(directive), "=")
112+
if !ok {
113+
continue
114+
}
115+
value = strings.Trim(strings.TrimSpace(value), `"`)
116+
switch strings.ToLower(strings.TrimSpace(key)) {
117+
case "proto":
118+
proto = value
119+
case "host":
120+
host = value
121+
}
122+
}
123+
return proto, host
124+
}

core/http/middleware/baseurl_test.go

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -135,4 +135,138 @@ var _ = Describe("BaseURL", func() {
135135
Entry("missing leading slash", "evil"),
136136
)
137137
})
138+
139+
Context("scheme detection hardening", func() {
140+
It("treats comma-separated X-Forwarded-Proto as https when first token is https", func() {
141+
app := echo.New()
142+
actualURL := ""
143+
app.GET("/x", func(c echo.Context) error {
144+
actualURL = BaseURL(c)
145+
return nil
146+
})
147+
req := httptest.NewRequest("GET", "/x", nil)
148+
req.Header.Set("X-Forwarded-Proto", "https,http")
149+
rec := httptest.NewRecorder()
150+
app.ServeHTTP(rec, req)
151+
Expect(actualURL).To(Equal("https://example.com/"))
152+
})
153+
154+
It("derives https from the RFC 7239 Forwarded proto directive", func() {
155+
app := echo.New()
156+
actualURL := ""
157+
app.GET("/x", func(c echo.Context) error {
158+
actualURL = BaseURL(c)
159+
return nil
160+
})
161+
req := httptest.NewRequest("GET", "/x", nil)
162+
req.Header.Set("Forwarded", "for=192.0.2.1;proto=https;host=proxy.example")
163+
rec := httptest.NewRecorder()
164+
app.ServeHTTP(rec, req)
165+
Expect(actualURL).To(Equal("https://proxy.example/"))
166+
})
167+
168+
It("prefers X-Forwarded-Host over the Forwarded host directive", func() {
169+
app := echo.New()
170+
actualURL := ""
171+
app.GET("/x", func(c echo.Context) error {
172+
actualURL = BaseURL(c)
173+
return nil
174+
})
175+
req := httptest.NewRequest("GET", "/x", nil)
176+
req.Header.Set("X-Forwarded-Host", "xfh.example")
177+
req.Header.Set("Forwarded", "host=fwd.example;proto=https")
178+
rec := httptest.NewRecorder()
179+
app.ServeHTTP(rec, req)
180+
Expect(actualURL).To(Equal("https://xfh.example/"))
181+
})
182+
})
183+
184+
Context("explicit external base URL override", func() {
185+
It("uses the configured origin over conflicting forwarded headers", func() {
186+
app := echo.New()
187+
actualURL := ""
188+
app.GET("/x", func(c echo.Context) error {
189+
c.Set("_external_base_url", "https://192.168.0.13:34567")
190+
actualURL = BaseURL(c)
191+
return nil
192+
})
193+
req := httptest.NewRequest("GET", "/x", nil)
194+
req.Header.Set("X-Forwarded-Proto", "http")
195+
req.Header.Set("X-Forwarded-Host", "internal:8080")
196+
rec := httptest.NewRecorder()
197+
app.ServeHTTP(rec, req)
198+
Expect(actualURL).To(Equal("https://192.168.0.13:34567/"))
199+
})
200+
201+
It("combines the configured origin with a detected path prefix", func() {
202+
app := echo.New()
203+
actualURL := ""
204+
app.GET("/hello", func(c echo.Context) error {
205+
c.Set("_original_path", "/localai/hello")
206+
c.Set("_external_base_url", "https://ext.example")
207+
actualURL = BaseURL(c)
208+
return nil
209+
})
210+
req := httptest.NewRequest("GET", "/hello", nil)
211+
rec := httptest.NewRecorder()
212+
app.ServeHTTP(rec, req)
213+
Expect(actualURL).To(Equal("https://ext.example/localai/"))
214+
})
215+
216+
It("ignores an empty override", func() {
217+
app := echo.New()
218+
actualURL := ""
219+
app.GET("/x", func(c echo.Context) error {
220+
c.Set("_external_base_url", "")
221+
actualURL = BaseURL(c)
222+
return nil
223+
})
224+
req := httptest.NewRequest("GET", "/x", nil)
225+
rec := httptest.NewRecorder()
226+
app.ServeHTTP(rec, req)
227+
Expect(actualURL).To(Equal("http://example.com/"))
228+
})
229+
})
230+
231+
Context("parseForwarded helper", func() {
232+
It("parses unquoted proto and host", func() {
233+
proto, host := parseForwarded("for=192.0.2.1;proto=https;host=h.example")
234+
Expect(proto).To(Equal("https"))
235+
Expect(host).To(Equal("h.example"))
236+
})
237+
238+
It("strips quotes around values", func() {
239+
proto, host := parseForwarded(`proto="https";host="h.example"`)
240+
Expect(proto).To(Equal("https"))
241+
Expect(host).To(Equal("h.example"))
242+
})
243+
244+
It("uses only the first element of a multi-element header", func() {
245+
proto, host := parseForwarded("proto=https;host=first.example, proto=http;host=second.example")
246+
Expect(proto).To(Equal("https"))
247+
Expect(host).To(Equal("first.example"))
248+
})
249+
250+
It("returns empty strings for an empty header", func() {
251+
proto, host := parseForwarded("")
252+
Expect(proto).To(BeEmpty())
253+
Expect(host).To(BeEmpty())
254+
})
255+
256+
It("skips directives without a value", func() {
257+
proto, host := parseForwarded("proto;host=h.example")
258+
Expect(proto).To(BeEmpty())
259+
Expect(host).To(Equal("h.example"))
260+
})
261+
})
262+
263+
Context("firstToken helper", func() {
264+
It("returns the whole trimmed string when there is no comma", func() {
265+
Expect(firstToken(" https ")).To(Equal("https"))
266+
})
267+
268+
It("returns the first trimmed token when there is a comma", func() {
269+
Expect(firstToken("https , http")).To(Equal("https"))
270+
})
271+
})
138272
})

core/http/routes/auth.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -268,7 +268,7 @@ func RegisterAuthRoutes(e *echo.Echo, app *application.Application) {
268268
// Set up OAuth manager when any OAuth/OIDC provider is configured
269269
if appConfig.Auth.GitHubClientID != "" || appConfig.Auth.OIDCClientID != "" {
270270
oauthMgr, err := auth.NewOAuthManager(
271-
appConfig.Auth.BaseURL,
271+
appConfig.ExternalBaseURL,
272272
auth.OAuthParams{
273273
GitHubClientID: appConfig.Auth.GitHubClientID,
274274
GitHubClientSecret: appConfig.Auth.GitHubClientSecret,

docs/content/advanced/reverse-proxy-tls.md

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,26 @@ When running LocalAI behind a TLS termination reverse proxy, the Web UI may fail
1414

1515
LocalAI uses the `X-Forwarded-Proto` HTTP header to determine the protocol used by clients. When this header is set to `https`, LocalAI will generate HTTPS URLs for static assets in the Web UI.
1616

17+
## Running behind a reverse proxy (HTTPS / subpath)
18+
19+
LocalAI does not terminate TLS itself, so HTTPS is provided by a reverse
20+
proxy in front of it. Self-referential links (generated image and video
21+
URLs, async job status URLs, OAuth callbacks) need the externally visible
22+
scheme, host and port.
23+
24+
LocalAI determines these in this order:
25+
26+
1. `LOCALAI_BASE_URL` - if set, it is authoritative for the origin. Set it to
27+
the externally visible base URL, e.g. `LOCALAI_BASE_URL=https://localai.example.com`
28+
or `https://192.168.0.13:34567`. Recommended whenever links come back with
29+
the wrong scheme or host.
30+
2. Otherwise, the `X-Forwarded-Proto` and `X-Forwarded-Host` headers (or the
31+
RFC 7239 `Forwarded` header) sent by the proxy. Ensure your proxy forwards
32+
`X-Forwarded-Proto: https`.
33+
34+
A reverse-proxy subpath mount is supported via `X-Forwarded-Prefix`; it is
35+
appended to `LOCALAI_BASE_URL` when both are present.
36+
1737
## Required Headers
1838

1939
Your reverse proxy must forward these headers to LocalAI:

0 commit comments

Comments
 (0)