diff --git a/core/cli/run.go b/core/cli/run.go index abb0cdbf1..fd7ba8cd9 100644 --- a/core/cli/run.go +++ b/core/cli/run.go @@ -140,7 +140,7 @@ type RunCMD struct { OIDCIssuer string `env:"LOCALAI_OIDC_ISSUER" help:"OIDC issuer URL for auto-discovery" group:"auth"` OIDCClientID string `env:"LOCALAI_OIDC_CLIENT_ID" help:"OIDC Client ID (auto-enables auth)" group:"auth"` OIDCClientSecret string `env:"LOCALAI_OIDC_CLIENT_SECRET" help:"OIDC Client Secret" group:"auth"` - AuthBaseURL string `env:"LOCALAI_BASE_URL" help:"Base URL for OAuth callbacks (e.g. http://localhost:8080)" group:"auth"` + 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"` AuthAdminEmail string `env:"LOCALAI_ADMIN_EMAIL" help:"Email address to auto-promote to admin role" group:"auth"` AuthRegistrationMode string `env:"LOCALAI_REGISTRATION_MODE" default:"open" help:"Registration mode: 'open' (default), 'approval', or 'invite' (invite code required)" group:"auth"` 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 { opts = append(opts, config.WithAuthOIDCClientID(r.OIDCClientID)) opts = append(opts, config.WithAuthOIDCClientSecret(r.OIDCClientSecret)) } - if r.AuthBaseURL != "" { - opts = append(opts, config.WithAuthBaseURL(r.AuthBaseURL)) - } if r.AuthAdminEmail != "" { opts = append(opts, config.WithAuthAdminEmail(r.AuthAdminEmail)) } @@ -523,6 +520,12 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error { } } + // Applied unconditionally: the external base URL governs all self-referential + // links (not just OAuth callbacks), so it must take effect even when auth is off. + if r.ExternalBaseURL != "" { + opts = append(opts, config.WithExternalBaseURL(r.ExternalBaseURL)) + } + if idleWatchDog || busyWatchDog { opts = append(opts, config.EnableWatchDog) if idleWatchDog { diff --git a/core/config/application_config.go b/core/config/application_config.go index 87acd6bd5..1821a8441 100644 --- a/core/config/application_config.go +++ b/core/config/application_config.go @@ -49,6 +49,13 @@ type ApplicationConfig struct { P2PNetworkID string Federated bool + // ExternalBaseURL is the externally visible base URL of this instance + // (scheme+host[:port]), set via LOCALAI_BASE_URL. When non-empty it is + // authoritative for every self-referential URL LocalAI emits (OAuth + // callbacks, generated image/video links, async job StatusURLs), + // overriding proxy-header detection. Empty = derive from request headers. + ExternalBaseURL string + // DisableStats turns off per-request token tracking. By default the // routing module's billing recorder runs in every mode (including // no-auth single-user) so dashboards and `/api/usage` are immediately @@ -196,7 +203,6 @@ type AuthConfig struct { OIDCIssuer string // OIDC issuer URL for auto-discovery (e.g. https://accounts.google.com) OIDCClientID string OIDCClientSecret string - BaseURL string // for OAuth callback URLs (e.g. "http://localhost:8080") AdminEmail string // auto-promote to admin on login RegistrationMode string // "open", "approval" (default when empty), "invite" DisableLocalAuth bool // disable local email/password registration and login @@ -950,9 +956,9 @@ func WithAuthGitHubClientSecret(clientSecret string) AppOption { } } -func WithAuthBaseURL(baseURL string) AppOption { +func WithExternalBaseURL(url string) AppOption { return func(o *ApplicationConfig) { - o.Auth.BaseURL = baseURL + o.ExternalBaseURL = url } } diff --git a/core/http/app.go b/core/http/app.go index 9ec0711fb..ee5cd99eb 100644 --- a/core/http/app.go +++ b/core/http/app.go @@ -149,6 +149,18 @@ func API(application *application.Application) (*echo.Echo, error) { // Middleware - StripPathPrefix must be registered early as it uses Rewrite which runs before routing e.Pre(httpMiddleware.StripPathPrefix()) + // Stamp the configured external base URL into each request context so + // middleware.BaseURL can treat it as authoritative for self-referential + // links. Registered as Pre so it runs before routing and handlers. + if extBaseURL := application.ApplicationConfig().ExternalBaseURL; extBaseURL != "" { + e.Pre(func(next echo.HandlerFunc) echo.HandlerFunc { + return func(c echo.Context) error { + c.Set("_external_base_url", extBaseURL) + return next(c) + } + }) + } + e.Pre(middleware.RemoveTrailingSlash()) if application.ApplicationConfig().MachineTag != "" { diff --git a/core/http/middleware/baseurl.go b/core/http/middleware/baseurl.go index a1e1844ae..84f72cf69 100644 --- a/core/http/middleware/baseurl.go +++ b/core/http/middleware/baseurl.go @@ -55,17 +55,70 @@ 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 { + // An explicit external base URL (LOCALAI_BASE_URL) is authoritative for + // the origin. The proxy-derived path prefix is still appended so a + // reverse-proxy mount point keeps working. Trailing slashes are + // normalized via BasePathPrefix, which always starts and ends with "/". + if ext, ok := c.Get("_external_base_url").(string); ok && ext != "" { + return strings.TrimRight(ext, "/") + BasePathPrefix(c) + } + + 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..6a132514b 100644 --- a/core/http/middleware/baseurl_test.go +++ b/core/http/middleware/baseurl_test.go @@ -135,4 +135,138 @@ 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/")) + }) + }) + + Context("explicit external base URL override", func() { + It("uses the configured origin over conflicting forwarded headers", func() { + app := echo.New() + actualURL := "" + app.GET("/x", func(c echo.Context) error { + c.Set("_external_base_url", "https://192.168.0.13:34567") + actualURL = BaseURL(c) + return nil + }) + req := httptest.NewRequest("GET", "/x", nil) + req.Header.Set("X-Forwarded-Proto", "http") + req.Header.Set("X-Forwarded-Host", "internal:8080") + rec := httptest.NewRecorder() + app.ServeHTTP(rec, req) + Expect(actualURL).To(Equal("https://192.168.0.13:34567/")) + }) + + It("combines the configured origin with a detected path prefix", func() { + app := echo.New() + actualURL := "" + app.GET("/hello", func(c echo.Context) error { + c.Set("_original_path", "/localai/hello") + c.Set("_external_base_url", "https://ext.example") + actualURL = BaseURL(c) + return nil + }) + req := httptest.NewRequest("GET", "/hello", nil) + rec := httptest.NewRecorder() + app.ServeHTTP(rec, req) + Expect(actualURL).To(Equal("https://ext.example/localai/")) + }) + + It("ignores an empty override", func() { + app := echo.New() + actualURL := "" + app.GET("/x", func(c echo.Context) error { + c.Set("_external_base_url", "") + actualURL = BaseURL(c) + return nil + }) + req := httptest.NewRequest("GET", "/x", nil) + rec := httptest.NewRecorder() + app.ServeHTTP(rec, req) + Expect(actualURL).To(Equal("http://example.com/")) + }) + }) + + Context("parseForwarded helper", func() { + It("parses unquoted proto and host", func() { + proto, host := parseForwarded("for=192.0.2.1;proto=https;host=h.example") + Expect(proto).To(Equal("https")) + Expect(host).To(Equal("h.example")) + }) + + It("strips quotes around values", func() { + proto, host := parseForwarded(`proto="https";host="h.example"`) + Expect(proto).To(Equal("https")) + Expect(host).To(Equal("h.example")) + }) + + It("uses only the first element of a multi-element header", func() { + proto, host := parseForwarded("proto=https;host=first.example, proto=http;host=second.example") + Expect(proto).To(Equal("https")) + Expect(host).To(Equal("first.example")) + }) + + It("returns empty strings for an empty header", func() { + proto, host := parseForwarded("") + Expect(proto).To(BeEmpty()) + Expect(host).To(BeEmpty()) + }) + + It("skips directives without a value", func() { + proto, host := parseForwarded("proto;host=h.example") + Expect(proto).To(BeEmpty()) + Expect(host).To(Equal("h.example")) + }) + }) + + Context("firstToken helper", func() { + It("returns the whole trimmed string when there is no comma", func() { + Expect(firstToken(" https ")).To(Equal("https")) + }) + + It("returns the first trimmed token when there is a comma", func() { + Expect(firstToken("https , http")).To(Equal("https")) + }) + }) }) diff --git a/core/http/routes/auth.go b/core/http/routes/auth.go index ef8372fff..b4144e0a1 100644 --- a/core/http/routes/auth.go +++ b/core/http/routes/auth.go @@ -268,7 +268,7 @@ func RegisterAuthRoutes(e *echo.Echo, app *application.Application) { // Set up OAuth manager when any OAuth/OIDC provider is configured if appConfig.Auth.GitHubClientID != "" || appConfig.Auth.OIDCClientID != "" { oauthMgr, err := auth.NewOAuthManager( - appConfig.Auth.BaseURL, + appConfig.ExternalBaseURL, auth.OAuthParams{ GitHubClientID: appConfig.Auth.GitHubClientID, GitHubClientSecret: appConfig.Auth.GitHubClientSecret, diff --git a/docs/content/advanced/reverse-proxy-tls.md b/docs/content/advanced/reverse-proxy-tls.md index 24af55c62..d36a64ae4 100644 --- a/docs/content/advanced/reverse-proxy-tls.md +++ b/docs/content/advanced/reverse-proxy-tls.md @@ -14,6 +14,26 @@ When running LocalAI behind a TLS termination reverse proxy, the Web UI may fail 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. +## Running behind a reverse proxy (HTTPS / subpath) + +LocalAI does not terminate TLS itself, so HTTPS is provided by a reverse +proxy in front of it. Self-referential links (generated image and video +URLs, async job status URLs, OAuth callbacks) need the externally visible +scheme, host and port. + +LocalAI determines these in this order: + +1. `LOCALAI_BASE_URL` - if set, it is authoritative for the origin. Set it to + the externally visible base URL, e.g. `LOCALAI_BASE_URL=https://localai.example.com` + or `https://192.168.0.13:34567`. Recommended whenever links come back with + the wrong scheme or host. +2. Otherwise, the `X-Forwarded-Proto` and `X-Forwarded-Host` headers (or the + RFC 7239 `Forwarded` header) sent by the proxy. Ensure your proxy forwards + `X-Forwarded-Proto: https`. + +A reverse-proxy subpath mount is supported via `X-Forwarded-Prefix`; it is +appended to `LOCALAI_BASE_URL` when both are present. + ## Required Headers Your reverse proxy must forward these headers to LocalAI: