diff --git a/core/changelog.md b/core/changelog.md index 49c69d7c9..3ab6a734b 100644 --- a/core/changelog.md +++ b/core/changelog.md @@ -1 +1,2 @@ -- feat: add document/file support for Anthropic, Bedrock, and Gemini \ No newline at end of file +- feat: add document/file support for Anthropic, Bedrock, and Gemini +- feat: adds support for allow-list and deny-list for custom and built-in headers \ No newline at end of file diff --git a/core/version b/core/version index f3014908e..bb3653fe5 100644 --- a/core/version +++ b/core/version @@ -1 +1 @@ -1.2.42 \ No newline at end of file +1.2.43 \ No newline at end of file diff --git a/framework/changelog.md b/framework/changelog.md index e69de29bb..1d2315e6c 100644 --- a/framework/changelog.md +++ b/framework/changelog.md @@ -0,0 +1 @@ +- feat: adds support for HeaderFilterConfig in configstore \ No newline at end of file diff --git a/framework/configstore/clientconfig.go b/framework/configstore/clientconfig.go index f38d3107a..1bbdadcec 100644 --- a/framework/configstore/clientconfig.go +++ b/framework/configstore/clientconfig.go @@ -34,19 +34,20 @@ type EnvKeyInfo struct { // ClientConfig represents the core configuration for Bifrost HTTP transport and the Bifrost Client. // It includes settings for excess request handling, Prometheus metrics, and initial pool size. type ClientConfig struct { - DropExcessRequests bool `json:"drop_excess_requests"` // Drop excess requests if the provider queue is full - InitialPoolSize int `json:"initial_pool_size"` // The initial pool size for the bifrost client - PrometheusLabels []string `json:"prometheus_labels"` // The labels to be used for prometheus metrics - EnableLogging bool `json:"enable_logging"` // Enable logging of requests and responses - DisableContentLogging bool `json:"disable_content_logging"` // Disable logging of content - LogRetentionDays int `json:"log_retention_days" validate:"min=1"` // Number of days to retain logs (minimum 1 day) - EnableGovernance bool `json:"enable_governance"` // Enable governance on all requests - EnforceGovernanceHeader bool `json:"enforce_governance_header"` // Enforce governance on all requests - AllowDirectKeys bool `json:"allow_direct_keys"` // Allow direct keys to be used for requests - AllowedOrigins []string `json:"allowed_origins,omitempty"` // Additional allowed origins for CORS and WebSocket (localhost is always allowed) - MaxRequestBodySizeMB int `json:"max_request_body_size_mb"` // The maximum request body size in MB - EnableLiteLLMFallbacks bool `json:"enable_litellm_fallbacks"` // Enable litellm-specific fallbacks for text completion for Groq - ConfigHash string `json:"-"` // Config hash for reconciliation (not serialized) + DropExcessRequests bool `json:"drop_excess_requests"` // Drop excess requests if the provider queue is full + InitialPoolSize int `json:"initial_pool_size"` // The initial pool size for the bifrost client + PrometheusLabels []string `json:"prometheus_labels"` // The labels to be used for prometheus metrics + EnableLogging bool `json:"enable_logging"` // Enable logging of requests and responses + DisableContentLogging bool `json:"disable_content_logging"` // Disable logging of content + LogRetentionDays int `json:"log_retention_days" validate:"min=1"` // Number of days to retain logs (minimum 1 day) + EnableGovernance bool `json:"enable_governance"` // Enable governance on all requests + EnforceGovernanceHeader bool `json:"enforce_governance_header"` // Enforce governance on all requests + AllowDirectKeys bool `json:"allow_direct_keys"` // Allow direct keys to be used for requests + AllowedOrigins []string `json:"allowed_origins,omitempty"` // Additional allowed origins for CORS and WebSocket (localhost is always allowed) + MaxRequestBodySizeMB int `json:"max_request_body_size_mb"` // The maximum request body size in MB + EnableLiteLLMFallbacks bool `json:"enable_litellm_fallbacks"` // Enable litellm-specific fallbacks for text completion for Groq + HeaderFilterConfig *tables.GlobalHeaderFilterConfig `json:"header_filter_config,omitempty"` // Global header filtering configuration for x-bf-eh-* headers + ConfigHash string `json:"-"` // Config hash for reconciliation (not serialized) } // GenerateClientConfigHash generates a SHA256 hash of the client configuration. @@ -140,6 +141,34 @@ func (c *ClientConfig) GenerateClientConfigHash() (string, error) { hash.Write(data) } + // Hash HeaderFilterConfig + if c.HeaderFilterConfig != nil { + // Hash Allowlist (sorted for deterministic hashing) + if len(c.HeaderFilterConfig.Allowlist) > 0 { + sortedAllowlist := make([]string, len(c.HeaderFilterConfig.Allowlist)) + copy(sortedAllowlist, c.HeaderFilterConfig.Allowlist) + sort.Strings(sortedAllowlist) + data, err := sonic.Marshal(sortedAllowlist) + if err != nil { + return "", err + } + hash.Write([]byte("headerFilterConfig.allowlist:")) + hash.Write(data) + } + // Hash Denylist (sorted for deterministic hashing) + if len(c.HeaderFilterConfig.Denylist) > 0 { + sortedDenylist := make([]string, len(c.HeaderFilterConfig.Denylist)) + copy(sortedDenylist, c.HeaderFilterConfig.Denylist) + sort.Strings(sortedDenylist) + data, err := sonic.Marshal(sortedDenylist) + if err != nil { + return "", err + } + hash.Write([]byte("headerFilterConfig.denylist:")) + hash.Write(data) + } + } + return hex.EncodeToString(hash.Sum(nil)), nil } diff --git a/framework/configstore/migrations.go b/framework/configstore/migrations.go index 93975ff16..26e5f72b5 100644 --- a/framework/configstore/migrations.go +++ b/framework/configstore/migrations.go @@ -110,6 +110,9 @@ func triggerMigrations(ctx context.Context, db *gorm.DB) error { if err := migrationAddUseForBatchAPIColumnAndS3BucketsConfig(ctx, db); err != nil { return err } + if err := migrationAddHeaderFilterConfigJSONColumn(ctx, db); err != nil { + return err + } return nil } @@ -1827,3 +1830,37 @@ func migrationAddUseForBatchAPIColumnAndS3BucketsConfig(ctx context.Context, db } return nil } + +// migrationAddHeaderFilterConfigJSONColumn adds the header_filter_config_json column to the config_client table +func migrationAddHeaderFilterConfigJSONColumn(ctx context.Context, db *gorm.DB) error { + m := migrator.New(db, migrator.DefaultOptions, []*migrator.Migration{{ + ID: "add_header_filter_config_json_column", + Migrate: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + mg := tx.Migrator() + + if !mg.HasColumn(&tables.TableClientConfig{}, "header_filter_config_json") { + if err := mg.AddColumn(&tables.TableClientConfig{}, "header_filter_config_json"); err != nil { + return fmt.Errorf("failed to add header_filter_config_json column: %w", err) + } + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + tx = tx.WithContext(ctx) + mg := tx.Migrator() + + if mg.HasColumn(&tables.TableClientConfig{}, "header_filter_config_json") { + if err := mg.DropColumn(&tables.TableClientConfig{}, "header_filter_config_json"); err != nil { + return fmt.Errorf("failed to drop header_filter_config_json column: %w", err) + } + } + return nil + }, + }}) + + if err := m.Migrate(); err != nil { + return fmt.Errorf("error running header_filter_config_json migration: %s", err.Error()) + } + return nil +} diff --git a/framework/configstore/rdb.go b/framework/configstore/rdb.go index ce6a335d7..553ca17bf 100644 --- a/framework/configstore/rdb.go +++ b/framework/configstore/rdb.go @@ -41,6 +41,7 @@ func (s *RDBConfigStore) UpdateClientConfig(ctx context.Context, config *ClientC AllowedOrigins: config.AllowedOrigins, MaxRequestBodySizeMB: config.MaxRequestBodySizeMB, EnableLiteLLMFallbacks: config.EnableLiteLLMFallbacks, + HeaderFilterConfig: config.HeaderFilterConfig, } // Delete existing client config and create new one in a transaction return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { @@ -195,6 +196,7 @@ func (s *RDBConfigStore) GetClientConfig(ctx context.Context) (*ClientConfig, er AllowedOrigins: dbConfig.AllowedOrigins, MaxRequestBodySizeMB: dbConfig.MaxRequestBodySizeMB, EnableLiteLLMFallbacks: dbConfig.EnableLiteLLMFallbacks, + HeaderFilterConfig: dbConfig.HeaderFilterConfig, }, nil } diff --git a/framework/configstore/tables/clientconfig.go b/framework/configstore/tables/clientconfig.go index 536d6a565..8ecf45acd 100644 --- a/framework/configstore/tables/clientconfig.go +++ b/framework/configstore/tables/clientconfig.go @@ -13,6 +13,7 @@ type TableClientConfig struct { DropExcessRequests bool `gorm:"default:false" json:"drop_excess_requests"` PrometheusLabelsJSON string `gorm:"type:text" json:"-"` // JSON serialized []string AllowedOriginsJSON string `gorm:"type:text" json:"-"` // JSON serialized []string + HeaderFilterConfigJSON string `gorm:"type:text" json:"-"` // JSON serialized GlobalHeaderFilterConfig InitialPoolSize int `gorm:"default:300" json:"initial_pool_size"` EnableLogging bool `gorm:"" json:"enable_logging"` DisableContentLogging bool `gorm:"default:false" json:"disable_content_logging"` // DisableContentLogging controls whether sensitive content (inputs, outputs, embeddings, etc.) is logged @@ -32,8 +33,9 @@ type TableClientConfig struct { UpdatedAt time.Time `gorm:"index;not null" json:"updated_at"` // Virtual fields for runtime use (not stored in DB) - PrometheusLabels []string `gorm:"-" json:"prometheus_labels"` - AllowedOrigins []string `gorm:"-" json:"allowed_origins,omitempty"` + PrometheusLabels []string `gorm:"-" json:"prometheus_labels"` + AllowedOrigins []string `gorm:"-" json:"allowed_origins,omitempty"` + HeaderFilterConfig *GlobalHeaderFilterConfig `gorm:"-" json:"header_filter_config,omitempty"` } // TableName sets the table name for each model @@ -60,6 +62,16 @@ func (cc *TableClientConfig) BeforeSave(tx *gorm.DB) error { cc.AllowedOriginsJSON = "[]" } + if cc.HeaderFilterConfig != nil { + data, err := json.Marshal(cc.HeaderFilterConfig) + if err != nil { + return err + } + cc.HeaderFilterConfigJSON = string(data) + } else { + cc.HeaderFilterConfigJSON = "" + } + return nil } @@ -77,5 +89,13 @@ func (cc *TableClientConfig) AfterFind(tx *gorm.DB) error { } } + if cc.HeaderFilterConfigJSON != "" { + var headerFilterConfig GlobalHeaderFilterConfig + if err := json.Unmarshal([]byte(cc.HeaderFilterConfigJSON), &headerFilterConfig); err != nil { + return err + } + cc.HeaderFilterConfig = &headerFilterConfig + } + return nil } diff --git a/framework/configstore/tables/config.go b/framework/configstore/tables/config.go index 44a1fe4cd..1fff5b084 100644 --- a/framework/configstore/tables/config.go +++ b/framework/configstore/tables/config.go @@ -9,6 +9,7 @@ const ( ConfigDisableAuthOnInferenceKey = "disable_auth_on_inference" ConfigProxyKey = "proxy_config" ConfigRestartRequiredKey = "restart_required" + ConfigHeaderFilterKey = "header_filter_config" ) // RestartRequiredConfig represents the restart required configuration @@ -22,20 +23,31 @@ type RestartRequiredConfig struct { // GlobalProxyConfig represents the global proxy configuration type GlobalProxyConfig struct { - Enabled bool `json:"enabled"` - Type network.GlobalProxyType `json:"type"` // "http", "socks5", "tcp" - URL string `json:"url"` // Proxy URL (e.g., http://proxy.example.com:8080) - Username string `json:"username,omitempty"` // Optional authentication username - Password string `json:"password,omitempty"` // Optional authentication password - NoProxy string `json:"no_proxy,omitempty"` // Comma-separated list of hosts to bypass proxy - Timeout int `json:"timeout,omitempty"` // Connection timeout in seconds - SkipTLSVerify bool `json:"skip_tls_verify,omitempty"`// Skip TLS certificate verification + Enabled bool `json:"enabled"` + Type network.GlobalProxyType `json:"type"` // "http", "socks5", "tcp" + URL string `json:"url"` // Proxy URL (e.g., http://proxy.example.com:8080) + Username string `json:"username,omitempty"` // Optional authentication username + Password string `json:"password,omitempty"` // Optional authentication password + NoProxy string `json:"no_proxy,omitempty"` // Comma-separated list of hosts to bypass proxy + Timeout int `json:"timeout,omitempty"` // Connection timeout in seconds + SkipTLSVerify bool `json:"skip_tls_verify,omitempty"` // Skip TLS certificate verification // Entity enablement flags EnableForSCIM bool `json:"enable_for_scim"` // Enable proxy for SCIM requests (enterprise only) EnableForInference bool `json:"enable_for_inference"` // Enable proxy for inference requests EnableForAPI bool `json:"enable_for_api"` // Enable proxy for API requests } +// GlobalHeaderFilterConfig represents global header filtering configuration +// for headers forwarded to LLM providers via the x-bf-eh-* prefix. +// Filter logic: +// - If allowlist is non-empty, only headers in the allowlist are forwarded +// - If denylist is non-empty, headers in the denylist are dropped +// - If both are non-empty, allowlist takes precedence first, then denylist filters the result +type GlobalHeaderFilterConfig struct { + Allowlist []string `json:"allowlist,omitempty"` // If non-empty, only these headers are allowed + Denylist []string `json:"denylist,omitempty"` // Headers to always block +} + // TableGovernanceConfig represents generic configuration key-value pairs type TableGovernanceConfig struct { Key string `gorm:"primaryKey;type:varchar(255)" json:"key"` diff --git a/framework/version b/framework/version index c39e7b3ca..76700a794 100644 --- a/framework/version +++ b/framework/version @@ -1 +1 @@ -1.1.52 \ No newline at end of file +1.1.53 \ No newline at end of file diff --git a/plugins/governance/changelog.md b/plugins/governance/changelog.md index e69de29bb..b95ed157c 100644 --- a/plugins/governance/changelog.md +++ b/plugins/governance/changelog.md @@ -0,0 +1 @@ +- chore: upgrade core to 1.2.43 and framework to 1.1.53 \ No newline at end of file diff --git a/plugins/governance/version b/plugins/governance/version index 573f75b1b..ff81b39cf 100644 --- a/plugins/governance/version +++ b/plugins/governance/version @@ -1 +1 @@ -1.3.53 \ No newline at end of file +1.3.54 \ No newline at end of file diff --git a/plugins/jsonparser/changelog.md b/plugins/jsonparser/changelog.md index e69de29bb..b95ed157c 100644 --- a/plugins/jsonparser/changelog.md +++ b/plugins/jsonparser/changelog.md @@ -0,0 +1 @@ +- chore: upgrade core to 1.2.43 and framework to 1.1.53 \ No newline at end of file diff --git a/plugins/jsonparser/version b/plugins/jsonparser/version index 573f75b1b..ff81b39cf 100644 --- a/plugins/jsonparser/version +++ b/plugins/jsonparser/version @@ -1 +1 @@ -1.3.53 \ No newline at end of file +1.3.54 \ No newline at end of file diff --git a/plugins/logging/changelog.md b/plugins/logging/changelog.md index e69de29bb..b95ed157c 100644 --- a/plugins/logging/changelog.md +++ b/plugins/logging/changelog.md @@ -0,0 +1 @@ +- chore: upgrade core to 1.2.43 and framework to 1.1.53 \ No newline at end of file diff --git a/plugins/logging/version b/plugins/logging/version index 573f75b1b..ff81b39cf 100644 --- a/plugins/logging/version +++ b/plugins/logging/version @@ -1 +1 @@ -1.3.53 \ No newline at end of file +1.3.54 \ No newline at end of file diff --git a/plugins/maxim/changelog.md b/plugins/maxim/changelog.md index e69de29bb..b95ed157c 100644 --- a/plugins/maxim/changelog.md +++ b/plugins/maxim/changelog.md @@ -0,0 +1 @@ +- chore: upgrade core to 1.2.43 and framework to 1.1.53 \ No newline at end of file diff --git a/plugins/maxim/version b/plugins/maxim/version index b64d2f606..17e32e5fe 100644 --- a/plugins/maxim/version +++ b/plugins/maxim/version @@ -1 +1 @@ -1.4.53 \ No newline at end of file +1.4.54 \ No newline at end of file diff --git a/plugins/mocker/changelog.md b/plugins/mocker/changelog.md index e69de29bb..b95ed157c 100644 --- a/plugins/mocker/changelog.md +++ b/plugins/mocker/changelog.md @@ -0,0 +1 @@ +- chore: upgrade core to 1.2.43 and framework to 1.1.53 \ No newline at end of file diff --git a/plugins/mocker/version b/plugins/mocker/version index a60ed34e1..573f75b1b 100644 --- a/plugins/mocker/version +++ b/plugins/mocker/version @@ -1 +1 @@ -1.3.52 \ No newline at end of file +1.3.53 \ No newline at end of file diff --git a/plugins/otel/changelog.md b/plugins/otel/changelog.md index e69de29bb..b95ed157c 100644 --- a/plugins/otel/changelog.md +++ b/plugins/otel/changelog.md @@ -0,0 +1 @@ +- chore: upgrade core to 1.2.43 and framework to 1.1.53 \ No newline at end of file diff --git a/plugins/otel/version b/plugins/otel/version index bfaf9d539..153ed03e6 100644 --- a/plugins/otel/version +++ b/plugins/otel/version @@ -1 +1 @@ -1.0.52 \ No newline at end of file +1.0.53 \ No newline at end of file diff --git a/plugins/semanticcache/changelog.md b/plugins/semanticcache/changelog.md index e69de29bb..b95ed157c 100644 --- a/plugins/semanticcache/changelog.md +++ b/plugins/semanticcache/changelog.md @@ -0,0 +1 @@ +- chore: upgrade core to 1.2.43 and framework to 1.1.53 \ No newline at end of file diff --git a/plugins/semanticcache/version b/plugins/semanticcache/version index a60ed34e1..573f75b1b 100644 --- a/plugins/semanticcache/version +++ b/plugins/semanticcache/version @@ -1 +1 @@ -1.3.52 \ No newline at end of file +1.3.53 \ No newline at end of file diff --git a/plugins/telemetry/changelog.md b/plugins/telemetry/changelog.md index e69de29bb..b95ed157c 100644 --- a/plugins/telemetry/changelog.md +++ b/plugins/telemetry/changelog.md @@ -0,0 +1 @@ +- chore: upgrade core to 1.2.43 and framework to 1.1.53 \ No newline at end of file diff --git a/plugins/telemetry/version b/plugins/telemetry/version index a60ed34e1..573f75b1b 100644 --- a/plugins/telemetry/version +++ b/plugins/telemetry/version @@ -1 +1 @@ -1.3.52 \ No newline at end of file +1.3.53 \ No newline at end of file diff --git a/transports/bifrost-http/handlers/config.go b/transports/bifrost-http/handlers/config.go index 175567689..2b4d751d8 100644 --- a/transports/bifrost-http/handlers/config.go +++ b/transports/bifrost-http/handlers/config.go @@ -22,6 +22,22 @@ import ( "github.com/valyala/fasthttp" ) +// securityHeaders is the list of headers that cannot be configured in allowlist/denylist +// These headers are always blocked for security reasons regardless of user configuration +var securityHeaders = []string{ + "authorization", + "proxy-authorization", + "cookie", + "host", + "content-length", + "connection", + "transfer-encoding", + "x-api-key", + "x-goog-api-key", + "x-bf-api-key", + "x-bf-vk", +} + // ConfigManager is the interface for the config manager type ConfigManager interface { UpdateAuthConfig(ctx context.Context, authConfig *configstore.AuthConfig) error @@ -31,6 +47,7 @@ type ConfigManager interface { UpdateDropExcessRequests(ctx context.Context, value bool) ReloadPlugin(ctx context.Context, name string, path *string, pluginConfig any) error ReloadProxyConfig(ctx context.Context, config *configstoreTables.GlobalProxyConfig) error + ReloadHeaderFilterConfig(ctx context.Context, config *configstoreTables.GlobalHeaderFilterConfig) error } // ConfigHandler manages runtime configuration updates for Bifrost. @@ -265,6 +282,22 @@ func (h *ConfigHandler) updateConfig(ctx *fasthttp.RequestCtx) { updatedConfig.EnableLiteLLMFallbacks = payload.ClientConfig.EnableLiteLLMFallbacks + // Handle HeaderFilterConfig changes + if !headerFilterConfigEqual(payload.ClientConfig.HeaderFilterConfig, currentConfig.HeaderFilterConfig) { + // Validate that no security headers are in the allowlist or denylist + if err := validateHeaderFilterConfig(payload.ClientConfig.HeaderFilterConfig); err != nil { + logger.Warn(fmt.Sprintf("invalid header filter config: %v", err)) + SendError(ctx, fasthttp.StatusBadRequest, err.Error()) + return + } + updatedConfig.HeaderFilterConfig = payload.ClientConfig.HeaderFilterConfig + if err := h.configManager.ReloadHeaderFilterConfig(ctx, payload.ClientConfig.HeaderFilterConfig); err != nil { + logger.Warn(fmt.Sprintf("failed to reload header filter config: %v", err)) + SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("failed to reload header filter config: %v", err)) + return + } + } + // Validate LogRetentionDays if payload.ClientConfig.LogRetentionDays < 1 { logger.Warn("log_retention_days must be at least 1") @@ -611,3 +644,46 @@ func (h *ConfigHandler) updateProxyConfig(ctx *fasthttp.RequestCtx) { "message": "proxy configuration updated successfully", }) } + +// headerFilterConfigEqual compares two GlobalHeaderFilterConfig for equality +func headerFilterConfigEqual(a, b *configstoreTables.GlobalHeaderFilterConfig) bool { + if a == nil && b == nil { + return true + } + if a == nil || b == nil { + return false + } + return slices.Equal(a.Allowlist, b.Allowlist) && slices.Equal(a.Denylist, b.Denylist) +} + +// validateHeaderFilterConfig validates that no security headers are in the allowlist or denylist +// Returns an error if any security headers are found +func validateHeaderFilterConfig(config *configstoreTables.GlobalHeaderFilterConfig) error { + if config == nil { + return nil + } + + var foundSecurityHeaders []string + + // Check allowlist for security headers + for _, header := range config.Allowlist { + headerLower := strings.ToLower(strings.TrimSpace(header)) + if slices.Contains(securityHeaders, headerLower) { + foundSecurityHeaders = append(foundSecurityHeaders, headerLower) + } + } + + // Check denylist for security headers + for _, header := range config.Denylist { + headerLower := strings.ToLower(strings.TrimSpace(header)) + if slices.Contains(securityHeaders, headerLower) && !slices.Contains(foundSecurityHeaders, headerLower) { + foundSecurityHeaders = append(foundSecurityHeaders, headerLower) + } + } + + if len(foundSecurityHeaders) > 0 { + return fmt.Errorf("the following headers are not allowed to be configured: %s. These headers are security headers and are always blocked", strings.Join(foundSecurityHeaders, ", ")) + } + + return nil +} diff --git a/transports/bifrost-http/handlers/inference.go b/transports/bifrost-http/handlers/inference.go index 8900206bf..d1a8c29c9 100644 --- a/transports/bifrost-http/handlers/inference.go +++ b/transports/bifrost-http/handlers/inference.go @@ -447,7 +447,7 @@ func (h *CompletionHandler) listModels(ctx *fasthttp.RequestCtx) { provider := string(ctx.QueryArgs().Peek("provider")) // Convert context - bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, h.handlerStore.ShouldAllowDirectKeys()) + bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, h.handlerStore.ShouldAllowDirectKeys(), h.config.GetHeaderFilterConfig()) defer cancel() // Ensure cleanup on function exit if bifrostCtx == nil { SendError(ctx, fasthttp.StatusInternalServerError, "Failed to convert context") @@ -570,7 +570,7 @@ func (h *CompletionHandler) textCompletion(ctx *fasthttp.RequestCtx) { Fallbacks: fallbacks, } // Convert context - bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, h.handlerStore.ShouldAllowDirectKeys()) + bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, h.handlerStore.ShouldAllowDirectKeys(), h.config.GetHeaderFilterConfig()) if bifrostCtx == nil { SendError(ctx, fasthttp.StatusInternalServerError, "Failed to convert context") return @@ -646,7 +646,7 @@ func (h *CompletionHandler) chatCompletion(ctx *fasthttp.RequestCtx) { } // Convert context - bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, h.handlerStore.ShouldAllowDirectKeys()) + bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, h.handlerStore.ShouldAllowDirectKeys(), h.config.GetHeaderFilterConfig()) if bifrostCtx == nil { SendError(ctx, fasthttp.StatusInternalServerError, "Failed to convert context") return @@ -728,7 +728,7 @@ func (h *CompletionHandler) responses(ctx *fasthttp.RequestCtx) { } // Convert context - bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, h.handlerStore.ShouldAllowDirectKeys()) + bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, h.handlerStore.ShouldAllowDirectKeys(), h.config.GetHeaderFilterConfig()) if bifrostCtx == nil { SendError(ctx, fasthttp.StatusInternalServerError, "Failed to convert context") return @@ -800,7 +800,7 @@ func (h *CompletionHandler) embeddings(ctx *fasthttp.RequestCtx) { } // Convert context - bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, h.handlerStore.ShouldAllowDirectKeys()) + bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, h.handlerStore.ShouldAllowDirectKeys(), h.config.GetHeaderFilterConfig()) defer cancel() // Ensure cleanup on function exit if bifrostCtx == nil { SendError(ctx, fasthttp.StatusInternalServerError, "Failed to convert context") @@ -876,7 +876,7 @@ func (h *CompletionHandler) speech(ctx *fasthttp.RequestCtx) { } // Convert context - bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, h.handlerStore.ShouldAllowDirectKeys()) + bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, h.handlerStore.ShouldAllowDirectKeys(), h.config.GetHeaderFilterConfig()) if bifrostCtx == nil { SendError(ctx, fasthttp.StatusInternalServerError, "Failed to convert context") return @@ -1007,7 +1007,7 @@ func (h *CompletionHandler) transcription(ctx *fasthttp.RequestCtx) { } // Convert context - bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, h.handlerStore.ShouldAllowDirectKeys()) + bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, h.handlerStore.ShouldAllowDirectKeys(), h.config.GetHeaderFilterConfig()) if bifrostCtx == nil { SendError(ctx, fasthttp.StatusInternalServerError, "Failed to convert context") return @@ -1093,7 +1093,7 @@ func (h *CompletionHandler) countTokens(ctx *fasthttp.RequestCtx) { } // Convert context - bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, h.handlerStore.ShouldAllowDirectKeys()) + bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, h.handlerStore.ShouldAllowDirectKeys(), h.config.GetHeaderFilterConfig()) if bifrostCtx == nil { SendError(ctx, fasthttp.StatusInternalServerError, "Failed to convert context") return @@ -1392,7 +1392,7 @@ func (h *CompletionHandler) batchCreate(ctx *fasthttp.RequestCtx) { } // Convert context - bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, h.handlerStore.ShouldAllowDirectKeys()) + bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, h.handlerStore.ShouldAllowDirectKeys(), h.config.GetHeaderFilterConfig()) defer cancel() if bifrostCtx == nil { SendError(ctx, fasthttp.StatusInternalServerError, "Failed to convert context") @@ -1445,7 +1445,7 @@ func (h *CompletionHandler) batchList(ctx *fasthttp.RequestCtx) { } // Convert context - bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, h.handlerStore.ShouldAllowDirectKeys()) + bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, h.handlerStore.ShouldAllowDirectKeys(), h.config.GetHeaderFilterConfig()) defer cancel() if bifrostCtx == nil { SendError(ctx, fasthttp.StatusInternalServerError, "Failed to convert context") @@ -1484,7 +1484,7 @@ func (h *CompletionHandler) batchRetrieve(ctx *fasthttp.RequestCtx) { } // Convert context - bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, h.handlerStore.ShouldAllowDirectKeys()) + bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, h.handlerStore.ShouldAllowDirectKeys(), h.config.GetHeaderFilterConfig()) defer cancel() if bifrostCtx == nil { SendError(ctx, fasthttp.StatusInternalServerError, "Failed to convert context") @@ -1523,7 +1523,7 @@ func (h *CompletionHandler) batchCancel(ctx *fasthttp.RequestCtx) { } // Convert context - bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, h.handlerStore.ShouldAllowDirectKeys()) + bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, h.handlerStore.ShouldAllowDirectKeys(), h.config.GetHeaderFilterConfig()) defer cancel() if bifrostCtx == nil { SendError(ctx, fasthttp.StatusInternalServerError, "Failed to convert context") @@ -1562,7 +1562,7 @@ func (h *CompletionHandler) batchResults(ctx *fasthttp.RequestCtx) { } // Convert context - bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, h.handlerStore.ShouldAllowDirectKeys()) + bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, h.handlerStore.ShouldAllowDirectKeys(), h.config.GetHeaderFilterConfig()) defer cancel() if bifrostCtx == nil { SendError(ctx, fasthttp.StatusInternalServerError, "Failed to convert context") @@ -1644,7 +1644,7 @@ func (h *CompletionHandler) fileUpload(ctx *fasthttp.RequestCtx) { } // Convert context - bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, h.handlerStore.ShouldAllowDirectKeys()) + bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, h.handlerStore.ShouldAllowDirectKeys(), h.config.GetHeaderFilterConfig()) defer cancel() if bifrostCtx == nil { SendError(ctx, fasthttp.StatusInternalServerError, "Failed to convert context") @@ -1703,7 +1703,7 @@ func (h *CompletionHandler) fileList(ctx *fasthttp.RequestCtx) { } // Convert context - bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, h.handlerStore.ShouldAllowDirectKeys()) + bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, h.handlerStore.ShouldAllowDirectKeys(), h.config.GetHeaderFilterConfig()) defer cancel() if bifrostCtx == nil { SendError(ctx, fasthttp.StatusInternalServerError, "Failed to convert context") @@ -1742,7 +1742,7 @@ func (h *CompletionHandler) fileRetrieve(ctx *fasthttp.RequestCtx) { } // Convert context - bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, h.handlerStore.ShouldAllowDirectKeys()) + bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, h.handlerStore.ShouldAllowDirectKeys(), h.config.GetHeaderFilterConfig()) defer cancel() if bifrostCtx == nil { SendError(ctx, fasthttp.StatusInternalServerError, "Failed to convert context") @@ -1781,7 +1781,7 @@ func (h *CompletionHandler) fileDelete(ctx *fasthttp.RequestCtx) { } // Convert context - bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, h.handlerStore.ShouldAllowDirectKeys()) + bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, h.handlerStore.ShouldAllowDirectKeys(), h.config.GetHeaderFilterConfig()) defer cancel() if bifrostCtx == nil { SendError(ctx, fasthttp.StatusInternalServerError, "Failed to convert context") @@ -1820,7 +1820,7 @@ func (h *CompletionHandler) fileContent(ctx *fasthttp.RequestCtx) { } // Convert context - bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, h.handlerStore.ShouldAllowDirectKeys()) + bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, h.handlerStore.ShouldAllowDirectKeys(), h.config.GetHeaderFilterConfig()) defer cancel() if bifrostCtx == nil { SendError(ctx, fasthttp.StatusInternalServerError, "Failed to convert context") diff --git a/transports/bifrost-http/handlers/mcp.go b/transports/bifrost-http/handlers/mcp.go index 88b710d20..320b058e4 100644 --- a/transports/bifrost-http/handlers/mcp.go +++ b/transports/bifrost-http/handlers/mcp.go @@ -64,7 +64,7 @@ func (h *MCPHandler) executeTool(ctx *fasthttp.RequestCtx) { } // Convert context - bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, false) + bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, false, h.store.GetHeaderFilterConfig()) defer cancel() // Ensure cleanup on function exit if bifrostCtx == nil { SendError(ctx, fasthttp.StatusInternalServerError, "Failed to convert context") diff --git a/transports/bifrost-http/integrations/bedrock_test.go b/transports/bifrost-http/integrations/bedrock_test.go index c9d2f20e6..7894eb420 100644 --- a/transports/bifrost-http/integrations/bedrock_test.go +++ b/transports/bifrost-http/integrations/bedrock_test.go @@ -6,6 +6,7 @@ import ( "github.com/maximhq/bifrost/core/providers/bedrock" "github.com/maximhq/bifrost/core/schemas" + configstoreTables "github.com/maximhq/bifrost/framework/configstore/tables" "github.com/maximhq/bifrost/transports/bifrost-http/lib" "github.com/stretchr/testify/assert" "github.com/valyala/fasthttp" @@ -13,13 +14,18 @@ import ( // mockHandlerStore implements lib.HandlerStore for testing type mockHandlerStore struct { - allowDirectKeys bool + allowDirectKeys bool + headerFilterConfig *configstoreTables.GlobalHeaderFilterConfig } func (m *mockHandlerStore) ShouldAllowDirectKeys() bool { return m.allowDirectKeys } +func (m *mockHandlerStore) GetHeaderFilterConfig() *configstoreTables.GlobalHeaderFilterConfig { + return m.headerFilterConfig +} + // Ensure mockHandlerStore implements lib.HandlerStore var _ lib.HandlerStore = (*mockHandlerStore)(nil) diff --git a/transports/bifrost-http/integrations/router.go b/transports/bifrost-http/integrations/router.go index 2fea6d108..27cf1dca1 100644 --- a/transports/bifrost-http/integrations/router.go +++ b/transports/bifrost-http/integrations/router.go @@ -390,7 +390,7 @@ func (g *GenericRouter) createHandler(config RouteConfig) fasthttp.RequestHandle var rawBody []byte // Execute the request through Bifrost - bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, g.handlerStore.ShouldAllowDirectKeys()) + bifrostCtx, cancel := lib.ConvertToBifrostContext(ctx, g.handlerStore.ShouldAllowDirectKeys(), g.handlerStore.GetHeaderFilterConfig()) // Set send back raw response flag for all integration requests *bifrostCtx = context.WithValue(*bifrostCtx, schemas.BifrostContextKeySendBackRawResponse, true) diff --git a/transports/bifrost-http/lib/config.go b/transports/bifrost-http/lib/config.go index 83d9728db..de52f63fb 100644 --- a/transports/bifrost-http/lib/config.go +++ b/transports/bifrost-http/lib/config.go @@ -36,6 +36,8 @@ import ( type HandlerStore interface { // ShouldAllowDirectKeys returns whether direct API keys in headers are allowed ShouldAllowDirectKeys() bool + // GetHeaderFilterConfig returns the global header filter configuration + GetHeaderFilterConfig() *configstoreTables.GlobalHeaderFilterConfig } // Retry backoff constants for validation @@ -531,6 +533,18 @@ func mergeClientConfig(dbConfig *configstore.ClientConfig, fileConfig *configsto if !dbConfig.EnableLiteLLMFallbacks && fileConfig.EnableLiteLLMFallbacks { dbConfig.EnableLiteLLMFallbacks = fileConfig.EnableLiteLLMFallbacks } + // Merge HeaderFilterConfig: DB takes priority, but fill in empty values from config file + if dbConfig.HeaderFilterConfig == nil && fileConfig.HeaderFilterConfig != nil { + dbConfig.HeaderFilterConfig = fileConfig.HeaderFilterConfig + } else if dbConfig.HeaderFilterConfig != nil && fileConfig.HeaderFilterConfig != nil { + // Merge individual lists: DB values take priority, but if empty, use file values + if len(dbConfig.HeaderFilterConfig.Allowlist) == 0 && len(fileConfig.HeaderFilterConfig.Allowlist) > 0 { + dbConfig.HeaderFilterConfig.Allowlist = fileConfig.HeaderFilterConfig.Allowlist + } + if len(dbConfig.HeaderFilterConfig.Denylist) == 0 && len(fileConfig.HeaderFilterConfig.Denylist) > 0 { + dbConfig.HeaderFilterConfig.Denylist = fileConfig.HeaderFilterConfig.Denylist + } + } } // loadProvidersFromFile loads and merges providers from file with store using hash reconciliation @@ -2021,6 +2035,14 @@ func (c *Config) ShouldAllowDirectKeys() bool { return c.ClientConfig.AllowDirectKeys } +// GetHeaderFilterConfig returns the global header filter configuration +// Note: This method doesn't use locking for performance. In rare cases during +// config updates, it may return stale data, but this is acceptable since pointer +// reads are atomic and won't cause panics. +func (c *Config) GetHeaderFilterConfig() *configstoreTables.GlobalHeaderFilterConfig { + return c.ClientConfig.HeaderFilterConfig +} + // GetLoadedPlugins returns the current snapshot of loaded plugins. // This method is lock-free and safe for concurrent access from hot paths. // It returns the plugin slice from the atomic pointer, which is safe to iterate diff --git a/transports/bifrost-http/lib/ctx.go b/transports/bifrost-http/lib/ctx.go index ed522af84..7491a366d 100644 --- a/transports/bifrost-http/lib/ctx.go +++ b/transports/bifrost-http/lib/ctx.go @@ -8,12 +8,14 @@ package lib import ( "context" + "slices" "strconv" "strings" "time" "github.com/google/uuid" "github.com/maximhq/bifrost/core/schemas" + configstoreTables "github.com/maximhq/bifrost/framework/configstore/tables" "github.com/maximhq/bifrost/plugins/governance" "github.com/maximhq/bifrost/plugins/maxim" "github.com/maximhq/bifrost/plugins/semanticcache" @@ -75,7 +77,7 @@ import ( // defer cancel() // Ensure cleanup // // bifrostCtx now contains any prometheus and maxim header values -func ConvertToBifrostContext(ctx *fasthttp.RequestCtx, allowDirectKeys bool) (*context.Context, context.CancelFunc) { +func ConvertToBifrostContext(ctx *fasthttp.RequestCtx, allowDirectKeys bool, headerFilterConfig *configstoreTables.GlobalHeaderFilterConfig) (*context.Context, context.CancelFunc) { // Create cancellable context for all requests // This enables proper cleanup when clients disconnect or requests are cancelled baseCtx := context.Background() @@ -95,8 +97,9 @@ func ConvertToBifrostContext(ctx *fasthttp.RequestCtx, allowDirectKeys bool) (*c maximTags := make(map[string]string) // Initialize extra headers map for headers prefixed with x-bf-eh- extraHeaders := make(map[string][]string) - // Denylist of header names that should not be accepted (case-insensitive) - denylist := map[string]bool{ + // Security denylist of header names that should never be accepted (case-insensitive) + // This denylist is always enforced regardless of user configuration + securityDenylist := map[string]bool{ "authorization": true, "proxy-authorization": true, "cookie": true, @@ -112,6 +115,46 @@ func ConvertToBifrostContext(ctx *fasthttp.RequestCtx, allowDirectKeys bool) (*c "x-bf-vk": true, } + // shouldAllowHeader determines if a header should be forwarded based on + // the configurable header filter config (separate from security denylist) + // Filter logic: + // 1. If allowlist is non-empty, header must be in allowlist + // 2. If denylist is non-empty, header must not be in denylist + // 3. If both are non-empty, allowlist takes precedence first, then denylist filters + // 4. If both are empty, header is allowed + shouldAllowHeader := func(headerName string) bool { + if headerFilterConfig == nil { + return true + } + hasAllowlist := len(headerFilterConfig.Allowlist) > 0 + hasDenylist := len(headerFilterConfig.Denylist) > 0 + // If allowlist is non-empty, header must be in allowlist + if hasAllowlist { + if !slices.ContainsFunc(headerFilterConfig.Allowlist, func(s string) bool { + return strings.EqualFold(s, headerName) + }) { + return false + } + } + // If denylist is non-empty, header must not be in denylist + if hasDenylist { + if slices.ContainsFunc(headerFilterConfig.Denylist, func(s string) bool { + return strings.EqualFold(s, headerName) + }) { + return false + + } + } + return true + } + + // Debug: Log header filter config + if headerFilterConfig != nil { + logger.Debug("headerFilterConfig allowlist: %v, denylist: %v", headerFilterConfig.Allowlist, headerFilterConfig.Denylist) + } else { + logger.Debug("headerFilterConfig is nil") + } + // Then process other headers ctx.Request.Header.All()(func(key, value []byte) bool { keyStr := strings.ToLower(string(key)) @@ -252,14 +295,47 @@ func ConvertToBifrostContext(ctx *fasthttp.RequestCtx, allowDirectKeys bool) (*c } // Normalize header name to lowercase labelName = strings.ToLower(labelName) - // Validate against denylist - if denylist[labelName] { + // Validate against security denylist (always enforced) + if securityDenylist[labelName] { + return true + } + // Apply configurable header filter + if !shouldAllowHeader(labelName) { return true } // Append header value (allow multiple values for the same header) extraHeaders[labelName] = append(extraHeaders[labelName], string(value)) return true } + // Direct header forwarding: when allowlist is configured, any header explicitly + // in the allowlist can be forwarded directly without the x-bf-eh- prefix. + // This enables forwarding arbitrary headers like "anthropic-beta" directly. + // Only applies when allowlist is non-empty (backward compatible). + if headerFilterConfig != nil && len(headerFilterConfig.Allowlist) > 0 { + // Check if this header is explicitly in the allowlist (case-insensitive) + if slices.ContainsFunc(headerFilterConfig.Allowlist, func(s string) bool { + return strings.EqualFold(s, keyStr) + }) { + // Skip reserved x-bf-* headers (handled separately) + if strings.HasPrefix(keyStr, "x-bf-") { + return true + } + // Validate against security denylist (always enforced) + if securityDenylist[keyStr] { + return true + } + // Check denylist (allowlist check already passed by being in allowlist, case-insensitive) + if len(headerFilterConfig.Denylist) > 0 && slices.ContainsFunc(headerFilterConfig.Denylist, func(s string) bool { + return strings.EqualFold(s, keyStr) + }) { + return true + } + // Forward the header directly with its original name + logger.Debug("forwarding header via allowlist: %s = %s", keyStr, string(value)) + extraHeaders[keyStr] = append(extraHeaders[keyStr], string(value)) + return true + } + } // Send back raw response header if keyStr == "x-bf-send-back-raw-response" { if valueStr := string(value); valueStr == "true" { diff --git a/transports/bifrost-http/server/server.go b/transports/bifrost-http/server/server.go index 46e6d6f29..16d1bf7ba 100644 --- a/transports/bifrost-http/server/server.go +++ b/transports/bifrost-http/server/server.go @@ -63,6 +63,7 @@ type ServerCallbacks interface { ReloadPricingManager(ctx context.Context) error ForceReloadPricing(ctx context.Context) error ReloadProxyConfig(ctx context.Context, config *tables.GlobalProxyConfig) error + ReloadHeaderFilterConfig(ctx context.Context, config *tables.GlobalHeaderFilterConfig) error UpdateDropExcessRequests(ctx context.Context, value bool) ReloadTeam(ctx context.Context, id string) (*tables.TableTeam, error) RemoveTeam(ctx context.Context, id string) error @@ -837,6 +838,23 @@ func (s *BifrostHTTPServer) ReloadProxyConfig(ctx context.Context, config *table return nil } +// ReloadHeaderFilterConfig reloads the header filter configuration +func (s *BifrostHTTPServer) ReloadHeaderFilterConfig(ctx context.Context, config *tables.GlobalHeaderFilterConfig) error { + if s.Config == nil { + return fmt.Errorf("config not found") + } + // Store the header filter config in ClientConfig + s.Config.ClientConfig.HeaderFilterConfig = config + allowlistLen := 0 + denylistLen := 0 + if config != nil { + allowlistLen = len(config.Allowlist) + denylistLen = len(config.Denylist) + } + logger.Info("header filter configuration reloaded: allowlist=%d, denylist=%d", allowlistLen, denylistLen) + return nil +} + // RefetchModelsForProvider deletes existing models for a provider and re-fetches them from the provider func (s *BifrostHTTPServer) RefetchModelsForProvider(ctx context.Context, provider schemas.ModelProvider) error { if s.Config == nil || s.Config.PricingManager == nil { diff --git a/transports/changelog.md b/transports/changelog.md index 49c69d7c9..d65266fba 100644 --- a/transports/changelog.md +++ b/transports/changelog.md @@ -1 +1,2 @@ -- feat: add document/file support for Anthropic, Bedrock, and Gemini \ No newline at end of file +- feat: add document/file support for Anthropic, Bedrock, and Gemini +- feat: adds support for allowlist and denylist in config for forward or block headers from forwarding it to providers \ No newline at end of file diff --git a/transports/config.schema.json b/transports/config.schema.json index fa7a1a9e2..91bb67b3c 100644 --- a/transports/config.schema.json +++ b/transports/config.schema.json @@ -80,6 +80,27 @@ "enable_litellm_fallbacks": { "type": "boolean", "description": "Enable litellm-specific fallbacks for text completion for Groq" + }, + "header_filter_config": { + "type": "object", + "description": "Global header filtering configuration for x-bf-eh-* headers forwarded to LLM providers", + "properties": { + "allowlist": { + "type": "array", + "items": { + "type": "string" + }, + "description": "If non-empty, only these headers (from x-bf-eh-* prefix) are allowed to be forwarded" + }, + "denylist": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Headers to always block from being forwarded" + } + }, + "additionalProperties": false } }, "additionalProperties": false diff --git a/transports/version b/transports/version index 573f75b1b..ff81b39cf 100644 --- a/transports/version +++ b/transports/version @@ -1 +1 @@ -1.3.53 \ No newline at end of file +1.3.54 \ No newline at end of file diff --git a/ui/app/workspace/config/views/clientSettingsView.tsx b/ui/app/workspace/config/views/clientSettingsView.tsx index 0d90e2bdb..da1bff11d 100644 --- a/ui/app/workspace/config/views/clientSettingsView.tsx +++ b/ui/app/workspace/config/views/clientSettingsView.tsx @@ -1,10 +1,15 @@ "use client"; +import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@/components/ui/accordion"; import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; import { Switch } from "@/components/ui/switch"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { getErrorMessage, useGetCoreConfigQuery, useGetDroppedRequestsQuery, useUpdateCoreConfigMutation } from "@/lib/store"; -import { CoreConfig } from "@/lib/types/config"; +import { CoreConfig, DefaultGlobalHeaderFilterConfig, GlobalHeaderFilterConfig } from "@/lib/types/config"; +import { cn } from "@/lib/utils"; import { RbacOperation, RbacResource, useRbac } from "@enterprise/lib"; +import { Info, Plus, X } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { toast } from "sonner"; @@ -21,8 +26,44 @@ const defaultConfig: CoreConfig = { max_request_body_size_mb: 100, enable_litellm_fallbacks: false, log_retention_days: 365, + header_filter_config: DefaultGlobalHeaderFilterConfig, }; +// Security headers that cannot be configured in allowlist/denylist +// These headers are always blocked for security reasons regardless of configuration +const SECURITY_HEADERS = [ + "authorization", + "proxy-authorization", + "cookie", + "host", + "content-length", + "connection", + "transfer-encoding", + "x-api-key", + "x-goog-api-key", + "x-bf-api-key", + "x-bf-vk", +]; + +// Helper to check if a header is a security header +function isSecurityHeader(header: string): boolean { + return SECURITY_HEADERS.includes(header.toLowerCase().trim()); +} + +// Helper to compare header filter configs +function headerFilterConfigEqual(a?: GlobalHeaderFilterConfig, b?: GlobalHeaderFilterConfig): boolean { + const aAllowlist = a?.allowlist || []; + const bAllowlist = b?.allowlist || []; + const aDenylist = a?.denylist || []; + const bDenylist = b?.denylist || []; + + if (aAllowlist.length !== bAllowlist.length || aDenylist.length !== bDenylist.length) { + return false; + } + + return aAllowlist.every((v, i) => v === bAllowlist[i]) && aDenylist.every((v, i) => v === bDenylist[i]); +} + export default function ClientSettingsView() { const hasSettingsUpdateAccess = useRbac(RbacResource.Settings, RbacOperation.Update); const [droppedRequests, setDroppedRequests] = useState(0); @@ -40,7 +81,10 @@ export default function ClientSettingsView() { useEffect(() => { if (config) { - setLocalConfig(config); + setLocalConfig({ + ...config, + header_filter_config: config.header_filter_config || DefaultGlobalHeaderFilterConfig, + }); } }, [config]); @@ -48,38 +92,140 @@ export default function ClientSettingsView() { if (!config) return false; return ( localConfig.drop_excess_requests !== config.drop_excess_requests || - localConfig.enable_litellm_fallbacks !== config.enable_litellm_fallbacks + localConfig.enable_litellm_fallbacks !== config.enable_litellm_fallbacks || + !headerFilterConfigEqual(localConfig.header_filter_config, config.header_filter_config) ); }, [config, localConfig]); - const handleConfigChange = useCallback((field: keyof CoreConfig, value: boolean | number | string[]) => { + // Detect security headers in allowlist/denylist + const invalidSecurityHeaders = useMemo(() => { + const allowlist = localConfig.header_filter_config?.allowlist || []; + const denylist = localConfig.header_filter_config?.denylist || []; + const invalidInAllowlist = allowlist.filter((h) => h && isSecurityHeader(h)); + const invalidInDenylist = denylist.filter((h) => h && isSecurityHeader(h)); + return [...new Set([...invalidInAllowlist, ...invalidInDenylist])]; + }, [localConfig.header_filter_config]); + + const hasSecurityHeaderError = invalidSecurityHeaders.length > 0; + + const handleConfigChange = useCallback((field: keyof CoreConfig, value: boolean | number | string[] | GlobalHeaderFilterConfig) => { setLocalConfig((prev) => ({ ...prev, [field]: value })); }, []); const handleSave = useCallback(async () => { + // Defense in depth - don't save if security headers are present + if (hasSecurityHeaderError) { + return; + } + try { - await updateCoreConfig({ ...bifrostConfig!, client_config: localConfig }).unwrap(); + // Clean up empty strings from header filter config + const cleanedConfig = { + ...localConfig, + header_filter_config: { + allowlist: (localConfig.header_filter_config?.allowlist || []).filter((h) => h && h.trim().length > 0), + denylist: (localConfig.header_filter_config?.denylist || []).filter((h) => h && h.trim().length > 0), + }, + }; + + await updateCoreConfig({ ...bifrostConfig!, client_config: cleanedConfig }).unwrap(); toast.success("Client settings updated successfully."); } catch (error) { toast.error(getErrorMessage(error)); } - }, [bifrostConfig, localConfig, updateCoreConfig]); + }, [bifrostConfig, hasSecurityHeaderError, localConfig, updateCoreConfig]); + + // Header filter list handlers + const handleAddAllowlistHeader = useCallback(() => { + setLocalConfig((prev) => ({ + ...prev, + header_filter_config: { + ...prev.header_filter_config, + allowlist: [...(prev.header_filter_config?.allowlist || []), ""], + }, + })); + }, []); + + const handleRemoveAllowlistHeader = useCallback((index: number) => { + setLocalConfig((prev) => ({ + ...prev, + header_filter_config: { + ...prev.header_filter_config, + allowlist: (prev.header_filter_config?.allowlist || []).filter((_, i) => i !== index), + }, + })); + }, []); + + const handleAllowlistChange = useCallback((index: number, value: string) => { + const lowerValue = value.toLowerCase(); + setLocalConfig((prev) => ({ + ...prev, + header_filter_config: { + ...prev.header_filter_config, + allowlist: (prev.header_filter_config?.allowlist || []).map((h, i) => (i === index ? lowerValue : h)), + }, + })); + }, []); + + const handleAddDenylistHeader = useCallback(() => { + setLocalConfig((prev) => ({ + ...prev, + header_filter_config: { + ...prev.header_filter_config, + denylist: [...(prev.header_filter_config?.denylist || []), ""], + }, + })); + }, []); + + const handleRemoveDenylistHeader = useCallback((index: number) => { + setLocalConfig((prev) => ({ + ...prev, + header_filter_config: { + ...prev.header_filter_config, + denylist: (prev.header_filter_config?.denylist || []).filter((_, i) => i !== index), + }, + })); + }, []); + + const handleDenylistChange = useCallback((index: number, value: string) => { + const lowerValue = value.toLowerCase(); + setLocalConfig((prev) => ({ + ...prev, + header_filter_config: { + ...prev.header_filter_config, + denylist: (prev.header_filter_config?.denylist || []).map((h, i) => (i === index ? lowerValue : h)), + }, + })); + }, []); return ( -
+

Client Settings

Configure client behavior and request handling.

- + {hasSecurityHeaderError ? ( + + + + + + + + Remove security header{invalidSecurityHeaders.length > 1 ? "s" : ""}: {invalidSecurityHeaders.join(", ")} + + + ) : ( + + )}
{/* Drop Excess Requests */} -
+
+ {/* Enable LiteLLM Fallbacks */} -
+
+ + {/* Header Filter Section */} +
+
+

Header Forwarding

+

Control which extra headers are forwarded to LLM providers.

+
+ + + + + + + About Header Forwarding + + + +
+

Two ways to forward headers:

+
    +
  • + Prefixed headers: Use{" "} + x-bf-eh-* prefix. For example,{" "} + x-bf-eh-custom-id is forwarded as{" "} + custom-id. +
  • +
  • + Direct headers: Any header explicitly added to the allowlist can be forwarded + directly without the prefix (e.g., anthropic-beta). +
  • +
+
+
+

How allowlist and denylist work:

+
    +
  • + Allowlist empty: Only x-bf-eh-* prefixed headers are forwarded (default behavior) +
  • +
  • + Allowlist configured: Prefixed headers filtered by allowlist, plus any direct header in the allowlist is forwarded +
  • +
  • + Denylist: Headers in the denylist are always blocked from forwarding +
  • +
+
+
+

Important:

+
    +
  • + Allowlist/denylist entries should be the header name without the{" "} + x-bf-eh- prefix +
  • +
  • + Example: To allow x-bf-eh-custom-id or direct{" "} + custom-id, add{" "} + custom-id to the allowlist +
  • +
+
+
+
+ + + + + + Security Note + + + +

Some headers are always blocked for security reasons regardless of configuration. These headers cannot be added to the allowlist or denylist:

+

+ authorization, proxy-authorization, cookie, host, content-length, connection, transfer-encoding, x-api-key, x-goog-api-key, + x-bf-api-key, x-bf-vk +

+
+
+
+ + {/* Allowlist Section */} +
+
+

Allowlist

+

+ Headers to allow. Enter names without the x-bf-eh- prefix. + Any header in this list can also be sent directly without the prefix. +

+
+ +
+ {(localConfig.header_filter_config?.allowlist || []).map((header, index) => ( +
+ handleAllowlistChange(index, e.target.value)} + disabled={!hasSettingsUpdateAccess} + /> + +
+ ))} + +
+
+ + {/* Denylist Section */} +
+
+

