Skip to content
Open
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
9 changes: 8 additions & 1 deletion core/providers/utils/utils.go
Original file line number Diff line number Diff line change
Expand Up @@ -1230,6 +1230,13 @@ func GetPathFromContext(ctx context.Context, defaultPath string) string {
return defaultPath
}

// IsAbsoluteRequestURL reports whether a request-path override is a full URL (scheme and host)
// that GetRequestPath sends requests to directly, rather than a path on the provider's base URL.
func IsAbsoluteRequestURL(s string) bool {
u, err := url.Parse(strings.TrimSpace(s))
return err == nil && u != nil && u.IsAbs() && u.Host != ""
}

// GetRequestPath gets the request path from the context, if it exists, checking for path overrides in the custom provider config.
// It returns the resolved value and a boolean indicating whether the value is a full absolute URL.
// If the boolean is false, the returned string is a path (leading slash ensured).
Expand All @@ -1252,7 +1259,7 @@ func GetRequestPath(ctx context.Context, defaultPath string, customProviderConfi
}

// Treat absolute URLs with scheme+host as full URLs.
if u, err := url.Parse(override); err == nil && u != nil && u.IsAbs() && u.Host != "" {
if IsAbsoluteRequestURL(override) {
return override, true
}

Expand Down
18 changes: 18 additions & 0 deletions core/providers/utils/utils_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3693,3 +3693,21 @@ func TestRewriteToolSchemaPatterns_DescendsIntoPlainMapSchemas(t *testing.T) {
t.Fatal("an unchanged plain-map schema was copied instead of shared")
}
}

// TestIsAbsoluteRequestURL pins the rule GetRequestPath and the management API's auth guard
// share: only a scheme plus host makes a request-path override a full destination URL.
func TestIsAbsoluteRequestURL(t *testing.T) {
cases := map[string]bool{
"https://evil.example.com/v1/chat": true,
" http://10.0.0.5:8080/x ": true,
"/v2/chat/completions": false,
"v2/chat/completions": false,
"https:///no-host": false,
"": false,
}
for in, want := range cases {
if got := IsAbsoluteRequestURL(in); got != want {
t.Errorf("IsAbsoluteRequestURL(%q) = %v, want %v", in, got, want)
}
}
}
13 changes: 12 additions & 1 deletion docs/openapi/paths/management/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,13 @@ proxy-config:
put:
operationId: updateProxyConfig
summary: Update proxy configuration
description: Updates the global proxy configuration.
description: |
Updates the global proxy configuration.
Setting a new proxy url or turning on skip_tls_verify requires a genuinely
authenticated admin session - it is refused with 403 if dashboard authentication is
disabled or unconfigured, even though other management endpoints remain reachable in
that state. Saving other fields with the stored url, disabling the proxy, or turning
skip_tls_verify off is allowed.
tags:
- Configuration
requestBody:
Expand All @@ -125,6 +131,11 @@ proxy-config:
$ref: '../../schemas/management/common.yaml#/SuccessResponse'
'400':
$ref: '../../openapi.yaml#/components/responses/BadRequest'
'403':
description: >-
A new proxy url was supplied or skip_tls_verify was turned on, and dashboard
authentication is disabled or unconfigured (the request was not genuinely
authenticated)
'500':
$ref: '../../openapi.yaml#/components/responses/InternalError'

