Files
LocalAI/core/http/auth/oauth.go
Richard Palethorpe 12d1f3a697 security(http): refuse redirects on outbound clients via hardened pkg/httpclient (#10087)
LocalAI's outbound HTTP clients used Go's default redirect policy, which
follows up to 10 redirects. On a cross-host redirect Go forwards custom
request headers — including credential headers such as Anthropic's
x-api-key — to the redirect target (Go strips Authorization, Cookie and
WWW-Authenticate cross-host, but NOT arbitrary custom headers). An
attacker able to elicit a redirect from an upstream (a hijacked or
spoofed upstream, DNS trickery, or a malicious upstream_url) then
harvests the operator's provider API key.

This was first reported against the cloud-proxy / MITM PII path
(GHSA-3mj3-57v2-4636); the same class affects every other outbound
client. Rather than patch each call site, add pkg/httpclient as the one
sanctioned constructor for outbound HTTP and route everything through it.

pkg/httpclient:
  - New(...)             refuses redirects, TLS 1.2 floor, no body
                         deadline (streaming/SSE safe)
  - NewWithTimeout(d)    simple request/response calls
  - WithFollowRedirects  opt-in following that still strips credential
                         headers on any cross-host hop; different
                         scheme/host/port == different origin, guarding
                         the curl CVE-2022-27774 port-confusion class
  - WithTransport(rt)    keep a custom transport (IP-pin, HTTP/2, a
                         credential-injecting RoundTripper)
  - HardenedTransport()  base transport with the TLS floor + bounded setup
  - Harden(c)            apply the policy to a library-supplied *http.Client
  - NoRedirect           the CheckRedirect policy; wraps ErrRedirectBlocked

Lint: a forbidigo rule flags http.DefaultClient and http.Get/Post/
PostForm/Head, pointing at pkg/httpclient (.golangci.yml,
.agents/coding-style.md). forbidigo cannot match the &http.Client{}
composite literal without also flagging legitimate *http.Client type
references, so that form is enforced by review.

Migrates every non-test outbound call site across core/, pkg/, cmd/, and
the Go backend (backend/go/cloud-proxy). Credential-bearing and
internal-RPC clients refuse redirects; download / CDN / registry clients
use WithFollowRedirects so they keep working while stripping secrets
cross-host. The only credential-bearing client that follows redirects is
the gated-download path (pkg/downloader/uri.go), which strips the token
on the cross-host hop to the CDN. Hardening this closes, in passing:
  - MCP remote-server bearer token leaking via a redirect (the
    RoundTripper re-injected Authorization on every hop)
  - agent multimedia/webhook clients leaking user-supplied auth headers
  - cors_proxy following redirects, bypassing its SSRF IP-pin
  - downloader's authorized read path leaking the token cross-host

Fixes: GHSA-3mj3-57v2-4636 (cloud-proxy leaks operator provider API key
(x-api-key) to attacker host on cross-host redirect)
Reported-by: tonghuaroot
Assisted-by: Claude:claude-opus-4-8 [Claude Code]

Signed-off-by: Richard Palethorpe <io@richiejp.com>
2026-05-30 12:04:10 +02:00

470 lines
14 KiB
Go

package auth
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/coreos/go-oidc/v3/oidc"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"github.com/mudler/xlog"
"golang.org/x/oauth2"
githubOAuth "golang.org/x/oauth2/github"
"gorm.io/gorm"
"github.com/mudler/LocalAI/pkg/httpclient"
)
// providerEntry holds the OAuth2/OIDC config for a single provider.
type providerEntry struct {
oauth2Config oauth2.Config
oidcVerifier *oidc.IDTokenVerifier // nil for GitHub (API-based user info)
name string
userInfoURL string // only used for GitHub
}
// oauthUserInfo is a provider-agnostic representation of an authenticated user.
// EmailVerified MUST reflect upstream verification: AssignRole compares Email
// against the configured admin email, so an unverified claim of a privileged
// address must not be honoured. Callers that cannot prove verification set
// EmailVerified=false.
type oauthUserInfo struct {
Subject string
Email string
EmailVerified bool
Name string
AvatarURL string
}
// OAuthManager manages multiple OAuth/OIDC providers.
type OAuthManager struct {
providers map[string]*providerEntry
}
// OAuthParams groups the parameters needed to create an OAuthManager.
type OAuthParams struct {
GitHubClientID string
GitHubClientSecret string
OIDCIssuer string
OIDCClientID string
OIDCClientSecret string
}
// NewOAuthManager creates an OAuthManager from the given params.
func NewOAuthManager(baseURL string, params OAuthParams) (*OAuthManager, error) {
m := &OAuthManager{providers: make(map[string]*providerEntry)}
if params.GitHubClientID != "" {
m.providers[ProviderGitHub] = &providerEntry{
name: ProviderGitHub,
oauth2Config: oauth2.Config{
ClientID: params.GitHubClientID,
ClientSecret: params.GitHubClientSecret,
Endpoint: githubOAuth.Endpoint,
RedirectURL: baseURL + "/api/auth/github/callback",
Scopes: []string{"user:email", "read:user"},
},
userInfoURL: "https://api.github.com/user",
}
}
if params.OIDCClientID != "" && params.OIDCIssuer != "" {
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
provider, err := oidc.NewProvider(ctx, params.OIDCIssuer)
if err != nil {
return nil, fmt.Errorf("OIDC discovery failed for %s: %w", params.OIDCIssuer, err)
}
verifier := provider.Verifier(&oidc.Config{ClientID: params.OIDCClientID})
m.providers[ProviderOIDC] = &providerEntry{
name: ProviderOIDC,
oauth2Config: oauth2.Config{
ClientID: params.OIDCClientID,
ClientSecret: params.OIDCClientSecret,
Endpoint: provider.Endpoint(),
RedirectURL: baseURL + "/api/auth/oidc/callback",
Scopes: []string{oidc.ScopeOpenID, "profile", "email"},
},
oidcVerifier: verifier,
}
}
return m, nil
}
// Providers returns the list of configured provider names.
func (m *OAuthManager) Providers() []string {
names := make([]string, 0, len(m.providers))
for name := range m.providers {
names = append(names, name)
}
return names
}
// LoginHandler redirects the user to the OAuth provider's login page.
func (m *OAuthManager) LoginHandler(providerName string) echo.HandlerFunc {
return func(c echo.Context) error {
provider, ok := m.providers[providerName]
if !ok {
return c.JSON(http.StatusNotFound, map[string]string{"error": "unknown provider"})
}
state, err := generateState()
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to generate state"})
}
secure := isSecure(c)
c.SetCookie(&http.Cookie{
Name: "oauth_state",
Value: state,
Path: "/",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
MaxAge: 600, // 10 minutes
})
// Store invite code in cookie if provided
if inviteCode := c.QueryParam("invite_code"); inviteCode != "" {
c.SetCookie(&http.Cookie{
Name: "invite_code",
Value: inviteCode,
Path: "/",
HttpOnly: true,
Secure: secure,
SameSite: http.SameSiteLaxMode,
MaxAge: 600,
})
}
url := provider.oauth2Config.AuthCodeURL(state)
return c.Redirect(http.StatusTemporaryRedirect, url)
}
}
// CallbackHandler handles the OAuth callback, creates/updates the user, and
// creates a session.
func (m *OAuthManager) CallbackHandler(providerName string, db *gorm.DB, adminEmail, registrationMode, hmacSecret string) echo.HandlerFunc {
return func(c echo.Context) error {
provider, ok := m.providers[providerName]
if !ok {
return c.JSON(http.StatusNotFound, map[string]string{"error": "unknown provider"})
}
// Validate state
stateCookie, err := c.Cookie("oauth_state")
if err != nil || stateCookie.Value == "" || subtle.ConstantTimeCompare([]byte(stateCookie.Value), []byte(c.QueryParam("state"))) != 1 {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "invalid OAuth state"})
}
// Clear state cookie
c.SetCookie(&http.Cookie{
Name: "oauth_state",
Value: "",
Path: "/",
HttpOnly: true,
Secure: isSecure(c),
MaxAge: -1,
})
// Exchange code for token
code := c.QueryParam("code")
if code == "" {
return c.JSON(http.StatusBadRequest, map[string]string{"error": "missing authorization code"})
}
ctx, cancel := context.WithTimeout(c.Request().Context(), 30*time.Second)
defer cancel()
token, err := provider.oauth2Config.Exchange(ctx, code)
if err != nil {
xlog.Error("OAuth code exchange failed", "provider", providerName, "error", err)
return c.JSON(http.StatusBadRequest, map[string]string{"error": "OAuth authentication failed"})
}
// Fetch user info — branch based on provider type
var userInfo *oauthUserInfo
if provider.oidcVerifier != nil {
userInfo, err = extractOIDCUserInfo(ctx, provider.oidcVerifier, token)
} else {
userInfo, err = fetchGitHubUserInfoAsOAuth(ctx, token.AccessToken)
}
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to fetch user info"})
}
// Retrieve invite code from cookie if present
var inviteCode string
if ic, err := c.Cookie("invite_code"); err == nil && ic.Value != "" {
inviteCode = ic.Value
// Clear the invite code cookie
c.SetCookie(&http.Cookie{
Name: "invite_code",
Value: "",
Path: "/",
HttpOnly: true,
Secure: isSecure(c),
MaxAge: -1,
})
}
// Check if user already exists
var existingUser User
userExists := db.Where("provider = ? AND subject = ?", providerName, userInfo.Subject).First(&existingUser).Error == nil
var user *User
if userExists {
// Existing user — update profile fields, no invite gating needed
existingUser.Name = userInfo.Name
existingUser.AvatarURL = userInfo.AvatarURL
if userInfo.Email != "" {
existingUser.Email = strings.ToLower(strings.TrimSpace(userInfo.Email))
}
db.Save(&existingUser)
user = &existingUser
} else {
// New user — validate invite BEFORE creating, inside a transaction
var validInvite *InviteCode
txErr := db.Transaction(func(tx *gorm.DB) error {
email := ""
if userInfo.Email != "" {
email = strings.ToLower(strings.TrimSpace(userInfo.Email))
}
// roleEmail is what AssignRole and NeedsInviteOrApproval
// use to short-circuit on admin-email matches. Pass the
// unverified-email-substituted form so an IdP-supplied
// copy of LOCALAI_ADMIN_EMAIL doesn't bypass either gate.
roleEmail := emailForRoleDecision(email, userInfo.EmailVerified)
role := AssignRole(tx, roleEmail, adminEmail)
status := StatusActive
if NeedsInviteOrApproval(tx, roleEmail, adminEmail, registrationMode) {
if registrationMode == "invite" {
if inviteCode == "" {
return fmt.Errorf("invite_required")
}
invite, err := ValidateInvite(tx, inviteCode, hmacSecret)
if err != nil {
return fmt.Errorf("invalid_invite")
}
validInvite = invite
status = StatusActive
} else {
// approval mode — create as pending
status = StatusPending
}
}
newUser := User{
ID: uuid.New().String(),
Email: email,
Name: userInfo.Name,
AvatarURL: userInfo.AvatarURL,
Provider: providerName,
Subject: userInfo.Subject,
Role: role,
Status: status,
}
if err := tx.Create(&newUser).Error; err != nil {
return fmt.Errorf("failed to create user: %w", err)
}
if validInvite != nil {
ConsumeInvite(tx, validInvite, newUser.ID)
}
user = &newUser
return nil
})
if txErr != nil {
msg := txErr.Error()
if msg == "invite_required" {
return c.Redirect(http.StatusTemporaryRedirect, "/login?error=invite_required")
}
if msg == "invalid_invite" {
return c.Redirect(http.StatusTemporaryRedirect, "/login?error=invalid_invite")
}
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create user"})
}
}
if user.Status != StatusActive {
return c.JSON(http.StatusForbidden, map[string]string{"error": "account pending approval"})
}
// Same gate as roleEmail above: only verified emails can flip an
// existing user to admin via the LOCALAI_ADMIN_EMAIL match.
if userInfo.EmailVerified {
MaybePromote(db, user, adminEmail)
}
// Create session
sessionID, err := CreateSession(db, user.ID, hmacSecret)
if err != nil {
return c.JSON(http.StatusInternalServerError, map[string]string{"error": "failed to create session"})
}
SetSessionCookie(c, sessionID)
return c.Redirect(http.StatusTemporaryRedirect, "/app")
}
}
// extractOIDCUserInfo extracts user info from the OIDC ID token.
func extractOIDCUserInfo(ctx context.Context, verifier *oidc.IDTokenVerifier, token *oauth2.Token) (*oauthUserInfo, error) {
rawIDToken, ok := token.Extra("id_token").(string)
if !ok || rawIDToken == "" {
return nil, fmt.Errorf("no id_token in token response")
}
idToken, err := verifier.Verify(ctx, rawIDToken)
if err != nil {
return nil, fmt.Errorf("failed to verify ID token: %w", err)
}
var claims struct {
Sub string `json:"sub"`
Email string `json:"email"`
EmailVerified *bool `json:"email_verified"`
Name string `json:"name"`
Picture string `json:"picture"`
}
if err := idToken.Claims(&claims); err != nil {
return nil, fmt.Errorf("failed to parse ID token claims: %w", err)
}
// Default to false on absence: an IdP that doesn't issue the claim is
// not asserting verification, and we must not promote on its email.
verified := claims.EmailVerified != nil && *claims.EmailVerified
return &oauthUserInfo{
Subject: claims.Sub,
Email: claims.Email,
EmailVerified: verified,
Name: claims.Name,
AvatarURL: claims.Picture,
}, nil
}
type githubUserInfo struct {
ID int `json:"id"`
Login string `json:"login"`
Name string `json:"name"`
Email string `json:"email"`
AvatarURL string `json:"avatar_url"`
}
type githubEmail struct {
Email string `json:"email"`
Primary bool `json:"primary"`
Verified bool `json:"verified"`
}
// fetchGitHubUserInfoAsOAuth fetches GitHub user info and returns it as oauthUserInfo.
// GitHub only surfaces verified emails (public profile email and the
// /user/emails Verified=true filter), so a non-empty email is always verified.
func fetchGitHubUserInfoAsOAuth(ctx context.Context, accessToken string) (*oauthUserInfo, error) {
info, err := fetchGitHubUserInfo(ctx, accessToken)
if err != nil {
return nil, err
}
return &oauthUserInfo{
Subject: fmt.Sprintf("%d", info.ID),
Email: info.Email,
EmailVerified: info.Email != "",
Name: info.Name,
AvatarURL: info.AvatarURL,
}, nil
}
func fetchGitHubUserInfo(ctx context.Context, accessToken string) (*githubUserInfo, error) {
client := httpclient.NewWithTimeout(10 * time.Second)
req, _ := http.NewRequestWithContext(ctx, "GET", "https://api.github.com/user", nil)
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var info githubUserInfo
if err := json.Unmarshal(body, &info); err != nil {
return nil, err
}
// If no public email, fetch from /user/emails
if info.Email == "" {
info.Email, _ = fetchGitHubPrimaryEmail(ctx, accessToken)
}
return &info, nil
}
func fetchGitHubPrimaryEmail(ctx context.Context, accessToken string) (string, error) {
client := httpclient.NewWithTimeout(10 * time.Second)
req, _ := http.NewRequestWithContext(ctx, "GET", "https://api.github.com/user/emails", nil)
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
var emails []githubEmail
if err := json.Unmarshal(body, &emails); err != nil {
return "", err
}
for _, e := range emails {
if e.Primary && e.Verified {
return e.Email, nil
}
}
// Fall back to first verified email
for _, e := range emails {
if e.Verified {
return e.Email, nil
}
}
return "", fmt.Errorf("no verified email found")
}
func generateState() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}