Denylist

+

+ Headers to block. Enter names without the x-bf-eh- prefix. + Applies to both prefixed and direct header forwarding. +

+
+ +
+ {(localConfig.header_filter_config?.denylist || []).map((header, index) => ( +
+ handleDenylistChange(index, e.target.value)} + disabled={!hasSettingsUpdateAccess} + /> + +
+ ))} + +
+
+
); } diff --git a/ui/lib/types/config.ts b/ui/lib/types/config.ts index 9ccd60a25..f375e8d5b 100644 --- a/ui/lib/types/config.ts +++ b/ui/lib/types/config.ts @@ -299,6 +299,19 @@ export const DefaultGlobalProxyConfig: GlobalProxyConfig = { enable_for_api: false, }; +// Global header filter configuration matching Go's tables.GlobalHeaderFilterConfig +// Controls which headers with the x-bf-eh-* prefix are forwarded to LLM providers +export interface GlobalHeaderFilterConfig { + allowlist?: string[]; // If non-empty, only these headers are allowed + denylist?: string[]; // Headers to always block +} + +// Default GlobalHeaderFilterConfig +export const DefaultGlobalHeaderFilterConfig: GlobalHeaderFilterConfig = { + allowlist: [], + denylist: [], +}; + // Restart required configuration export interface RestartRequiredConfig { required: boolean; @@ -310,7 +323,7 @@ export interface BifrostConfig { client_config: CoreConfig; framework_config: FrameworkConfig; auth_config?: AuthConfig; - proxy_config?: GlobalProxyConfig; + proxy_config?: GlobalProxyConfig; restart_required?: RestartRequiredConfig; is_db_connected: boolean; is_cache_connected: boolean; @@ -332,6 +345,7 @@ export interface CoreConfig { allowed_origins: string[]; max_request_body_size_mb: number; enable_litellm_fallbacks: boolean; + header_filter_config?: GlobalHeaderFilterConfig; } // Semantic cache configuration types diff --git a/ui/lib/types/schemas.ts b/ui/lib/types/schemas.ts index 64418aa2e..9bee6c405 100644 --- a/ui/lib/types/schemas.ts +++ b/ui/lib/types/schemas.ts @@ -677,6 +677,18 @@ export const globalProxyFormSchema = z.object({ proxy_config: globalProxyConfigSchema, }); +// Global header filter configuration schema +// Controls which headers with the x-bf-eh-* prefix are forwarded to LLM providers +export const globalHeaderFilterConfigSchema = z.object({ + allowlist: z.array(z.string()).optional(), // If non-empty, only these headers are allowed + denylist: z.array(z.string()).optional(), // Headers to always block +}); + +// Global header filter form schema for the HeaderFilterView +export const globalHeaderFilterFormSchema = z.object({ + header_filter_config: globalHeaderFilterConfigSchema, +}); + // Export type inference helpers export type MCPClientUpdateSchema = z.infer; export type ModelProviderKeySchema = z.infer; @@ -694,3 +706,5 @@ export type PerformanceFormSchema = z.infer; export type CustomProviderConfigSchema = z.infer; export type GlobalProxyConfigSchema = z.infer; export type GlobalProxyFormSchema = z.infer; +export type GlobalHeaderFilterConfigSchema = z.infer; +export type GlobalHeaderFilterFormSchema = z.infer;