Expand Down
61 changes: 60 additions & 1 deletion docs/openapi/paths/management/providers.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,17 @@ providers:
post:
operationId: addProvider
summary: Add a new provider
description: Adds a new provider with the specified configuration.
description: |
Adds a new provider with the specified configuration.
Setting network_config.base_url or network_config.allow_private_network requires a
genuinely authenticated admin session - it is refused with 403 if dashboard
authentication is disabled or unconfigured, even though other management endpoints
remain reachable in that state. allow_private_network is guarded even without a base
URL, because it also governs which addresses key-level URLs (Ollama/SGL/VLLM) may reach.
The same applies to settings that let a third party read credentials in flight:
proxy_config.url, proxy_config.ca_cert_pem, network_config.ca_cert_pem,
network_config.insecure_skip_verify, and any absolute (scheme and host)
custom_provider_config.request_path_overrides value.
tags:
- Providers
requestBody:
Expand All @@ -42,6 +52,12 @@ providers:
$ref: '../../schemas/management/providers.yaml#/ProviderResponse'
'400':
$ref: '../../openapi.yaml#/components/responses/BadRequest'
'403':
description: >-
network_config.base_url, allow_private_network, a proxy URL, a CA certificate,
insecure_skip_verify, or an absolute request path override was supplied and dashboard
authentication is disabled or unconfigured (the request was not genuinely
authenticated)
'409':
description: Provider already exists
content:
Expand Down Expand Up @@ -92,6 +108,16 @@ providers-by-name:
description: |
Updates a provider's configuration. Expects ALL fields to be provided,
including both edited and non-edited fields. Partial updates are not supported.
Changing network_config.base_url, or turning on network_config.allow_private_network
(with or without a base URL), requires a genuinely authenticated admin session - it is
refused with 403 if dashboard authentication is disabled or unconfigured, even though
other management endpoints remain reachable in that state. The same applies to setting
or changing proxy_config.url, proxy_config.ca_cert_pem, network_config.ca_cert_pem, or
an absolute (scheme and host) custom_provider_config.request_path_overrides value, and
to turning on network_config.insecure_skip_verify. Sending the stored values back
unchanged (including redacted placeholders), clearing the base URL, proxy or an
override, path-only overrides, or turning allow_private_network or
insecure_skip_verify off is allowed.
tags:
- Providers
parameters:
Expand Down Expand Up @@ -119,6 +145,12 @@ providers-by-name:
$ref: '../../schemas/management/providers.yaml#/ProviderResponse'
'400':
$ref: '../../openapi.yaml#/components/responses/BadRequest'
'403':
description: >-
network_config.base_url, a proxy URL, a CA certificate, or an absolute request path
override was changed, or allow_private_network or insecure_skip_verify was turned on,
and dashboard authentication is disabled or unconfigured (the request was not
genuinely authenticated)
'500':
$ref: '../../openapi.yaml#/components/responses/InternalError'

Expand Down Expand Up @@ -198,6 +230,15 @@ provider-keys:
Creates a new API key for the specified provider. The key `id` is auto-generated
if omitted. `enabled` defaults to `true` if omitted. `value` is required and must
not be empty. Keys cannot be created on keyless providers.
Setting any field that chooses the host Bifrost sends this key's credentials to
requires a genuinely authenticated admin session - it is refused with 403 if dashboard
authentication is disabled or unconfigured, even though other management endpoints
remain reachable in that state.
Guarded fields: ollama_key_config.url, sgl_key_config.url, vllm_key_config.url,
azure_key_config.endpoint, databricks_key_config.workspace_url,
github_copilot_key_config.github_domain,
bedrock_key_config.endpoints.*, bedrock_mantle_key_config.endpoints.*, and
aliases.<name>.endpoint.
tags:
- Providers
parameters:
Expand Down Expand Up @@ -225,6 +266,10 @@ provider-keys:
$ref: '../../schemas/management/providers.yaml#/Key'
'400':
$ref: '../../openapi.yaml#/components/responses/BadRequest'
'403':
description: >-
A key endpoint field was set, changed, or removed and dashboard authentication is
disabled or unconfigured (the request was not genuinely authenticated)
'404':
description: Provider not found
content:
Expand Down Expand Up @@ -288,6 +333,16 @@ provider-key-by-id:
Updates an existing key. Send the full key object. Redacted values sent back
unchanged are automatically preserved (the server merges them with the stored
raw values).
Setting, changing, or removing any field that chooses the host Bifrost sends this
key's credentials to requires a genuinely authenticated admin session - it is refused
with 403 if dashboard authentication is disabled or unconfigured, even though other
management endpoints remain reachable in that state. Sending the stored values back
unchanged is allowed.
Guarded fields: ollama_key_config.url, sgl_key_config.url, vllm_key_config.url,
azure_key_config.endpoint, databricks_key_config.workspace_url,
github_copilot_key_config.github_domain,
bedrock_key_config.endpoints.*, bedrock_mantle_key_config.endpoints.*, and
aliases.<name>.endpoint.
tags:
- Providers
parameters:
Expand Down Expand Up @@ -321,6 +376,10 @@ provider-key-by-id:
$ref: '../../schemas/management/providers.yaml#/Key'
'400':
$ref: '../../openapi.yaml#/components/responses/BadRequest'
'403':
description: >-
A key endpoint field was set, changed, or removed and dashboard authentication is
disabled or unconfigured (the request was not genuinely authenticated)
'404':
description: Provider or key not found
content:
Expand Down
34 changes: 34 additions & 0 deletions transports/bifrost-http/handlers/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -1072,6 +1072,21 @@ func (h *ConfigHandler) updateProxyConfig(ctx *fasthttp.RequestCtx) {
return
}

