Merge branch 'reverse-proxy-allow-match-or' into reverse-proxy-crowdsec-appsec

# Conflicts:
#	proxy/internal/auth/middleware.go
#	proxy/internal/auth/middleware_test.go
#	proxy/internal/auth/tunnel_lookup_test.go
#	proxy/management_integration_test.go
#	proxy/server.go
#	shared/management/proto/proxy_service.pb.go
This commit is contained in:
Viktor Liu
2026-09-23 07:53:31 +02:00
968 changed files with 72582 additions and 16724 deletions
@@ -115,3 +115,20 @@ func TestRouter_BedrockNamespacedInferenceProfilesStripsPrefix(t *testing.T) {
assert.Equal(t, "/bedrock", out.Mutations.RewriteUpstream.StripPathPrefix,
"the namespace prefix must not reach the real Bedrock endpoint")
}
// TestRouteClaimsModel_VertexNormalizesCandidate is the Vertex counterpart of
// the Bedrock case above: the parser strips the "@version" suffix from the
// path model, so a provider registered with the versioned form must still
// match the normalized request model.
func TestRouteClaimsModel_VertexNormalizesCandidate(t *testing.T) {
route := ProviderRoute{Vertex: true, Models: []string{"claude-sonnet-4-5@20250929"}}
assert.True(t, routeClaimsModel(route, "claude-sonnet-4-5"),
"raw @version Vertex model must match the normalized request model")
assert.False(t, routeClaimsModel(route, "claude-opus-4-8"),
"a model outside the provider's list must not match")
// Non-Vertex routes keep exact matching (no @version stripping).
openai := ProviderRoute{Models: []string{"gpt-4o@2024"}}
assert.False(t, routeClaimsModel(openai, "gpt-4o"),
"non-Vertex routes must not strip an @version suffix")
}
@@ -36,7 +36,10 @@ type ProviderRoute struct {
// request on a same-vendor route so catch-all gateways of a different
// vendor can't swallow it. Empty disables vendor filtering for this
// route.
Vendor string `json:"vendor,omitempty"`
Vendor string `json:"vendor,omitempty"`
// Vendors lists every parser surface a multi-surface gateway accepts.
// Vendor remains supported for existing single-surface configurations.
Vendors []string `json:"vendors,omitempty"`
Models []string `json:"models"`
UpstreamScheme string `json:"upstream_scheme"`
UpstreamHost string `json:"upstream_host"`
@@ -331,6 +331,11 @@ func discoverableModels(route ProviderRoute, userGroups []string) ([]string, boo
intersection[m] = struct{}{}
}
}
if route.Vertex {
if _, ok := permitted[llm.NormalizeVertexModel(m)]; ok {
intersection[m] = struct{}{}
}
}
}
return sortedModels(intersection), true
}
@@ -409,7 +414,7 @@ func stripBedrockNamespace(out *middleware.Output) {
// peer, return matchOutcomeUnauthorised so the caller can emit
// the dedicated no_authorised_provider deny code.
// 3. Vendor precedence: when the request carries a detected vendor
// (llm.provider) and at least one candidate is the same vendor,
// (llm.provider) and at least one candidate declares that vendor,
// drop the rest — a vendor-tagged request must never cross to
// another vendor's route (e.g. an Anthropic call landing on an
// OpenAI-compatible gateway that also claims the model).
@@ -432,9 +437,9 @@ func (m *Middleware) matchRoute(model, vendor, reqPath string, userGroups []stri
// Vendor pinning runs BEFORE the group filter so a request the parser
// tagged with a vendor can never cross to another vendor's route — not
// even an authorised one. Narrow to same-vendor routes when any
// model-matched route declares that vendor; setups with no vendor tag on
// any route fall through unchanged. After narrowing, if no same-vendor
// even an authorised one. Narrow to supporting routes when any
// model-matched route declares that vendor; setups with no matching vendor
// declaration fall through unchanged. After narrowing, if no supporting
// route authorises the caller, that's matchOutcomeUnauthorised (no
// cross-vendor fallback).
if vendor != "" {
@@ -805,21 +810,31 @@ func authorisingGroupsCSV(routeGroups, userGroups []string) string {
return strings.Join(out, ",")
}
// matchingVendor returns the subset of routes whose Vendor equals the
// request's detected vendor. Routes with an empty Vendor never match — an
// untagged route can't be asserted to speak the request's surface, so it
// stays out of the vendor-filtered set (but remains eligible via the
// fall-through when no route matches the vendor at all).
// matchingVendor returns the routes that declare the request's detected
// vendor through either the legacy singular field or the multi-vendor field.
// Untagged routes remain eligible only when no route declares the vendor.
func matchingVendor(routes []ProviderRoute, vendor string) []ProviderRoute {
var out []ProviderRoute
for _, r := range routes {
if r.Vendor == vendor {
if routeSupportsVendor(r, vendor) {
out = append(out, r)
}
}
return out
}
func routeSupportsVendor(route ProviderRoute, vendor string) bool {
if route.Vendor == vendor {
return true
}
for _, candidate := range route.Vendors {
if candidate == vendor {
return true
}
}
return false
}
// explicitlyClaiming returns the subset of routes whose Models list
// names the model exactly. Catch-all routes (empty Models) are excluded,
// so callers can prefer a provider that genuinely declares the model over
@@ -859,6 +874,11 @@ func routeClaimsModel(route ProviderRoute, model string) bool {
if route.Bedrock && llm.NormalizeBedrockModel(candidate) == model {
return true
}
// Vertex likewise: the parser strips the "@version" suffix from the
// path model, while the operator may register the versioned form.
if route.Vertex && llm.NormalizeVertexModel(candidate) == model {
return true
}
// A client may pin a dated Anthropic id ("claude-sonnet-4-5-20250929")
// where the operator registered the undated one. Only an undated
// registration absorbs a dated request: normalising both sides would
@@ -412,6 +412,50 @@ func TestRouter_VendorKeepsOpenAIOffAnthropic(t *testing.T) {
assert.Equal(t, "api.openai.com", out.Mutations.RewriteUpstream.Host, "openai vendor must pin to the openai route despite anthropic being declared first")
}
func TestRouter_MultiVendorGatewayAcceptsBothSurfaces(t *testing.T) {
gateway := ProviderRoute{
ID: "agentgateway",
Vendors: []string{"openai", "anthropic"},
Models: nil,
AllowedGroupIDs: []string{defaultTestGroup},
UpstreamScheme: "https",
UpstreamHost: "gateway.example.com",
}
other := ProviderRoute{
ID: "other-vendor",
Vendor: "mistral",
Models: nil,
AllowedGroupIDs: []string{defaultTestGroup},
UpstreamScheme: "https",
UpstreamHost: "mistral.example.com",
}
mw := New(Config{Providers: []ProviderRoute{other, gateway}})
for _, tc := range []struct {
name string
vendor string
model string
path string
}{
{name: "OpenAI", vendor: "openai", model: "gpt-4o-mini", path: "/v1/chat/completions"},
{name: "Anthropic", vendor: "anthropic", model: "claude-sonnet-4-5", path: "/v1/messages"},
} {
t.Run(tc.name, func(t *testing.T) {
out, err := mw.Invoke(context.Background(), newInputVendorModelURL(tc.vendor, tc.model, tc.path))
require.NoError(t, err)
require.NotNil(t, out)
assert.Equal(t, middleware.DecisionAllow, out.Decision,
"supported vendor must route through the multi-surface gateway")
require.NotNil(t, out.Mutations)
require.NotNil(t, out.Mutations.RewriteUpstream)
assert.Equal(t, "gateway.example.com", out.Mutations.RewriteUpstream.Host)
provider, _ := metaValue(t, out.Metadata, middleware.KeyLLMResolvedProviderID)
assert.Equal(t, "agentgateway", provider)
})
}
}
// TestRouter_VendorAbsentFallsBackToModelPath confirms vendor filtering is
// inert when the request carries no detected vendor: routing then relies on
// model/path as before.
@@ -692,6 +736,23 @@ func TestRouter_FactoryRejectsBadJSON(t *testing.T) {
require.Error(t, err, "malformed JSON config must be rejected at chain build time")
}
func TestRouter_FactoryDecodesLegacyAndMultiVendorFields(t *testing.T) {
raw := []byte(`{"providers":[` +
`{"id":"legacy","vendor":"openai","models":[],"upstream_scheme":"https","upstream_host":"openai.example.com","auth_header_name":"Authorization","auth_header_value":"Bearer legacy","allowed_group_ids":["group"]},` +
`{"id":"multi","vendors":["openai","anthropic"],"models":[],"upstream_scheme":"https","upstream_host":"gateway.example.com","auth_header_name":"Authorization","auth_header_value":"Bearer multi","allowed_group_ids":["group"]}` +
`]}`)
resolved, err := Factory{}.New(raw)
require.NoError(t, err)
router, ok := resolved.(*Middleware)
require.True(t, ok, "factory must return the concrete router middleware")
require.Len(t, router.cfg.Providers, 2)
assert.Equal(t, "openai", router.cfg.Providers[0].Vendor,
"the legacy singular field must keep decoding")
assert.Equal(t, []string{"openai", "anthropic"}, router.cfg.Providers[1].Vendors,
"the multi-vendor field must decode both supported surfaces")
}
func TestRouter_FactoryAcceptsEmptyShapes(t *testing.T) {
cases := [][]byte{nil, []byte(""), []byte(" "), []byte("null"), []byte("{}"), []byte("[]")}
for _, raw := range cases {
+1 -1
View File
@@ -264,7 +264,7 @@ func applyMutations(ctx context.Context, d *Dispatcher, spec Spec, r *http.Reque
if m == nil {
return
}
add, remove, blocked := FilterHeaderMutations(m)
add, remove, blocked := filterHeaderMutations(m, spec.ID)
for _, h := range blocked {
d.metrics.IncHeaderMutationBlocked(ctx, spec.ID, h)
}
+59
View File
@@ -2,6 +2,7 @@ package middleware
import (
"context"
"net/http"
"strconv"
"testing"
@@ -278,6 +279,64 @@ func TestChain_ApplyMutations_RewriteGatedOnCanMutate(t *testing.T) {
assert.Nil(t, rewrite, "rewrite must be filtered when CanMutate=false")
}
func TestChain_IdentityInjectReplacesReservedNetBirdHeaders(t *testing.T) {
mw := &fakeMiddleware{
id: "llm_identity_inject",
slot: SlotOnRequest,
mutationsSupported: true,
canMutate: true,
mutations: &Mutations{
HeadersRemove: []string{"x-netbird-user-id", "x-netbird-groups"},
HeadersAdd: []KV{
{Key: "x-netbird-user-id", Value: "trusted-user"},
{Key: "x-netbird-groups", Value: "trusted-group"},
},
},
}
c := chainFor(t, mw)
req, err := http.NewRequest(http.MethodGet, "https://gateway.example.com/v1/models", nil)
require.NoError(t, err)
req.Header.Set("x-netbird-user-id", "spoofed-user")
req.Header.Set("x-netbird-groups", "spoofed-group")
denied, _, _, err := c.RunRequest(context.Background(), req, &Input{}, NewAccumulator(0))
require.NoError(t, err)
assert.Nil(t, denied, "identity injection must not deny the request")
assert.Equal(t, "trusted-user", req.Header.Get("x-netbird-user-id"),
"the built-in identity middleware must replace a spoofed user header")
assert.Equal(t, "trusted-group", req.Header.Get("x-netbird-groups"),
"the built-in identity middleware must replace spoofed groups")
}
func TestChain_OtherMiddlewareCannotReplaceReservedNetBirdHeaders(t *testing.T) {
mw := &fakeMiddleware{
id: "untrusted-middleware",
slot: SlotOnRequest,
mutationsSupported: true,
canMutate: true,
mutations: &Mutations{
HeadersRemove: []string{"x-netbird-user-id", "x-netbird-groups"},
HeadersAdd: []KV{
{Key: "x-netbird-user-id", Value: "replacement-user"},
{Key: "x-netbird-groups", Value: "replacement-group"},
},
},
}
c := chainFor(t, mw)
req, err := http.NewRequest(http.MethodGet, "https://gateway.example.com/v1/models", nil)
require.NoError(t, err)
req.Header.Set("x-netbird-user-id", "original-user")
req.Header.Set("x-netbird-groups", "original-group")
denied, _, _, err := c.RunRequest(context.Background(), req, &Input{}, NewAccumulator(0))
require.NoError(t, err)
assert.Nil(t, denied, "blocked mutations must not deny the request")
assert.Equal(t, "original-user", req.Header.Get("x-netbird-user-id"),
"other middleware must remain unable to mutate reserved identity headers")
assert.Equal(t, "original-group", req.Header.Get("x-netbird-groups"),
"other middleware must remain unable to mutate reserved identity headers")
}
// TestChain_RunRequest_PropagatesUserGroups asserts the chain forwards
// Input.UserGroups verbatim through cloneInputFor so policy-aware
// middlewares (e.g. llm_policy_check) can authorise without an extra
+16 -2
View File
@@ -2,6 +2,8 @@ package middleware
import "strings"
const trustedIdentityMiddlewareID = "llm_identity_inject"
var denyHeaders = []string{
"Authorization",
"Connection",
@@ -78,18 +80,22 @@ func isHeaderFieldName(name string) bool {
// header names so the dispatcher can increment the blocked-header
// metric.
func FilterHeaderMutations(m *Mutations) (filteredAdd []KV, filteredRemove []string, blocked []string) {
return filterHeaderMutations(m, "")
}
func filterHeaderMutations(m *Mutations, middlewareID string) (filteredAdd []KV, filteredRemove []string, blocked []string) {
if m == nil {
return nil, nil, nil
}
for _, kv := range m.HeadersAdd {
if IsHeaderMutable(kv.Key) {
if IsHeaderMutable(kv.Key) || isTrustedIdentityHeader(middlewareID, kv.Key) {
filteredAdd = append(filteredAdd, kv)
continue
}
blocked = append(blocked, kv.Key)
}
for _, name := range m.HeadersRemove {
if IsHeaderMutable(name) {
if IsHeaderMutable(name) || isTrustedIdentityHeader(middlewareID, name) {
filteredRemove = append(filteredRemove, name)
continue
}
@@ -97,3 +103,11 @@ func FilterHeaderMutations(m *Mutations) (filteredAdd []KV, filteredRemove []str
}
return filteredAdd, filteredRemove, blocked
}
func isTrustedIdentityHeader(middlewareID, name string) bool {
if middlewareID != trustedIdentityMiddlewareID {
return false
}
return strings.EqualFold(name, "x-netbird-user-id") ||
strings.EqualFold(name, "x-netbird-groups")
}
@@ -0,0 +1,26 @@
package middleware
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestFilterHeaderMutationsDoesNotTrustReservedHeaders(t *testing.T) {
mutations := &Mutations{
HeadersAdd: []KV{
{Key: "x-request-label", Value: "allowed"},
{Key: "x-netbird-user-id", Value: "spoofed-user"},
},
HeadersRemove: []string{"x-request-label", "x-netbird-groups"},
}
filteredAdd, filteredRemove, blocked := FilterHeaderMutations(mutations)
assert.Equal(t, []KV{{Key: "x-request-label", Value: "allowed"}}, filteredAdd,
"the public filter should retain mutable additions")
assert.Equal(t, []string{"x-request-label"}, filteredRemove,
"the public filter should retain mutable removals")
assert.ElementsMatch(t, []string{"x-netbird-user-id", "x-netbird-groups"}, blocked,
"the public filter must not grant the identity middleware exception")
}