Skip to content

Commit 742d0bf

Browse files
committed
security hardening, approval mode
Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
1 parent 340025b commit 742d0bf

29 files changed

Lines changed: 1635 additions & 583 deletions

core/application/config_file_watcher.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -207,7 +207,7 @@ func readRuntimeSettingsJson(startupAppConfig config.ApplicationConfig) fileHand
207207
envF16 := appConfig.F16 == startupAppConfig.F16
208208
envDebug := appConfig.Debug == startupAppConfig.Debug
209209
envCORS := appConfig.CORS == startupAppConfig.CORS
210-
envCSRF := appConfig.CSRF == startupAppConfig.CSRF
210+
envCSRF := appConfig.DisableCSRF == startupAppConfig.DisableCSRF
211211
envCORSAllowOrigins := appConfig.CORSAllowOrigins == startupAppConfig.CORSAllowOrigins
212212
envP2PToken := appConfig.P2PToken == startupAppConfig.P2PToken
213213
envP2PNetworkID := appConfig.P2PNetworkID == startupAppConfig.P2PNetworkID
@@ -313,7 +313,7 @@ func readRuntimeSettingsJson(startupAppConfig config.ApplicationConfig) fileHand
313313
appConfig.CORS = *settings.CORS
314314
}
315315
if settings.CSRF != nil && !envCSRF {
316-
appConfig.CSRF = *settings.CSRF
316+
appConfig.DisableCSRF = *settings.CSRF
317317
}
318318
if settings.CORSAllowOrigins != nil && !envCORSAllowOrigins {
319319
appConfig.CORSAllowOrigins = *settings.CORSAllowOrigins

core/application/startup.go

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package application
22

33
import (
4+
"crypto/rand"
5+
"encoding/hex"
46
"encoding/json"
57
"fmt"
68
"os"
@@ -84,14 +86,24 @@ func New(opts ...config.AppOption) (*Application, error) {
8486

8587
// Initialize auth database if auth is enabled
8688
if options.Auth.Enabled {
89+
// Auto-generate HMAC secret if not provided
90+
if options.Auth.APIKeyHMACSecret == "" {
91+
secretFile := filepath.Join(options.DataPath, ".hmac_secret")
92+
secret, err := loadOrGenerateHMACSecret(secretFile)
93+
if err != nil {
94+
return nil, fmt.Errorf("failed to initialize HMAC secret: %w", err)
95+
}
96+
options.Auth.APIKeyHMACSecret = secret
97+
}
98+
8799
authDB, err := auth.InitDB(options.Auth.DatabaseURL)
88100
if err != nil {
89101
return nil, fmt.Errorf("failed to initialize auth database: %w", err)
90102
}
91103
application.authDB = authDB
92104
xlog.Info("Auth enabled", "database", options.Auth.DatabaseURL)
93105

94-
// Start session cleanup goroutine
106+
// Start session and expired API key cleanup goroutine
95107
go func() {
96108
ticker := time.NewTicker(1 * time.Hour)
97109
defer ticker.Stop()
@@ -103,6 +115,9 @@ func New(opts ...config.AppOption) (*Application, error) {
103115
if err := auth.CleanExpiredSessions(authDB); err != nil {
104116
xlog.Error("failed to clean expired sessions", "error", err)
105117
}
118+
if err := auth.CleanExpiredAPIKeys(authDB); err != nil {
119+
xlog.Error("failed to clean expired API keys", "error", err)
120+
}
106121
}
107122
}
108123
}()
@@ -461,6 +476,31 @@ func initializeWatchdog(application *Application, options *config.ApplicationCon
461476
}
462477
}
463478

479+
// loadOrGenerateHMACSecret loads an HMAC secret from the given file path,
480+
// or generates a random 32-byte secret and persists it if the file doesn't exist.
481+
func loadOrGenerateHMACSecret(path string) (string, error) {
482+
data, err := os.ReadFile(path)
483+
if err == nil {
484+
secret := string(data)
485+
if len(secret) >= 32 {
486+
return secret, nil
487+
}
488+
}
489+
490+
b := make([]byte, 32)
491+
if _, err := rand.Read(b); err != nil {
492+
return "", fmt.Errorf("failed to generate HMAC secret: %w", err)
493+
}
494+
secret := hex.EncodeToString(b)
495+
496+
if err := os.WriteFile(path, []byte(secret), 0600); err != nil {
497+
return "", fmt.Errorf("failed to persist HMAC secret: %w", err)
498+
}
499+
500+
xlog.Info("Generated new HMAC secret for API key hashing", "path", path)
501+
return secret, nil
502+
}
503+
464504
// migrateDataFiles moves persistent data files from the old config directory
465505
// to the new data directory. Only moves files that exist in src but not in dst.
466506
func migrateDataFiles(srcDir, dstDir string) {

core/cli/run.go

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ type RunCMD struct {
5858
Address string `env:"LOCALAI_ADDRESS,ADDRESS" default:":8080" help:"Bind address for the API server" group:"api"`
5959
CORS bool `env:"LOCALAI_CORS,CORS" help:"" group:"api"`
6060
CORSAllowOrigins string `env:"LOCALAI_CORS_ALLOW_ORIGINS,CORS_ALLOW_ORIGINS" group:"api"`
61-
CSRF bool `env:"LOCALAI_CSRF" help:"Enables fiber CSRF middleware" group:"api"`
61+
DisableCSRF bool `env:"LOCALAI_DISABLE_CSRF" help:"Disable CSRF middleware (enabled by default)" group:"api"`
6262
UploadLimit int `env:"LOCALAI_UPLOAD_LIMIT,UPLOAD_LIMIT" default:"15" help:"Default upload-limit in MB" group:"api"`
6363
APIKeys []string `env:"LOCALAI_API_KEY,API_KEY" help:"List of API Keys to enable API authentication. When this is set, all the requests must be authenticated with one of these API keys" group:"api"`
6464
DisableWebUI bool `env:"LOCALAI_DISABLE_WEBUI,DISABLE_WEBUI" default:"false" help:"Disables the web user interface. When set to true, the server will only expose API endpoints without serving the web interface" group:"api"`
@@ -131,8 +131,10 @@ type RunCMD struct {
131131
OIDCClientSecret string `env:"LOCALAI_OIDC_CLIENT_SECRET" help:"OIDC Client Secret" group:"auth"`
132132
AuthBaseURL string `env:"LOCALAI_BASE_URL" help:"Base URL for OAuth callbacks (e.g. http://localhost:8080)" group:"auth"`
133133
AuthAdminEmail string `env:"LOCALAI_ADMIN_EMAIL" help:"Email address to auto-promote to admin role" group:"auth"`
134-
AuthRegistrationMode string `env:"LOCALAI_REGISTRATION_MODE" default:"open" help:"Registration mode: 'open' (default) or 'approval'" group:"auth"`
134+
AuthRegistrationMode string `env:"LOCALAI_REGISTRATION_MODE" default:"open" help:"Registration mode: 'open' (default), 'approval', or 'invite' (invite code required)" group:"auth"`
135135
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"`
136+
AuthAPIKeyHMACSecret string `env:"LOCALAI_AUTH_HMAC_SECRET" help:"HMAC secret for API key hashing (auto-generated if empty)" group:"auth"`
137+
DefaultAPIKeyExpiry string `env:"LOCALAI_DEFAULT_API_KEY_EXPIRY" help:"Default expiry for API keys (e.g. 90d, 1y; empty = no expiry)" group:"auth"`
136138

137139
Version bool
138140
}
@@ -178,7 +180,7 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error {
178180
config.WithBackendGalleries(r.BackendGalleries),
179181
config.WithCors(r.CORS),
180182
config.WithCorsAllowOrigins(r.CORSAllowOrigins),
181-
config.WithCsrf(r.CSRF),
183+
config.WithDisableCSRF(r.DisableCSRF),
182184
config.WithThreads(r.Threads),
183185
config.WithUploadLimitMB(r.UploadLimit),
184186
config.WithApiKeys(r.APIKeys),
@@ -356,6 +358,12 @@ func (r *RunCMD) Run(ctx *cliContext.Context) error {
356358
if r.DisableLocalAuth {
357359
opts = append(opts, config.WithAuthDisableLocalAuth(true))
358360
}
361+
if r.AuthAPIKeyHMACSecret != "" {
362+
opts = append(opts, config.WithAuthAPIKeyHMACSecret(r.AuthAPIKeyHMACSecret))
363+
}
364+
if r.DefaultAPIKeyExpiry != "" {
365+
opts = append(opts, config.WithAuthDefaultAPIKeyExpiry(r.DefaultAPIKeyExpiry))
366+
}
359367
}
360368

361369
if idleWatchDog || busyWatchDog {

core/config/application_config.go

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ type ApplicationConfig struct {
3030
DynamicConfigsDir string
3131
DynamicConfigsDirPollInterval time.Duration
3232
CORS bool
33-
CSRF bool
33+
DisableCSRF bool
3434
PreloadJSONModels string
3535
PreloadModelsFromPath string
3636
CORSAllowOrigins string
@@ -112,8 +112,10 @@ type AuthConfig struct {
112112
OIDCClientSecret string
113113
BaseURL string // for OAuth callback URLs (e.g. "http://localhost:8080")
114114
AdminEmail string // auto-promote to admin on login
115-
RegistrationMode string // "open" (default), "approval", "invite"
115+
RegistrationMode string // "open", "approval" (default when empty), "invite"
116116
DisableLocalAuth bool // disable local email/password registration and login
117+
APIKeyHMACSecret string // HMAC secret for API key hashing; auto-generated if empty
118+
DefaultAPIKeyExpiry string // default expiry duration for API keys (e.g. "90d"); empty = no expiry
117119
}
118120

119121
// AgentPoolConfig holds configuration for the LocalAGI agent pool integration.
@@ -214,9 +216,9 @@ func WithP2PNetworkID(s string) AppOption {
214216
}
215217
}
216218

217-
func WithCsrf(b bool) AppOption {
219+
func WithDisableCSRF(b bool) AppOption {
218220
return func(o *ApplicationConfig) {
219-
o.CSRF = b
221+
o.DisableCSRF = b
220222
}
221223
}
222224

@@ -799,6 +801,18 @@ func WithAuthOIDCClientSecret(clientSecret string) AppOption {
799801
}
800802
}
801803

804+
func WithAuthAPIKeyHMACSecret(secret string) AppOption {
805+
return func(o *ApplicationConfig) {
806+
o.Auth.APIKeyHMACSecret = secret
807+
}
808+
}
809+
810+
func WithAuthDefaultAPIKeyExpiry(expiry string) AppOption {
811+
return func(o *ApplicationConfig) {
812+
o.Auth.DefaultAPIKeyExpiry = expiry
813+
}
814+
}
815+
802816
// ToConfigLoaderOptions returns a slice of ConfigLoader Option.
803817
// Some options defined at the application level are going to be passed as defaults for
804818
// all the configuration for the models.
@@ -838,7 +852,7 @@ func (o *ApplicationConfig) ToRuntimeSettings() RuntimeSettings {
838852
enableTracing := o.EnableTracing
839853
enableBackendLogging := o.EnableBackendLogging
840854
cors := o.CORS
841-
csrf := o.CSRF
855+
csrf := o.DisableCSRF
842856
corsAllowOrigins := o.CORSAllowOrigins
843857
p2pToken := o.P2PToken
844858
p2pNetworkID := o.P2PNetworkID
@@ -1046,7 +1060,7 @@ func (o *ApplicationConfig) ApplyRuntimeSettings(settings *RuntimeSettings) (req
10461060
o.CORS = *settings.CORS
10471061
}
10481062
if settings.CSRF != nil {
1049-
o.CSRF = *settings.CSRF
1063+
o.DisableCSRF = *settings.CSRF
10501064
}
10511065
if settings.CORSAllowOrigins != nil {
10521066
o.CORSAllowOrigins = *settings.CORSAllowOrigins

core/config/application_config_test.go

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ var _ = Describe("ApplicationConfig RuntimeSettings Conversion", func() {
2626
F16: true,
2727
Debug: true,
2828
CORS: true,
29-
CSRF: true,
29+
DisableCSRF: true,
3030
CORSAllowOrigins: "https://example.com",
3131
P2PToken: "test-token",
3232
P2PNetworkID: "test-network",
@@ -377,7 +377,7 @@ var _ = Describe("ApplicationConfig RuntimeSettings Conversion", func() {
377377
appConfig.ApplyRuntimeSettings(rs)
378378

379379
Expect(appConfig.CORS).To(BeTrue())
380-
Expect(appConfig.CSRF).To(BeTrue())
380+
Expect(appConfig.DisableCSRF).To(BeTrue())
381381
Expect(appConfig.CORSAllowOrigins).To(Equal("https://example.com,https://other.com"))
382382
})
383383

@@ -463,7 +463,7 @@ var _ = Describe("ApplicationConfig RuntimeSettings Conversion", func() {
463463
F16: true,
464464
Debug: false,
465465
CORS: true,
466-
CSRF: false,
466+
DisableCSRF: false,
467467
CORSAllowOrigins: "https://test.com",
468468
P2PToken: "round-trip-token",
469469
P2PNetworkID: "round-trip-network",
@@ -495,7 +495,7 @@ var _ = Describe("ApplicationConfig RuntimeSettings Conversion", func() {
495495
Expect(target.F16).To(Equal(original.F16))
496496
Expect(target.Debug).To(Equal(original.Debug))
497497
Expect(target.CORS).To(Equal(original.CORS))
498-
Expect(target.CSRF).To(Equal(original.CSRF))
498+
Expect(target.DisableCSRF).To(Equal(original.DisableCSRF))
499499
Expect(target.CORSAllowOrigins).To(Equal(original.CORSAllowOrigins))
500500
Expect(target.P2PToken).To(Equal(original.P2PToken))
501501
Expect(target.P2PNetworkID).To(Equal(original.P2PNetworkID))

core/http/app.go

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -234,10 +234,42 @@ func API(application *application.Application) (*echo.Echo, error) {
234234
e.Use(middleware.CORS())
235235
}
236236

237-
// CSRF middleware
238-
if application.ApplicationConfig().CSRF {
239-
xlog.Debug("Enabling CSRF middleware. Tokens are now required for state-modifying requests")
240-
e.Use(middleware.CSRF())
237+
// CSRF middleware (enabled by default, disable with LOCALAI_DISABLE_CSRF=true)
238+
//
239+
// Protection relies on Echo's Sec-Fetch-Site header check (supported by all
240+
// modern browsers). The legacy cookie+token approach is removed because
241+
// Echo's Sec-Fetch-Site short-circuit never sets the cookie, so the frontend
242+
// could never read a token to send back.
243+
if !application.ApplicationConfig().DisableCSRF {
244+
xlog.Debug("Enabling CSRF middleware (Sec-Fetch-Site mode)")
245+
e.Use(middleware.CSRFWithConfig(middleware.CSRFConfig{
246+
Skipper: func(c echo.Context) bool {
247+
// Skip CSRF for API clients using auth headers (may be cross-origin)
248+
if c.Request().Header.Get("Authorization") != "" {
249+
return true
250+
}
251+
if c.Request().Header.Get("x-api-key") != "" || c.Request().Header.Get("xi-api-key") != "" {
252+
return true
253+
}
254+
// Skip when Sec-Fetch-Site header is absent (older browsers, reverse
255+
// proxies that strip the header). The SameSite=Lax cookie attribute
256+
// provides baseline CSRF protection for these clients.
257+
if c.Request().Header.Get("Sec-Fetch-Site") == "" {
258+
return true
259+
}
260+
return false
261+
},
262+
// Allow same-site requests (subdomains / different ports) in addition
263+
// to same-origin which Echo already permits by default.
264+
AllowSecFetchSiteFunc: func(c echo.Context) (bool, error) {
265+
secFetchSite := c.Request().Header.Get("Sec-Fetch-Site")
266+
if secFetchSite == "same-site" {
267+
return true, nil
268+
}
269+
// cross-site: block
270+
return false, nil
271+
},
272+
}))
241273
}
242274

243275
// Admin middleware: enforces admin role when auth is enabled, no-op otherwise

core/http/auth/apikeys.go

Lines changed: 28 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package auth
22

33
import (
4+
"crypto/hmac"
45
"crypto/rand"
56
"crypto/sha256"
67
"encoding/hex"
@@ -18,31 +19,37 @@ const (
1819
)
1920

2021
// GenerateAPIKey generates a new API key. Returns the plaintext key,
21-
// its SHA-256 hash, and a display prefix.
22-
func GenerateAPIKey() (plaintext, hash, prefix string, err error) {
22+
// its HMAC-SHA256 hash, and a display prefix.
23+
func GenerateAPIKey(hmacSecret string) (plaintext, hash, prefix string, err error) {
2324
b := make([]byte, apiKeyRandBytes)
2425
if _, err := rand.Read(b); err != nil {
2526
return "", "", "", fmt.Errorf("failed to generate API key: %w", err)
2627
}
2728

2829
randHex := hex.EncodeToString(b)
2930
plaintext = apiKeyPrefix + randHex
30-
hash = HashAPIKey(plaintext)
31+
hash = HashAPIKey(plaintext, hmacSecret)
3132
prefix = plaintext[:len(apiKeyPrefix)+keyPrefixLen]
3233

3334
return plaintext, hash, prefix, nil
3435
}
3536

36-
// HashAPIKey returns the SHA-256 hex digest of the given plaintext key.
37-
func HashAPIKey(plaintext string) string {
38-
h := sha256.Sum256([]byte(plaintext))
39-
return hex.EncodeToString(h[:])
37+
// HashAPIKey returns the HMAC-SHA256 hex digest of the given plaintext key.
38+
// If hmacSecret is empty, falls back to plain SHA-256 for backward compatibility.
39+
func HashAPIKey(plaintext, hmacSecret string) string {
40+
if hmacSecret == "" {
41+
h := sha256.Sum256([]byte(plaintext))
42+
return hex.EncodeToString(h[:])
43+
}
44+
mac := hmac.New(sha256.New, []byte(hmacSecret))
45+
mac.Write([]byte(plaintext))
46+
return hex.EncodeToString(mac.Sum(nil))
4047
}
4148

4249
// CreateAPIKey generates and stores a new API key for the given user.
4350
// Returns the plaintext key (shown once) and the database record.
44-
func CreateAPIKey(db *gorm.DB, userID, name, role string) (string, *UserAPIKey, error) {
45-
plaintext, hash, prefix, err := GenerateAPIKey()
51+
func CreateAPIKey(db *gorm.DB, userID, name, role, hmacSecret string, expiresAt *time.Time) (string, *UserAPIKey, error) {
52+
plaintext, hash, prefix, err := GenerateAPIKey(hmacSecret)
4653
if err != nil {
4754
return "", nil, err
4855
}
@@ -54,6 +61,7 @@ func CreateAPIKey(db *gorm.DB, userID, name, role string) (string, *UserAPIKey,
5461
KeyHash: hash,
5562
KeyPrefix: prefix,
5663
Role: role,
64+
ExpiresAt: expiresAt,
5765
}
5866

5967
if err := db.Create(record).Error; err != nil {
@@ -66,14 +74,18 @@ func CreateAPIKey(db *gorm.DB, userID, name, role string) (string, *UserAPIKey,
6674
// ValidateAPIKey looks up an API key by hashing the plaintext and searching
6775
// the database. Returns the key record if found, or an error.
6876
// Updates LastUsed on successful validation.
69-
func ValidateAPIKey(db *gorm.DB, plaintext string) (*UserAPIKey, error) {
70-
hash := HashAPIKey(plaintext)
77+
func ValidateAPIKey(db *gorm.DB, plaintext, hmacSecret string) (*UserAPIKey, error) {
78+
hash := HashAPIKey(plaintext, hmacSecret)
7179

7280
var key UserAPIKey
7381
if err := db.Preload("User").Where("key_hash = ?", hash).First(&key).Error; err != nil {
7482
return nil, fmt.Errorf("invalid API key")
7583
}
7684

85+
if key.ExpiresAt != nil && time.Now().After(*key.ExpiresAt) {
86+
return nil, fmt.Errorf("API key expired")
87+
}
88+
7789
if key.User.Status != StatusActive {
7890
return nil, fmt.Errorf("user account is not active")
7991
}
@@ -102,3 +114,8 @@ func RevokeAPIKey(db *gorm.DB, keyID, userID string) error {
102114
}
103115
return result.Error
104116
}
117+
118+
// CleanExpiredAPIKeys removes all API keys that have passed their expiry time.
119+
func CleanExpiredAPIKeys(db *gorm.DB) error {
120+
return db.Where("expires_at IS NOT NULL AND expires_at < ?", time.Now()).Delete(&UserAPIKey{}).Error
121+
}

0 commit comments

Comments
 (0)