// Under the fail-open bypass (dashboard auth disabled/unconfigured), refuse to point the
// global proxy somewhere new or to stop verifying its TLS: either lets whoever runs the
// proxy read the provider credentials of every proxied request.
if isAuthBypassed(ctx) {
existingConfig, err := h.store.ConfigStore.GetProxyConfig(ctx)
if err != nil && !errors.Is(err, configstore.ErrNotFound) {
SendError(ctx, fasthttp.StatusInternalServerError, fmt.Sprintf("failed to get existing proxy config: %v", err))
return
}
if changed := globalProxyInterceptionChanges(existingConfig, payload); len(changed) > 0 {
SendError(ctx, fasthttp.StatusForbidden, fmt.Sprintf("Changing the global proxy (%s) requires an authenticated admin session; dashboard auth is currently disabled or unconfigured. Enable dashboard authentication first.", strings.Join(changed, ", ")))
return
}
}

// Validate proxy config
if payload.Enabled {
// Validate proxy type
Expand Down Expand Up @@ -1302,3 +1317,22 @@ func validateGlobalToolSyncIntervalMinutes(minutes int) error {
}
return nil
}

// globalProxyInterceptionChanges returns the global proxy settings next adds or changes,
// relative to old (nil when none is stored), that widen who can read proxied traffic: a new
// proxy URL or TLS verification turned off. Keeping the stored URL while editing other fields,
// disabling the proxy, or turning verification back on is not reported.
func globalProxyInterceptionChanges(old *configstoreTables.GlobalProxyConfig, next configstoreTables.GlobalProxyConfig) []string {
var prev configstoreTables.GlobalProxyConfig
if old != nil {
prev = *old
}
var changed []string
if next.URL != "" && next.URL != prev.URL {
changed = append(changed, "url")
}
if next.SkipTLSVerify && !prev.SkipTLSVerify {
changed = append(changed, "skip_tls_verify")
}
return changed
}
48 changes: 48 additions & 0 deletions transports/bifrost-http/handlers/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (
"github.com/stretchr/testify/require"
"github.com/valyala/fasthttp"

"github.com/maximhq/bifrost/core/schemas"
"github.com/maximhq/bifrost/framework/configstore"
configtables "github.com/maximhq/bifrost/framework/configstore/tables"
"github.com/maximhq/bifrost/framework/modelcatalog"
Expand Down Expand Up @@ -128,3 +129,50 @@ func TestUpdateConfig_FrameworkConfigStoreFailureLeavesRuntimeUnchanged(t *testi
require.Equal(t, fasthttp.StatusInternalServerError, ctx.Response.StatusCode(), string(ctx.Response.Body()))
assert.Same(t, before, cfg.FrameworkConfig, "runtime framework config must not change when the store write fails")
}

// TestUpdateProxyConfig_InterceptionGuardWhenAuthBypassed pins that the fail-open bypass
// cannot point the global proxy at a caller-chosen host or turn off its TLS verification:
// either lets a third party read provider credentials for every proxied request. Saving
// other fields against the stored proxy URL must still go through.
func TestUpdateProxyConfig_InterceptionGuardWhenAuthBypassed(t *testing.T) {
SetLogger(&mockLogger{})
const storedURL = "http://10.0.0.5:3128"
cases := []struct {
name string
body string
want403 bool
}{
{name: "same url, timeout edit", body: `{"enabled":true,"type":"http","url":"` + storedURL + `","timeout":30,"enable_for_inference":true}`, want403: false},
{name: "url changed", body: `{"enabled":true,"type":"http","url":"http://evil.example.com:8080","timeout":10,"enable_for_inference":true}`, want403: true},
{name: "skip_tls_verify turned on", body: `{"enabled":true,"type":"http","url":"` + storedURL + `","timeout":10,"skip_tls_verify":true,"enable_for_inference":true}`, want403: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
store := newRealOAuth2Store(t)
require.NoError(t, store.UpdateProxyConfig(context.Background(), &configtables.GlobalProxyConfig{
Enabled: true, Type: "http", URL: storedURL, Timeout: 10, EnableForInference: true,
}))
cfg := newTestOAuth2Config(store, configtables.MCPServerAuthModeHeaders, false)
h := &ConfigHandler{store: cfg, configManager: stubConfigManager{}}

ctx := newTestRequestCtx(tc.body)
ctx.Request.Header.SetMethod(fasthttp.MethodPut)
ctx.SetUserValue(schemas.BifrostContextKeyAuthBypassed, true)

h.updateProxyConfig(ctx)

got403 := ctx.Response.StatusCode() == fasthttp.StatusForbidden
require.Equal(t, tc.want403, got403, "status %d; body=%s", ctx.Response.StatusCode(), ctx.Response.Body())
stored, err := store.GetProxyConfig(context.Background())
require.NoError(t, err)
if tc.want403 {
assert.Equal(t, storedURL, stored.URL)
assert.False(t, stored.SkipTLSVerify)
assert.Equal(t, 10, stored.Timeout)
} else {
require.Equal(t, fasthttp.StatusOK, ctx.Response.StatusCode(), "body=%s", ctx.Response.Body())
assert.Equal(t, 30, stored.Timeout)
}
})
}
}
14 changes: 14 additions & 0 deletions transports/bifrost-http/handlers/middlewares.go
Original file line number Diff line number Diff line change
Expand Up @@ -1610,3 +1610,17 @@ func GetObservabilityPlugins(plugins []schemas.BasePlugin) []schemas.Observabili

