Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions docs/deployment-guides/config-json/schema-reference.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ icon: "brackets-curly"
---

<Note>
The live schema is published at [`https://www.getbifrost.ai/schema`](https://www.getbifrost.ai/schema). Add `"$schema": "https://www.getbifrost.ai/schema"` to your `config.json` for IDE autocomplete and inline validation.
The live schema is published at [`https://www.getbifrost.ai/schema`](https://www.getbifrost.ai/schema). Add `"$schema": "https://www.getbifrost.ai/schema"` to your `config.json` for IDE autocomplete and inline validation, or point it to a mirrored HTTP(S) URL, `file://` URL, or filesystem path in isolated deployments. You can also set the `BIFROST_SCHEMA_URL` environment variable, which takes precedence over the `$schema` value. When mirroring, snapshot a schema published by a Bifrost release that supports custom `$schema` values; older schema copies pin `$schema` to the public URL and will flag a mirrored location as invalid in IDEs.
</Note>

This page is a concise reference for every top-level key in `config.json`. Click the **Guide** links for full field-by-field documentation.
Expand All @@ -16,7 +16,7 @@ This page is a concise reference for every top-level key in `config.json`. Click

| Key | Type | Description | Guide |
|-----|------|-------------|-------|
| `$schema` | string | Schema URL for IDE validation. Set to `"https://www.getbifrost.ai/schema"` | - |
| `$schema` | string | Schema location for IDE validation. Defaults to `"https://www.getbifrost.ai/schema"`; isolated deployments can use a mirrored URL, `file://` URL, or filesystem path. | - |
| `version` | integer | Compatibility switch for empty allow-list arrays. Omit for current v2 semantics. | [`version`](#version) |
| `source_of_truth` | string | Startup reconciliation mode for DB-backed `config.json`: `"split"` or `"config.json"` | [Source of Truth](/deployment-guides/config-json/source-of-truth) |
| `encryption_key` | string | Optional AES-256 key (derived via Argon2id). Accepts `env.VAR` prefix and is also read from `BIFROST_ENCRYPTION_KEY`. If omitted, data is stored in plaintext. | [Client](/deployment-guides/config-json/client#encryption-key) |
Expand Down
2 changes: 1 addition & 1 deletion helm-charts/bifrost/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ apiVersion: v2
name: bifrost
description: A Helm chart for deploying Bifrost - AI Gateway with unified interface for multiple providers
type: application
version: 2.1.26
version: 2.1.27
appVersion: "1.5.12"
keywords:
- ai
Expand Down
6 changes: 5 additions & 1 deletion helm-charts/bifrost/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@

Official Helm charts for deploying [Bifrost](https://github.com/maximhq/bifrost) - a high-performance AI gateway with unified interface for multiple providers.

**Latest Version:** 2.1.26
**Latest Version:** 2.1.27

## Changelog

### 2.1.27

- Added `bifrost.schemaUrl` to override the generated `config.json` `$schema` location for isolated deployments. It accepts HTTP(S), `file://`, or filesystem paths. When set, it is also exported as `BIFROST_SCHEMA_URL` in the pod; when empty (default), the env var is not injected and the public schema URL is used.

### 2.1.26

- Added `bifrost.client.mcpServerAuthMode` (`headers` | `both` | `oauth`) and `bifrost.client.oauth2ServerConfig` (`issuerUrl`, `authCodeTtl`, `accessTokenTtl`, `disableVkIdentity`) to control how `/mcp` authenticates inbound MCP clients. Renders into `client.mcp_server_auth_mode` and `client.oauth2_server_config`. `authCodeTtl` is capped at 900 seconds.
Expand Down
2 changes: 1 addition & 1 deletion helm-charts/bifrost/templates/_helpers.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -201,7 +201,7 @@ false
{{- end -}}

{{- define "bifrost.config" -}}
{{- $config := dict "$schema" "https://www.getbifrost.ai/schema" }}
{{- $config := dict "$schema" (.Values.bifrost.schemaUrl | default "https://www.getbifrost.ai/schema") }}
{{- if .Values.bifrost.sourceOfTruth }}
{{- $_ := set $config "source_of_truth" .Values.bifrost.sourceOfTruth }}
{{- end }}
Expand Down
4 changes: 4 additions & 0 deletions helm-charts/bifrost/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,10 @@ spec:
value: {{ .Values.bifrost.logLevel | quote }}
- name: LOG_STYLE
value: {{ .Values.bifrost.logStyle | quote }}
{{- if .Values.bifrost.schemaUrl }}
- name: BIFROST_SCHEMA_URL
value: {{ .Values.bifrost.schemaUrl | quote }}
{{- end }}
{{- if .Values.bifrost.encryptionKeySecret.name }}
- name: BIFROST_ENCRYPTION_KEY
valueFrom:
Expand Down
5 changes: 4 additions & 1 deletion helm-charts/bifrost/templates/stateful.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,10 @@ spec:
value: {{ .Values.bifrost.logLevel | quote }}
- name: LOG_STYLE
value: {{ .Values.bifrost.logStyle | quote }}
{{- if .Values.bifrost.schemaUrl }}
- name: BIFROST_SCHEMA_URL
value: {{ .Values.bifrost.schemaUrl | quote }}
{{- end }}
{{- if .Values.bifrost.encryptionKeySecret.name }}
- name: BIFROST_ENCRYPTION_KEY
valueFrom:
Expand Down Expand Up @@ -319,4 +323,3 @@ spec:
requests:
storage: {{ .Values.storage.persistence.size }}
{{- end }}

5 changes: 5 additions & 0 deletions helm-charts/bifrost/values.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,11 @@
"description": "Short label (max 10 characters) displayed in the management UI sidebar to identify the environment (e.g. \"staging\", \"prod\"). Written into config.json as env_label.",
"maxLength": 10
},
"schemaUrl": {
"type": "string",
"default": "",
"description": "Schema location written to config.json $schema and exported as BIFROST_SCHEMA_URL. Leave empty to use the default public URL without injecting the env var. Set for isolated deployments that mirror the Bifrost schema internally. Accepts an HTTP(S) URL, file:// URL, or filesystem path."
},
"sourceOfTruth": {
"type": "string",
"enum": ["split", "config.json"],
Expand Down
7 changes: 7 additions & 0 deletions helm-charts/bifrost/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,13 @@ bifrost:
logStyle: json
# envLabel: staging # Short label (max 10 chars) shown in the UI sidebar to identify the environment

# Schema location written to config.json $schema and exported as BIFROST_SCHEMA_URL.
# Leave empty to use the default (https://www.getbifrost.ai/schema); set it only for
# isolated deployments that mirror the schema internally. When empty, the env var is
# not injected, so a $schema override in a mounted config.json still takes effect.
# Accepts an HTTP(S) URL, file:// URL, or filesystem path.
schemaUrl: ""

# Controls how config.json is reconciled with the database on startup.
# "split" (default): existing merge behavior — file and DB rows coexist.
# "config.json": sections explicitly present in the file are authoritative;
Expand Down
4 changes: 3 additions & 1 deletion terraform/modules/bifrost/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,8 @@ The module supports three ways to provide Bifrost configuration, which are merge

Individual variables always take precedence over the base config. This lets you keep secrets out of your config file and inject them via Terraform variables or a secrets manager.

Set `schema_url` to write a mirrored schema location into `$schema` for isolated deployments. It accepts an HTTP(S) URL, `file://` URL, or filesystem path, and defaults to `https://www.getbifrost.ai/schema`.

### Configurable Sections

All 18 top-level properties from the [Bifrost config schema](../../../transports/config.schema.json) are exposed as Terraform variables:
Expand Down Expand Up @@ -157,7 +159,7 @@ Test files are in `tests/` and cover all 7 deployment targets:
| File | Coverage |
|-----------------------------|--------------------------------------------------|
| `root_validation.tftest.hcl`| Valid/invalid cloud_provider + service combos |
| `config_merging.tftest.hcl` | Config precedence, schema URL injection |
| `config_merging.tftest.hcl` | Config precedence, schema location injection |
| `aws_ecs.tftest.hcl` | ECS: ALB, autoscaling, private subnets |
| `aws_eks.tftest.hcl` | EKS: cluster, HPA, ingress, HTTPS, nodes |
| `aws_shared.tftest.hcl` | VPC/SG creation vs existing, ECS isolation |
Expand Down
7 changes: 6 additions & 1 deletion terraform/modules/bifrost/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,15 @@ locals {
{}
)

# Schema precedence: explicit schema_url var > $schema already in base config > public default.
# Only fall through to the public URL when the base config has no $schema, so a mirrored-schema
# setup provided via config_json/config_json_file is never silently rewritten.
schema_url = coalesce(var.schema_url, try(local.base_config["$schema"], null), "https://www.getbifrost.ai/schema")

# Terraform variable overrides (non-null values only)
overrides = {
for k, v in {
"$schema" = "https://www.getbifrost.ai/schema"
"$schema" = local.schema_url
encryption_key = var.encryption_key
auth_config = var.auth_config
client = var.client
Expand Down
17 changes: 16 additions & 1 deletion terraform/modules/bifrost/tests/config_merging.tftest.hcl
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,22 @@ run "schema_url_injected" {
}
assert {
condition = jsondecode(output.config_json)["$schema"] == "https://www.getbifrost.ai/schema"
error_message = "Schema URL should always be injected"
error_message = "Schema location should always be injected"
}
}

run "schema_url_override" {
command = plan
module { source = "./tests/setup" }
variables {
cloud_provider = "aws"
service = "ecs"
region = "us-east-1"
schema_url = "https://schema.internal/bifrost"
}
assert {
condition = jsondecode(output.config_json)["$schema"] == "https://schema.internal/bifrost"
error_message = "schema_url should override the default schema location"
}
}

Expand Down
39 changes: 20 additions & 19 deletions terraform/modules/bifrost/tests/setup/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -38,25 +38,26 @@ module "bifrost" {
service = var.service

# Config
config_json = var.config_json
config_json_file = var.config_json_file
encryption_key = var.encryption_key
auth_config = var.auth_config
client = var.client
framework = var.framework
providers_config = var.providers_config
governance = var.governance
mcp = var.mcp
vector_store = var.vector_store
config_store = var.config_store
logs_store = var.logs_store
cluster_config = var.cluster_config
scim_config = var.scim_config
config_json = var.config_json
config_json_file = var.config_json_file
schema_url = var.schema_url
encryption_key = var.encryption_key
auth_config = var.auth_config
client = var.client
framework = var.framework
providers_config = var.providers_config
governance = var.governance
mcp = var.mcp
vector_store = var.vector_store
config_store = var.config_store
logs_store = var.logs_store
cluster_config = var.cluster_config
scim_config = var.scim_config
load_balancer_config = var.load_balancer_config
guardrails_config = var.guardrails_config
plugins = var.plugins
audit_logs = var.audit_logs
websocket = var.websocket
guardrails_config = var.guardrails_config
plugins = var.plugins
audit_logs = var.audit_logs
websocket = var.websocket

# Image
image_tag = var.image_tag
Expand Down Expand Up @@ -97,7 +98,7 @@ module "bifrost" {
volume_size_gb = var.volume_size_gb

# Cloud-specific
gcp_project_id = var.gcp_project_id
gcp_project_id = var.gcp_project_id
azure_resource_group_name = var.azure_resource_group_name

# Generic K8s
Expand Down
2 changes: 2 additions & 0 deletions terraform/modules/bifrost/tests/setup/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ variable "config_json" {

variable "config_json_file" { default = null }

variable "schema_url" { default = null }

variable "encryption_key" {
type = string
default = null
Expand Down
6 changes: 6 additions & 0 deletions terraform/modules/bifrost/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,12 @@ variable "config_json_file" {
default = null
}

variable "schema_url" {
description = "Schema location written to config.json $schema. Override for isolated deployments that mirror the Bifrost schema internally. Accepts an HTTP(S) URL, file:// URL, or filesystem path. When null, an existing $schema in config_json/config_json_file is preserved, otherwise the public Bifrost schema URL is injected."
type = string
default = null
}

# --- Config: individual sections (each mirrors a top-level property from config.schema.json) ---
variable "encryption_key" {
description = "Encryption key for sensitive data. Accepts any string; a secure 32-byte AES-256 key will be derived using Argon2id KDF."
Expand Down
6 changes: 3 additions & 3 deletions transports/bifrost-http/lib/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -807,10 +807,10 @@ func LoadConfig(ctx context.Context, configDirPath string) (*Config, error) {
if err := json.Unmarshal(data, &schema); err != nil {
return nil, fmt.Errorf("failed to unmarshal schema: %w", err)
}
if schema["$schema"] != "https://www.getbifrost.ai/schema" {
if schemaURL, ok := schema["$schema"].(string); !ok || strings.TrimSpace(schemaURL) == "" {
yellowColor := "\033[33m"
resetColor := "\033[0m"
message := fmt.Sprintf("config file %s does not include \"$schema\":\"https://www.getbifrost.ai/schema\". Use our official schema file to avoid unexpected behavior.", absConfigFilePath)
message := fmt.Sprintf("config file %s does not include a \"$schema\" location. Set it to %q or your mirrored schema location to enable IDE validation.", absConfigFilePath, DefaultConfigSchemaURL)
boxWidth := 100
contentWidth := boxWidth - 4
words := strings.Fields(message)
Expand Down Expand Up @@ -839,7 +839,7 @@ func LoadConfig(ctx context.Context, configDirPath string) (*Config, error) {
}
fmt.Printf("%s╚%s╝%s\n", yellowColor, strings.Repeat("═", boxWidth-2), resetColor)
fmt.Println("")
logger.Warn("config file %s does not include \"$schema\":\"https://www.getbifrost.ai/schema\". Use our official schema file to avoid unexpected behavior.", absConfigFilePath)
logger.Warn("config file %s does not include a \"$schema\" location", absConfigFilePath)
}
// Parse config data
if err := json.Unmarshal(data, &configData); err != nil {
Expand Down
102 changes: 87 additions & 15 deletions transports/bifrost-http/lib/validator.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,75 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"

"github.com/santhosh-tekuri/jsonschema/v6"
)

const (
DefaultConfigSchemaURL = "https://www.getbifrost.ai/schema"
ConfigSchemaURLEnv = "BIFROST_SCHEMA_URL"
)

const schemaFetchTimeout = 10 * time.Second

func configuredConfigSchemaLocation() string {
return strings.TrimSpace(os.Getenv(ConfigSchemaURLEnv))
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Comment thread
impoiler marked this conversation as resolved.
Comment thread
impoiler marked this conversation as resolved.

// loadSchemaFromLocation reads schema bytes from an HTTP(S) URL, a file:// URL,
// or a plain filesystem path.
func loadSchemaFromLocation(location string) ([]byte, error) {
if strings.HasPrefix(location, "http://") || strings.HasPrefix(location, "https://") {
client := http.Client{Timeout: schemaFetchTimeout}
resp, err := client.Get(location)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, fmt.Errorf("unexpected HTTP status %d fetching schema", resp.StatusCode)
}
return io.ReadAll(resp.Body)
}
return os.ReadFile(filePathFromSchemaLocation(location))
}
Comment on lines +32 to +46

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Cap the HTTP schema response size.

loadSchemaFromLocation sets a fetch timeout but never bounds the response body size before io.ReadAll(resp.Body). A misconfigured or malicious schema location (env var, or $schema sourced from config data) can return an arbitrarily large body, exhausting memory.

🛡️ Proposed fix to cap the response size
-		defer resp.Body.Close()
-		if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
-			return nil, fmt.Errorf("unexpected HTTP status %d fetching schema", resp.StatusCode)
-		}
-		return io.ReadAll(resp.Body)
+		defer resp.Body.Close()
+		if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
+			return nil, fmt.Errorf("unexpected HTTP status %d fetching schema", resp.StatusCode)
+		}
+		const maxSchemaBytes = 1 << 20 // 1MB
+		return io.ReadAll(io.LimitReader(resp.Body, maxSchemaBytes+1))

As per path instructions, "enforce timeouts and size limits" for untrusted input handling in Go files.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
func loadSchemaFromLocation(location string) ([]byte, error) {
if strings.HasPrefix(location, "http://") || strings.HasPrefix(location, "https://") {
client := http.Client{Timeout: schemaFetchTimeout}
resp, err := client.Get(location)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, fmt.Errorf("unexpected HTTP status %d fetching schema", resp.StatusCode)
}
return io.ReadAll(resp.Body)
}
return os.ReadFile(filePathFromSchemaLocation(location))
}
func loadSchemaFromLocation(location string) ([]byte, error) {
if strings.HasPrefix(location, "http://") || strings.HasPrefix(location, "https://") {
client := http.Client{Timeout: schemaFetchTimeout}
resp, err := client.Get(location)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, fmt.Errorf("unexpected HTTP status %d fetching schema", resp.StatusCode)
}
const maxSchemaBytes = 1 << 20 // 1MB
return io.ReadAll(io.LimitReader(resp.Body, maxSchemaBytes+1))
}
return os.ReadFile(filePathFromSchemaLocation(location))
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@transports/bifrost-http/lib/validator.go` around lines 32 - 46, Cap the HTTP
schema response body in loadSchemaFromLocation before reading it. Keep the
existing timeout and status checks, but wrap resp.Body with a size limit and
return an error if the schema exceeds that bound instead of calling io.ReadAll
directly. Apply this only in the http/https branch of loadSchemaFromLocation,
preserving the local file path behavior via filePathFromSchemaLocation.

Source: Path instructions


// filePathFromSchemaLocation converts a file:// URL to a filesystem path,
// honoring the optional localhost host (RFC 8089) and percent-encoding.
// Plain paths are returned unchanged.
func filePathFromSchemaLocation(location string) string {
if !strings.HasPrefix(location, "file://") {
return location
}
parsed, err := url.Parse(location)
if err != nil {
return strings.TrimPrefix(location, "file://")
}
if parsed.Opaque != "" {
return parsed.Opaque
}
if parsed.Host != "" && parsed.Host != "localhost" {
return parsed.Host + parsed.Path
}
return parsed.Path
}

func schemaLocationFromConfig(data []byte) string {
var config map[string]any
if err := json.Unmarshal(data, &config); err != nil {
return ""
}
schemaLocation, ok := config["$schema"].(string)
if !ok {
return ""
}
return strings.TrimSpace(schemaLocation)
}

// localSchemaCandidates lists paths (relative to CWD) where config.schema.json may be found
// when running from a source checkout. Checked in order before falling back to the remote URL.
var localSchemaCandidates = []string{
Expand All @@ -36,27 +100,35 @@ func tryLoadLocalSchema() []byte {

// ValidateConfigSchema validates config data against the JSON schema.
// Returns nil if valid, or a formatted error describing all validation failures.
// An optional schemaOverride can be provided to use a local schema instead of fetching from the remote URL.
// An optional schemaOverride can be provided to use a local schema instead of loading from the configured location.
// Schema resolution order: schemaOverride arg, BIFROST_SCHEMA_URL env, the config's
// own non-default $schema value, a local source-checkout copy, the default public URL.
func ValidateConfigSchema(data []byte, schemaOverride ...[]byte) error {
var configSchemaJSONBytes []byte
if len(schemaOverride) > 0 && len(schemaOverride[0]) > 0 {
configSchemaJSONBytes = schemaOverride[0]
} else if localSchema := tryLoadLocalSchema(); localSchema != nil {
// Prefer the local schema file from the source checkout when available.
// This avoids validating against a potentially stale remote schema.
configSchemaJSONBytes = localSchema
} else {
// Pulling config.schema from https://www.getbifrost.ai/schema
configSchemaJSON, err := http.Get("https://www.getbifrost.ai/schema")
if err != nil {
return fmt.Errorf("failed to get config schema: %w", err)
schemaLocation := configuredConfigSchemaLocation()
if schemaLocation == "" {
if fromConfig := schemaLocationFromConfig(data); fromConfig != "" && fromConfig != DefaultConfigSchemaURL {
schemaLocation = fromConfig
}
}
if schemaLocation == "" {
if localSchema := tryLoadLocalSchema(); localSchema != nil {
// Prefer the local schema file from the source checkout when available.
// This avoids validating against a potentially stale remote schema.
configSchemaJSONBytes = localSchema
} else {
schemaLocation = DefaultConfigSchemaURL
}
}
defer configSchemaJSON.Body.Close()
var readErr error
configSchemaJSONBytes, readErr = io.ReadAll(configSchemaJSON.Body)
if readErr != nil {
logger.Warn("failed to download config schema: %v. running without config.json schema validation", readErr)
return nil
if configSchemaJSONBytes == nil {
var err error
configSchemaJSONBytes, err = loadSchemaFromLocation(schemaLocation)
if err != nil {
return fmt.Errorf("failed to get config schema from %s: %w", schemaLocation, err)
}
}
}
// Parse the schema JSON
Expand Down
Loading
Loading