mirror of
https://github.com/mudler/LocalAI.git
synced 2026-06-25 09:09:07 -04:00
* 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>
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"))
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user