mirror of
https://github.com/mudler/LocalAI.git
synced 2026-06-03 22:07:58 -04:00
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>
470 lines
14 KiB
Go
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
|
|
}
|