return obsPlugins
}

// AuthBypassedMiddleware marks every request as admitted without a credential check. The
// server installs it in place of AuthMiddleware.APIMiddleware when there is no config store
// (and so no auth at all), so handlers that require genuine auth for dangerous changes - which
// key off BifrostContextKeyAuthBypassed - still refuse them in that mode instead of reading an
// unmarked request as authenticated and failing open.
func AuthBypassedMiddleware() schemas.BifrostHTTPMiddleware {
return func(next fasthttp.RequestHandler) fasthttp.RequestHandler {
return func(ctx *fasthttp.RequestCtx) {
ctx.SetUserValue(schemas.BifrostContextKeyAuthBypassed, true)
next(ctx)
}
}
}
17 changes: 17 additions & 0 deletions transports/bifrost-http/handlers/middlewares_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2868,3 +2868,20 @@ func TestSecurityHeadersMiddleware_APINoStore(t *testing.T) {
})
}
}

// TestAuthBypassedMiddleware_MarksRequest pins the marker the server installs when there is
// no config store and so no auth middleware. Handlers that require genuine auth for dangerous
// changes key off BifrostContextKeyAuthBypassed; an unmarked request reads as authenticated,
// so without the marker every such guard fails open in exactly the no-auth deployment.
func TestAuthBypassedMiddleware_MarksRequest(t *testing.T) {
var sawBypassed bool
handler := lib.ChainMiddlewares(func(ctx *fasthttp.RequestCtx) {
sawBypassed, _ = ctx.UserValue(schemas.BifrostContextKeyAuthBypassed).(bool)
}, AuthBypassedMiddleware())

handler(&fasthttp.RequestCtx{})

if !sawBypassed {
t.Fatalf("expected request to be marked as auth-bypassed")
}
}
Loading
Loading