[proxy] Enforce per-target HTTP access actions (#8115)

Apply target access before service authentication: inherit, bypass, or block. Keep route selection and auth decisions on the same snapshot; reject stale or unavailable required middleware.

Validation: full auth/proxy race tests and scoped lint passed. Runtime regression tests remain with this layer.
This commit is contained in:
Maycon Santos
2026-10-12 12:31:57 +02:00
committed by GitHub
parent a7a2575bad
commit 1161616c02
16 changed files with 2692 additions and 640 deletions
+140 -30
View File
@@ -64,6 +64,22 @@ type DomainConfig struct {
// groups claim intersects this set. Empty means group membership does not
// restrict access.
AllowedGroups map[string]struct{}
// TargetResolver pins the target and access action used by this auth snapshot.
TargetResolver *proxy.TargetResolver
}
// DomainOption configures a domain authentication snapshot.
type DomainOption func(*DomainConfig) error
// WithTargetResolver attaches an immutable target-routing snapshot to a domain.
func WithTargetResolver(resolver *proxy.TargetResolver) DomainOption {
return func(config *DomainConfig) error {
if resolver == nil {
return errors.New("target resolver is nil")
}
config.TargetResolver = resolver
return nil
}
}
type validationResult struct {
@@ -132,6 +148,12 @@ func (mw *Middleware) Protect(next http.Handler) http.Handler {
return
}
resolvedRequest, handled := mw.handleTargetAccess(w, r, config, next)
if handled {
return
}
r = resolvedRequest
// Private services bypass operator schemes and gate on tunnel peer.
if config.Private {
if mw.forwardWithTunnelPeer(w, r, host, config, next) {
@@ -171,6 +193,66 @@ func (mw *Middleware) Protect(next http.Handler) http.Handler {
})
}
func (mw *Middleware) handleTargetAccess(w http.ResponseWriter, r *http.Request, config DomainConfig, next http.Handler) (*http.Request, bool) {
if config.TargetResolver == nil {
return r, false
}
resolvedRequest, action, err := config.TargetResolver.ResolveRequest(r)
if err != nil {
mw.rejectUnsafePath(w, r, err)
return r, true
}
r = resolvedRequest
switch action {
case proxy.AccessActionInherit:
return r, false
case proxy.AccessActionBypass:
if config.Private {
mw.logger.Error("private domain reached a bypass target; denying")
denyPrivate(w)
return r, true
}
markAccessAction(r, action)
next.ServeHTTP(w, r)
case proxy.AccessActionBlock:
markAccessAction(r, action)
w.Header().Set("Cache-Control", "no-store")
denyForbidden(w, config)
default:
mw.logger.Errorf("resolved unknown access action %q; denying", action)
markAccessAction(r, action)
w.Header().Set("Cache-Control", "no-store")
denyForbidden(w, config)
}
return r, true
}
func (mw *Middleware) rejectUnsafePath(w http.ResponseWriter, r *http.Request, err error) {
mw.logger.WithFields(log.Fields{
"host": r.Host,
"path": r.URL.EscapedPath(),
}).Debugf("rejecting request path: %v", err)
if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
cd.SetOrigin(proxy.OriginAuth)
cd.SetMetadata("access_action", "invalid_path")
}
w.Header().Set("Cache-Control", "no-store")
http.Error(w, "Bad Request", http.StatusBadRequest)
}
func markAccessAction(r *http.Request, action proxy.AccessAction) {
if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
cd.SetMetadata("access_action", string(action))
switch action {
case proxy.AccessActionBypass:
cd.SetAuthMethod("path_bypass")
case proxy.AccessActionBlock:
cd.SetAuthMethod("path_block")
cd.SetOrigin(proxy.OriginAuth)
}
}
}
// requestIsPlainHTTP reports whether the request arrived without TLS.
// Used to gate cookie-on-plain warnings and the OIDC plain-HTTP block.
func requestIsPlainHTTP(r *http.Request) bool {
@@ -666,36 +748,10 @@ func wasCredentialSubmitted(r *http.Request, method auth.Method) bool {
return false
}
// AddDomain registers authentication schemes for the given domain. With schemes a valid session public key is required.
// private=true forces ValidateTunnelPeer enforcement (403 on failure) regardless of the schemes list.
// allowedGroups restricts OIDC sessions to the given group ids; empty means unrestricted.
func (mw *Middleware) AddDomain(domain string, schemes []Scheme, publicKeyB64 string, expiration time.Duration, accountID types.AccountID, serviceID types.ServiceID, ipRestrictions *restrict.Filter, private bool, allowedGroups []string) error {
if len(schemes) == 0 {
mw.domainsMux.Lock()
defer mw.domainsMux.Unlock()
mw.domains[domain] = DomainConfig{
AccountID: accountID,
ServiceID: serviceID,
IPRestrictions: ipRestrictions,
Private: private,
AllowedGroups: groupSet(allowedGroups),
}
return nil
}
pubKeyBytes, err := base64.StdEncoding.DecodeString(publicKeyB64)
if err != nil {
return fmt.Errorf("decode session public key for domain %s: %w", domain, err)
}
if len(pubKeyBytes) != ed25519.PublicKeySize {
return fmt.Errorf("invalid session public key size for domain %s: got %d, want %d", domain, len(pubKeyBytes), ed25519.PublicKeySize)
}
mw.domainsMux.Lock()
defer mw.domainsMux.Unlock()
mw.domains[domain] = DomainConfig{
Schemes: schemes,
SessionPublicKey: pubKeyBytes,
// NewDomainConfig validates and builds an immutable domain configuration.
func NewDomainConfig(domain string, schemes []Scheme, publicKeyB64 string, expiration time.Duration, accountID types.AccountID, serviceID types.ServiceID, ipRestrictions *restrict.Filter, private bool, allowedGroups []string, opts ...DomainOption) (DomainConfig, error) {
config := DomainConfig{
Schemes: append([]Scheme(nil), schemes...),
SessionExpiration: expiration,
AccountID: accountID,
ServiceID: serviceID,
@@ -703,9 +759,63 @@ func (mw *Middleware) AddDomain(domain string, schemes []Scheme, publicKeyB64 st
Private: private,
AllowedGroups: groupSet(allowedGroups),
}
if len(schemes) > 0 {
pubKeyBytes, err := base64.StdEncoding.DecodeString(publicKeyB64)
if err != nil {
return DomainConfig{}, fmt.Errorf("decode session public key for domain %s: %w", domain, err)
}
if len(pubKeyBytes) != ed25519.PublicKeySize {
return DomainConfig{}, fmt.Errorf("invalid session public key size for domain %s: got %d, want %d", domain, len(pubKeyBytes), ed25519.PublicKeySize)
}
config.SessionPublicKey = append(ed25519.PublicKey(nil), pubKeyBytes...)
}
for _, option := range opts {
if option == nil {
return DomainConfig{}, fmt.Errorf("nil domain option for domain %s", domain)
}
if err := option(&config); err != nil {
return DomainConfig{}, fmt.Errorf("configure domain %s: %w", domain, err)
}
}
if config.Private && config.TargetResolver != nil && config.TargetResolver.HasBypass() {
return DomainConfig{}, fmt.Errorf("authentication bypass is not allowed for private domain %s", domain)
}
return config, nil
}
// AddDomainConfig atomically publishes a validated domain configuration.
func (mw *Middleware) AddDomainConfig(domain string, config DomainConfig) {
config.Schemes = append([]Scheme(nil), config.Schemes...)
config.SessionPublicKey = append(ed25519.PublicKey(nil), config.SessionPublicKey...)
config.AllowedGroups = cloneGroupSet(config.AllowedGroups)
mw.domainsMux.Lock()
defer mw.domainsMux.Unlock()
mw.domains[domain] = config
}
// AddDomain registers authentication schemes for the given domain. With schemes a valid session public key is required.
// private=true forces ValidateTunnelPeer enforcement (403 on failure) regardless of the schemes list.
// allowedGroups restricts OIDC sessions to the given group ids; empty means unrestricted.
func (mw *Middleware) AddDomain(domain string, schemes []Scheme, publicKeyB64 string, expiration time.Duration, accountID types.AccountID, serviceID types.ServiceID, ipRestrictions *restrict.Filter, private bool, allowedGroups []string, opts ...DomainOption) error {
config, err := NewDomainConfig(domain, schemes, publicKeyB64, expiration, accountID, serviceID, ipRestrictions, private, allowedGroups, opts...)
if err != nil {
return err
}
mw.AddDomainConfig(domain, config)
return nil
}
func cloneGroupSet(groups map[string]struct{}) map[string]struct{} {
if groups == nil {
return nil
}
cloned := make(map[string]struct{}, len(groups))
for group := range groups {
cloned[group] = struct{}{}
}
return cloned
}
// RemoveDomain unregisters authentication for the given domain.
func (mw *Middleware) RemoveDomain(domain string) {
mw.domainsMux.Lock()
+278
View File
@@ -0,0 +1,278 @@
package auth
import (
"errors"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"time"
log "github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
proxyauth "github.com/netbirdio/netbird/proxy/auth"
"github.com/netbirdio/netbird/proxy/internal/proxy"
"github.com/netbirdio/netbird/proxy/internal/restrict"
)
type targetAccessRoundTripFunc func(*http.Request) (*http.Response, error)
func (f targetAccessRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func targetAccessResolver(t *testing.T, action proxy.AccessAction) *proxy.TargetResolver {
t.Helper()
targetURL, err := url.Parse("http://backend.internal")
require.NoError(t, err)
resolver, err := proxy.NewTargetResolver(proxy.Mapping{
ID: "service-1",
AccountID: "account-1",
Host: "example.com",
Paths: map[string]*proxy.PathTarget{
"/selected": {
URL: targetURL,
AccessAction: action,
},
},
})
require.NoError(t, err)
return resolver
}
func TestProtect_TargetAccessActions(t *testing.T) {
tests := []struct {
name string
action proxy.AccessAction
wantStatus int
wantBackendCalls int
wantSchemeCalls int
wantCacheControl string
}{
{
name: "bypass forwards without authentication",
action: proxy.AccessActionBypass,
wantStatus: http.StatusNoContent,
wantBackendCalls: 1,
},
{
name: "block rejects before authentication",
action: proxy.AccessActionBlock,
wantStatus: http.StatusForbidden,
wantSchemeCalls: 0,
wantCacheControl: "no-store",
},
{
name: "inherit runs configured authentication",
action: proxy.AccessActionInherit,
wantStatus: http.StatusUnauthorized,
wantSchemeCalls: 1,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
kp := generateTestKeyPair(t)
schemeCalls := 0
scheme := &stubScheme{
method: proxyauth.MethodPIN,
authFn: func(*http.Request) (string, string, error) {
schemeCalls++
return "", "pin", nil
},
}
err := mw.AddDomain(
"example.com",
[]Scheme{scheme},
kp.PublicKey,
time.Hour,
"account-1",
"service-1",
nil,
false,
nil,
WithTargetResolver(targetAccessResolver(t, tt.action)),
)
require.NoError(t, err)
backendCalls := 0
handler := mw.Protect(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
backendCalls++
w.WriteHeader(http.StatusNoContent)
}))
req := httptest.NewRequest(http.MethodGet, "http://example.com/selected/status", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, tt.wantStatus, rec.Code)
assert.Equal(t, tt.wantBackendCalls, backendCalls, "backend call count")
assert.Equal(t, tt.wantSchemeCalls, schemeCalls, "authentication scheme call count")
assert.Equal(t, tt.wantCacheControl, rec.Header().Get("Cache-Control"), "cache policy")
})
}
}
func TestProtect_BypassRecordsActionWithoutIdentity(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
require.NoError(t, mw.AddDomain(
"example.com", nil, "", 0, "account-1", "service-1", nil, false, nil,
WithTargetResolver(targetAccessResolver(t, proxy.AccessActionBypass)),
))
handler := mw.Protect(newPassthroughHandler())
req := httptest.NewRequest(http.MethodGet, "http://example.com/selected", nil)
captured := proxy.NewCapturedData("request-1")
req = req.WithContext(proxy.WithCapturedData(req.Context(), captured))
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusOK, rec.Code)
assert.Empty(t, captured.GetUserID())
assert.Empty(t, captured.GetUserEmail())
assert.Empty(t, captured.GetUserGroups())
assert.Equal(t, "path_bypass", captured.GetAuthMethod())
assert.Equal(t, "bypass", captured.GetMetadata()["access_action"])
}
func TestProtect_TargetResolverPinsMissThroughAuthentication(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
resolver := targetAccessResolver(t, proxy.AccessActionBypass)
require.NoError(t, mw.AddDomain(
"example.com", nil, "", 0, "account-1", "service-1", nil, false, nil,
WithTargetResolver(resolver),
))
forwarded := false
reverseProxy := proxy.NewReverseProxy(targetAccessRoundTripFunc(func(*http.Request) (*http.Response, error) {
forwarded = true
return nil, errors.New("unexpected forwarding")
}), "auto", nil, nil)
replacementURL, err := url.Parse("http://replacement.internal")
require.NoError(t, err)
reverseProxy.AddMapping(proxy.Mapping{
ID: "service-1",
AccountID: "account-1",
Host: "example.com",
Paths: map[string]*proxy.PathTarget{
"/": {URL: replacementURL},
},
})
handler := mw.Protect(reverseProxy)
req := httptest.NewRequest(http.MethodGet, "http://example.com/not-selected", nil)
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusNotFound, rec.Code, "a miss pinned before auth must reach the proxy unchanged")
assert.False(t, forwarded, "the proxy must not fall back to a newly published mapping after a pinned miss")
}
func TestProtect_UnsafePathFailsBeforeAuthentication(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
kp := generateTestKeyPair(t)
schemeCalls := 0
scheme := &stubScheme{
method: proxyauth.MethodPIN,
authFn: func(*http.Request) (string, string, error) {
schemeCalls++
return "", "pin", nil
},
}
require.NoError(t, mw.AddDomain(
"example.com",
[]Scheme{scheme},
kp.PublicKey,
time.Hour,
"account-1",
"service-1",
nil,
false,
nil,
WithTargetResolver(targetAccessResolver(t, proxy.AccessActionBlock)),
))
backendCalled := false
handler := mw.Protect(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
backendCalled = true
}))
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
req.URL.Path = "/selected/../other"
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusBadRequest, rec.Code)
assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
assert.Zero(t, schemeCalls, "unsafe paths must be rejected before credentials are evaluated")
assert.False(t, backendCalled)
}
func TestProtect_IPRestrictionsRunBeforeTargetAccessAction(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
filter := restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"10.0.0.0/8"}})
require.NoError(t, mw.AddDomain(
"example.com", nil, "", 0, "account-1", "service-1", filter, false, nil,
WithTargetResolver(targetAccessResolver(t, proxy.AccessActionBypass)),
))
backendCalled := false
handler := mw.Protect(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
backendCalled = true
}))
req := httptest.NewRequest(http.MethodGet, "http://example.com/selected", nil)
req.RemoteAddr = "192.0.2.10:1234"
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
assert.Equal(t, http.StatusForbidden, rec.Code)
assert.False(t, backendCalled, "bypass must not bypass service IP restrictions")
}
func TestNewDomainConfig_RejectsPrivateBypass(t *testing.T) {
_, err := NewDomainConfig(
"private.example.com",
nil,
"",
0,
"account-1",
"service-1",
nil,
true,
nil,
WithTargetResolver(targetAccessResolver(t, proxy.AccessActionBypass)),
)
require.Error(t, err)
assert.Contains(t, err.Error(), "not allowed for private domain")
}
func TestAddDomainConfig_PublishesPreparedSnapshot(t *testing.T) {
mw := NewMiddleware(log.StandardLogger(), nil, nil)
config, err := NewDomainConfig(
"example.com",
nil,
"",
0,
"account-1",
"service-1",
nil,
false,
[]string{"group-1"},
WithTargetResolver(targetAccessResolver(t, proxy.AccessActionBlock)),
)
require.NoError(t, err)
mw.AddDomainConfig("example.com", config)
delete(config.AllowedGroups, "group-1")
published, exists := mw.getDomainConfig("example.com")
require.True(t, exists)
assert.Contains(t, published.AllowedGroups, "group-1", "published mutable fields should be copied")
assert.NotNil(t, published.TargetResolver)
}
+43 -8
View File
@@ -82,7 +82,15 @@ func NewReverseProxy(transport http.RoundTripper, forwardedProto string, trusted
}
func (p *ReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
result, exists := p.findTargetForRequest(r)
resolution, pinned := targetResolutionFromContext(r.Context())
result, exists := resolution.result, resolution.matched
if !pinned {
result, exists = p.findTargetForRequest(r)
if result.requirePinned {
p.serveProxyUnavailable(w, r)
return
}
}
if !exists {
p.serveRouteError(w, r, http.StatusNotFound, "Service Not Found",
"The requested service could not be found. Please check the URL, try refreshing, or check if the peer is running. If that doesn't work, see our documentation for help.")
@@ -119,7 +127,11 @@ func (p *ReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
rewriteMatchedPath = ""
}
chain := p.resolveChain(result)
chain, current := p.resolveChain(result)
if !current || (targetRequiresMiddleware(pt) && (chain == nil || chain.Empty())) {
p.serveProxyUnavailable(w, r)
return
}
if chain == nil || chain.Empty() {
p.serveDirect(w, r, ctx, result, rewriteMatchedPath)
return
@@ -127,6 +139,28 @@ func (p *ReverseProxy) ServeHTTP(w http.ResponseWriter, r *http.Request) {
p.serveWithChain(w, r, ctx, result, chain, rewriteMatchedPath, capturedData)
}
func (p *ReverseProxy) serveProxyUnavailable(w http.ResponseWriter, r *http.Request) {
if cd := CapturedDataFromContext(r.Context()); cd != nil {
cd.SetOrigin(OriginProxyError)
}
w.Header().Set("Cache-Control", "no-store")
web.ServeErrorPage(w, r, http.StatusServiceUnavailable, "Service Unavailable",
"The requested service is being updated. Please try again shortly.", getRequestID(r),
web.ErrorStatus{Proxy: true, Destination: false})
}
func targetRequiresMiddleware(target *PathTarget) bool {
if target == nil {
return false
}
for _, spec := range target.Middlewares {
if spec.Enabled {
return true
}
}
return false
}
// serveRouteError marks the request as un-routed on any captured-data
// context and renders the proxy error page.
func (p *ReverseProxy) serveRouteError(w http.ResponseWriter, r *http.Request, status int, title, message string) {
@@ -455,14 +489,15 @@ func applyUpstreamHeaders(r *http.Request, rewrite *middleware.UpstreamRewrite)
}
}
// resolveChain returns the middleware chain registered for the
// resolved target, or nil when middleware is disabled for the proxy
// or the target.
func (p *ReverseProxy) resolveChain(result targetResult) *middleware.Chain {
// resolveChain returns a chain only from the target's routing revision.
func (p *ReverseProxy) resolveChain(result targetResult) (*middleware.Chain, bool) {
if p.middlewareManager == nil {
return nil
return nil, result.middlewareRevision == 0
}
return p.middlewareManager.ChainFor(string(result.serviceID), result.matchedPath)
if result.middlewareRevision == 0 {
return p.middlewareManager.ChainFor(string(result.serviceID), result.matchedPath), true
}
return p.middlewareManager.ChainForRevision(string(result.serviceID), result.matchedPath, result.middlewareRevision)
}
// buildRequestInput gathers the per-request fields the middleware
+39 -15
View File
@@ -23,9 +23,22 @@ const (
PathRewritePreserve
)
// AccessAction controls how authentication handles requests for a target.
type AccessAction string
const (
// AccessActionInherit applies the service's configured authentication.
AccessActionInherit AccessAction = "inherit"
// AccessActionBypass skips the service's configured authentication.
AccessActionBypass AccessAction = "bypass"
// AccessActionBlock rejects the request before authentication.
AccessActionBlock AccessAction = "block"
)
// PathTarget holds a backend URL and per-target behavioral options.
type PathTarget struct {
URL *url.URL
AccessAction AccessAction
SkipTLSVerify bool
RequestTimeout time.Duration
PathRewrite PathRewriteMode
@@ -61,18 +74,25 @@ type Mapping struct {
// StripAuthHeaders are header names used for header-based auth.
// These headers are stripped from requests before forwarding.
StripAuthHeaders []string
// MiddlewareRevision binds this routing snapshot to its middleware policies.
MiddlewareRevision middleware.Revision
// sortedPaths caches the paths sorted by length (longest first).
sortedPaths []string
// requirePinnedResolution prevents a mapping with target access actions
// from being forwarded outside the auth-owned resolver snapshot.
requirePinnedResolution bool
}
type targetResult struct {
target *PathTarget
matchedPath string
serviceID types.ServiceID
accountID types.AccountID
passHostHeader bool
rewriteRedirects bool
stripAuthHeaders []string
target *PathTarget
matchedPath string
serviceID types.ServiceID
accountID types.AccountID
passHostHeader bool
rewriteRedirects bool
stripAuthHeaders []string
middlewareRevision middleware.Revision
requirePinned bool
}
func (p *ReverseProxy) findTargetForRequest(req *http.Request) (targetResult, bool) {
@@ -90,6 +110,7 @@ func (p *ReverseProxy) findTargetForRequest(req *http.Request) (targetResult, bo
p.logger.Debugf("no mapping found for host: %s", host)
return targetResult{}, false
}
baseResult := targetResult{requirePinned: m.requirePinnedResolution}
for _, path := range m.sortedPaths {
if strings.HasPrefix(req.URL.Path, path) {
@@ -100,18 +121,20 @@ func (p *ReverseProxy) findTargetForRequest(req *http.Request) (targetResult, bo
}
p.logger.Debugf("matched host: %s, path: %s -> %s", host, path, pt.URL)
return targetResult{
target: pt,
matchedPath: path,
serviceID: m.ID,
accountID: m.AccountID,
passHostHeader: m.PassHostHeader,
rewriteRedirects: m.RewriteRedirects,
stripAuthHeaders: m.StripAuthHeaders,
target: pt,
matchedPath: path,
serviceID: m.ID,
accountID: m.AccountID,
passHostHeader: m.PassHostHeader,
rewriteRedirects: m.RewriteRedirects,
stripAuthHeaders: m.StripAuthHeaders,
middlewareRevision: m.MiddlewareRevision,
requirePinned: m.requirePinnedResolution,
}, true
}
}
p.logger.Debugf("no path match for host: %s, path: %s", host, req.URL.Path)
return targetResult{}, false
return baseResult, false
}
// AddMapping registers a host-to-backend mapping for the reverse proxy.
@@ -125,6 +148,7 @@ func (p *ReverseProxy) AddMapping(m Mapping) {
return len(paths[i]) > len(paths[j])
})
m.sortedPaths = paths
m.requirePinnedResolution = mappingHasNonDefaultAccessAction(m)
p.mappingsMux.Lock()
defer p.mappingsMux.Unlock()
@@ -0,0 +1,150 @@
package proxy
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/proxy/internal/middleware"
guardrail "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_guardrail"
)
func TestReverseProxy_PinnedTargetFailsClosedAcrossMiddlewareRevisions(t *testing.T) {
restrictiveGuardrail := []middleware.Spec{{
ID: guardrail.ID,
Slot: middleware.SlotOnRequest,
Enabled: true,
RawConfig: []byte(`{"provider_allowlists":{"provider-1":["allowed-model"]}}`),
}}
unrestrictedGuardrail := []middleware.Spec{{
ID: guardrail.ID,
Slot: middleware.SlotOnRequest,
Enabled: true,
RawConfig: []byte(`{}`),
}}
tests := []struct {
name string
oldMiddlewares []middleware.Spec
newMiddlewares []middleware.Spec
oldStatus int
}{
{
name: "replacement policy cannot authorize old target",
oldMiddlewares: restrictiveGuardrail,
newMiddlewares: unrestrictedGuardrail,
oldStatus: http.StatusForbidden,
},
{
name: "new policy cannot run on old no-policy target",
newMiddlewares: unrestrictedGuardrail,
oldStatus: http.StatusNoContent,
},
{
name: "removed policy cannot disappear from old target",
oldMiddlewares: unrestrictedGuardrail,
oldStatus: http.StatusNoContent,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
registry := middleware.NewRegistry()
require.NoError(t, registry.Register(guardrail.Factory{}))
manager := middleware.NewManager(0, nil, nil)
manager.SetResolver(middleware.NewResolver(registry))
oldTarget := &PathTarget{
URL: mustTargetURL(t, "http://old-agent-backend.internal"),
AgentNetwork: true,
Middlewares: tt.oldMiddlewares,
}
oldMapping := resolverMapping(map[string]*PathTarget{"/": oldTarget})
oldRevision := rebuildMiddlewareSnapshot(t, manager, &oldMapping)
oldResolver, err := NewTargetResolver(oldMapping)
require.NoError(t, err)
// Auth resolves and pins the old route and middleware revision before
// the replacement snapshot is installed.
req := httptest.NewRequest(http.MethodPost, "http://example.com/v1/chat/completions", nil)
oldPinnedReq, _, err := oldResolver.ResolveRequest(req)
require.NoError(t, err)
var upstreamHosts []string
rp := NewReverseProxy(resolverRoundTripFunc(func(req *http.Request) (*http.Response, error) {
upstreamHosts = append(upstreamHosts, req.URL.Host)
return &http.Response{
StatusCode: http.StatusNoContent,
Header: make(http.Header),
Body: http.NoBody,
}, nil
}), "auto", nil, nil, WithMiddlewareManager(manager))
before := httptest.NewRecorder()
rp.ServeHTTP(before, oldPinnedReq.Clone(oldPinnedReq.Context()))
require.Equal(t, tt.oldStatus, before.Code, "the original snapshot must work before replacement")
if tt.oldStatus == http.StatusNoContent {
require.Equal(t, []string{"old-agent-backend.internal"}, upstreamHosts)
} else {
require.Empty(t, upstreamHosts)
}
upstreamHosts = nil
newTarget := &PathTarget{
URL: mustTargetURL(t, "http://new-agent-backend.internal"),
AgentNetwork: true,
Middlewares: tt.newMiddlewares,
}
newMapping := resolverMapping(map[string]*PathTarget{"/": newTarget})
newRevision := rebuildMiddlewareSnapshot(t, manager, &newMapping)
require.NotEqual(t, oldRevision, newRevision)
stale := httptest.NewRecorder()
rp.ServeHTTP(stale, oldPinnedReq)
assert.Equal(t, http.StatusServiceUnavailable, stale.Code,
"a pinned target must not use a replacement middleware revision")
assert.Equal(t, "no-store", stale.Header().Get("Cache-Control"))
assert.Empty(t, upstreamHosts,
"a mixed target/policy generation must never reach an upstream")
// A request resolved from the replacement route snapshot is coherent
// with the replacement chain and reaches only the new backend.
newResolver, err := NewTargetResolver(newMapping)
require.NoError(t, err)
freshReq, _, err := newResolver.ResolveRequest(
httptest.NewRequest(http.MethodPost, "http://example.com/v1/chat/completions", nil),
)
require.NoError(t, err)
fresh := httptest.NewRecorder()
rp.ServeHTTP(fresh, freshReq)
assert.Equal(t, http.StatusNoContent, fresh.Code)
assert.Equal(t, []string{"new-agent-backend.internal"}, upstreamHosts)
})
}
}
func rebuildMiddlewareSnapshot(t *testing.T, manager *middleware.Manager, mapping *Mapping) middleware.Revision {
t.Helper()
bindings := make([]middleware.PathTargetBinding, 0, len(mapping.Paths))
for path, target := range mapping.Paths {
if target == nil || len(target.Middlewares) == 0 {
continue
}
bindings = append(bindings, middleware.PathTargetBinding{
ServiceID: string(mapping.ID),
PathID: path,
Specs: target.Middlewares,
})
}
revision, err := manager.RebuildSnapshot(string(mapping.ID), bindings)
require.NoError(t, err)
require.NotZero(t, revision)
mapping.MiddlewareRevision = revision
return revision
}
+300
View File
@@ -0,0 +1,300 @@
package proxy
import (
"context"
"errors"
"fmt"
"maps"
"net/http"
"net/url"
"sort"
"strings"
"unicode/utf8"
"github.com/netbirdio/netbird/proxy/internal/middleware"
"github.com/netbirdio/netbird/proxy/internal/middleware/bodytap"
)
// ErrUnsafeRequestPath indicates that a request path cannot be matched safely.
var ErrUnsafeRequestPath = errors.New("unsafe request path")
// TargetResolver is an immutable routing snapshot shared by auth and forwarding.
type TargetResolver struct {
mapping Mapping
strictPaths bool
hasBypass bool
}
type targetResolution struct {
result targetResult
matched bool
}
type targetResolutionContextKey struct{}
// NewTargetResolver builds an immutable target-routing snapshot.
func NewTargetResolver(mapping Mapping) (*TargetResolver, error) {
snapshot, err := cloneResolverMapping(mapping)
if err != nil {
return nil, err
}
resolver := &TargetResolver{mapping: snapshot}
for _, target := range snapshot.Paths {
action := effectiveAccessAction(target.AccessAction)
if action != AccessActionInherit {
resolver.strictPaths = true
}
if action == AccessActionBypass {
resolver.hasBypass = true
}
}
return resolver, nil
}
// ResolveRequest selects and pins a target for both auth and forwarding.
func (r *TargetResolver) ResolveRequest(req *http.Request) (*http.Request, AccessAction, error) {
if r == nil {
return req, AccessActionInherit, nil
}
if req == nil || req.URL == nil {
return req, AccessActionInherit, fmt.Errorf("%w: missing URL", ErrUnsafeRequestPath)
}
if r.strictPaths {
if err := validateAccessPath(req.URL); err != nil {
return req, AccessActionInherit, err
}
}
result, matched := findTargetInMapping(req.URL.Path, r.mapping)
resolution := targetResolution{result: result, matched: matched}
ctx := context.WithValue(req.Context(), targetResolutionContextKey{}, resolution)
resolvedReq := req.WithContext(ctx)
if !matched {
return resolvedReq, AccessActionInherit, nil
}
if cd := CapturedDataFromContext(ctx); cd != nil {
cd.SetServiceID(result.serviceID)
cd.SetAccountID(result.accountID)
cd.SetAgentNetwork(result.target.AgentNetwork)
cd.SetSuppressAccessLog(result.target.DisableAccessLog)
}
return resolvedReq, effectiveAccessAction(result.target.AccessAction), nil
}
// HasBypass reports whether the snapshot contains an authentication bypass.
func (r *TargetResolver) HasBypass() bool {
return r != nil && r.hasBypass
}
// WithMiddlewareRevision returns a routing snapshot bound to the given policy revision.
func (r *TargetResolver) WithMiddlewareRevision(revision middleware.Revision) *TargetResolver {
bound := *r
bound.mapping.MiddlewareRevision = revision
return &bound
}
func targetResolutionFromContext(ctx context.Context) (targetResolution, bool) {
resolution, ok := ctx.Value(targetResolutionContextKey{}).(targetResolution)
return resolution, ok
}
func effectiveAccessAction(action AccessAction) AccessAction {
if action == "" {
return AccessActionInherit
}
return action
}
func mappingHasNonDefaultAccessAction(mapping Mapping) bool {
for _, target := range mapping.Paths {
if target != nil && effectiveAccessAction(target.AccessAction) != AccessActionInherit {
return true
}
}
return false
}
func cloneResolverMapping(mapping Mapping) (Mapping, error) {
snapshot := mapping
snapshot.Paths = make(map[string]*PathTarget, len(mapping.Paths))
snapshot.StripAuthHeaders = append([]string(nil), mapping.StripAuthHeaders...)
snapshot.sortedPaths = make([]string, 0, len(mapping.Paths))
for path, target := range mapping.Paths {
if target == nil || target.URL == nil {
return Mapping{}, fmt.Errorf("target for path %q is incomplete", path)
}
action := effectiveAccessAction(target.AccessAction)
switch action {
case AccessActionInherit, AccessActionBypass, AccessActionBlock:
default:
return Mapping{}, fmt.Errorf("unknown access action %q for path %q", target.AccessAction, path)
}
if action == AccessActionBypass && target.AgentNetwork {
return Mapping{}, fmt.Errorf("authentication bypass is not allowed for Agent Network target %q", path)
}
if action != AccessActionInherit {
if err := validateAccessPrefix(path); err != nil {
return Mapping{}, fmt.Errorf("invalid access-controlled path %q: %w", path, err)
}
}
cloned := clonePathTarget(target)
cloned.AccessAction = action
snapshot.Paths[path] = cloned
snapshot.sortedPaths = append(snapshot.sortedPaths, path)
}
sort.Slice(snapshot.sortedPaths, func(i, j int) bool {
return len(snapshot.sortedPaths[i]) > len(snapshot.sortedPaths[j])
})
snapshot.requirePinnedResolution = mappingHasNonDefaultAccessAction(snapshot)
return snapshot, nil
}
func validateAccessPrefix(prefix string) error {
if strings.ContainsAny(prefix, "?#") {
return fmt.Errorf("%w: query or fragment delimiter", ErrUnsafeRequestPath)
}
return validateAccessPath(&url.URL{Path: prefix})
}
func clonePathTarget(target *PathTarget) *PathTarget {
cloned := *target
clonedURL := *target.URL
cloned.URL = &clonedURL
cloned.CustomHeaders = maps.Clone(target.CustomHeaders)
cloned.Middlewares = cloneMiddlewareSpecs(target.Middlewares)
cloned.CaptureConfig = cloneCaptureConfig(target.CaptureConfig)
return &cloned
}
func cloneMiddlewareSpecs(specs []middleware.Spec) []middleware.Spec {
if specs == nil {
return nil
}
cloned := make([]middleware.Spec, len(specs))
for i, spec := range specs {
cloned[i] = spec.Clone()
}
return cloned
}
func cloneCaptureConfig(config *bodytap.Config) *bodytap.Config {
if config == nil {
return nil
}
cloned := *config
cloned.ContentTypes = append([]string(nil), config.ContentTypes...)
return &cloned
}
func findTargetInMapping(path string, mapping Mapping) (targetResult, bool) {
for _, prefix := range mapping.sortedPaths {
if !strings.HasPrefix(path, prefix) {
continue
}
target := mapping.Paths[prefix]
if target == nil || target.URL == nil {
continue
}
return targetResult{
target: target,
matchedPath: prefix,
serviceID: mapping.ID,
accountID: mapping.AccountID,
passHostHeader: mapping.PassHostHeader,
rewriteRedirects: mapping.RewriteRedirects,
stripAuthHeaders: mapping.StripAuthHeaders,
middlewareRevision: mapping.MiddlewareRevision,
requirePinned: mapping.requirePinnedResolution,
}, true
}
return targetResult{requirePinned: mapping.requirePinnedResolution}, false
}
func validateAccessPath(requestURL *url.URL) error {
if requestURL.Opaque != "" || requestURL.Path == "" || requestURL.Path[0] != '/' {
return fmt.Errorf("%w: path must be absolute", ErrUnsafeRequestPath)
}
if !utf8.ValidString(requestURL.Path) {
return fmt.Errorf("%w: path is not valid UTF-8", ErrUnsafeRequestPath)
}
if requestURL.RawPath != "" {
decoded, err := url.PathUnescape(requestURL.RawPath)
if err != nil || decoded != requestURL.Path {
return fmt.Errorf("%w: invalid escaped path", ErrUnsafeRequestPath)
}
}
if err := validateDecodedAccessPath(requestURL.Path); err != nil {
return err
}
return validateEscapedAccessPath(requestURL.EscapedPath())
}
func validateDecodedAccessPath(path string) error {
if strings.Contains(path, "//") || strings.ContainsAny(path, "\\;?#") {
return fmt.Errorf("%w: ambiguous path separator", ErrUnsafeRequestPath)
}
for _, segment := range strings.Split(path, "/") {
if segment == "." || segment == ".." {
return fmt.Errorf("%w: dot segment", ErrUnsafeRequestPath)
}
}
for _, char := range path {
if char < 0x20 || char == 0x7f {
return fmt.Errorf("%w: control character", ErrUnsafeRequestPath)
}
}
return nil
}
func validateEscapedAccessPath(escapedPath string) error {
for i := 0; i < len(escapedPath); i++ {
if escapedPath[i] != '%' {
continue
}
if i+2 >= len(escapedPath) {
return fmt.Errorf("%w: truncated escape", ErrUnsafeRequestPath)
}
value, ok := decodeHexByte(escapedPath[i+1], escapedPath[i+2])
if !ok {
return fmt.Errorf("%w: invalid escape", ErrUnsafeRequestPath)
}
switch value {
case '/', '\\', '.', '%', 0:
return fmt.Errorf("%w: ambiguous escaped character", ErrUnsafeRequestPath)
}
if value < 0x20 || value == 0x7f {
return fmt.Errorf("%w: escaped control character", ErrUnsafeRequestPath)
}
i += 2
}
return nil
}
func decodeHexByte(high, low byte) (byte, bool) {
hi, ok := hexNibble(high)
if !ok {
return 0, false
}
lo, ok := hexNibble(low)
if !ok {
return 0, false
}
return hi<<4 | lo, true
}
func hexNibble(value byte) (byte, bool) {
switch {
case value >= '0' && value <= '9':
return value - '0', true
case value >= 'a' && value <= 'f':
return value - 'a' + 10, true
case value >= 'A' && value <= 'F':
return value - 'A' + 10, true
default:
return 0, false
}
}
@@ -0,0 +1,202 @@
package proxy
import (
"net/url"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestValidateAccessPath(t *testing.T) {
tests := []struct {
name string
url url.URL
wantErr bool
}{
{name: "root", url: url.URL{Path: "/"}},
{name: "nested path", url: url.URL{Path: "/public/status"}},
{name: "trailing slash", url: url.URL{Path: "/public/"}},
{name: "dots within segments", url: url.URL{Path: "/.well-known/file..json"}},
{name: "space", url: url.URL{Path: "/file name"}},
{name: "unicode", url: url.URL{Path: "/unicode/é"}},
{name: "encoded space", url: url.URL{Path: "/file name", RawPath: "/file%20name"}},
{name: "encoded letter", url: url.URL{Path: "/public", RawPath: "/%70ublic"}},
{name: "encoded unicode", url: url.URL{Path: "/unicode/é", RawPath: "/unicode/%C3%a9"}},
{
name: "query and fragment are separate from the path",
url: url.URL{Path: "/public", RawQuery: "next=/../private", Fragment: "section"},
},
{name: "empty path", url: url.URL{}, wantErr: true},
{name: "relative path", url: url.URL{Path: "public"}, wantErr: true},
{name: "opaque URL", url: url.URL{Path: "/public", Opaque: "//example.com/private"}, wantErr: true},
{name: "invalid UTF-8", url: url.URL{Path: "/public/\xff"}, wantErr: true},
{
name: "encoded invalid UTF-8",
url: url.URL{Path: "/public/\xff", RawPath: "/public/%ff"},
wantErr: true,
},
{name: "bare escape", url: url.URL{Path: "/public", RawPath: "/public%"}, wantErr: true},
{name: "short escape", url: url.URL{Path: "/public", RawPath: "/public%2"}, wantErr: true},
{name: "invalid escape", url: url.URL{Path: "/public", RawPath: "/public%GG"}, wantErr: true},
{name: "mismatched raw path", url: url.URL{Path: "/public", RawPath: "/private"}, wantErr: true},
{name: "duplicate separator", url: url.URL{Path: "/public//private"}, wantErr: true},
{name: "backslash", url: url.URL{Path: "/public\\private"}, wantErr: true},
{name: "path parameter", url: url.URL{Path: "/public;private"}, wantErr: true},
{name: "query delimiter in path", url: url.URL{Path: "/public?private"}, wantErr: true},
{name: "fragment delimiter in path", url: url.URL{Path: "/public#private"}, wantErr: true},
{name: "dot segment", url: url.URL{Path: "/public/./private"}, wantErr: true},
{name: "parent segment", url: url.URL{Path: "/public/../private"}, wantErr: true},
{name: "NUL", url: url.URL{Path: "/public/\x00private"}, wantErr: true},
{name: "control character", url: url.URL{Path: "/public/\x1fprivate"}, wantErr: true},
{name: "DEL", url: url.URL{Path: "/public/\x7fprivate"}, wantErr: true},
{
name: "encoded separator",
url: url.URL{Path: "/public/private", RawPath: "/public%2Fprivate"},
wantErr: true,
},
{
// The decoded dot is within a segment, so only escaped validation rejects it.
name: "encoded dot within segment",
url: url.URL{Path: "/file.txt", RawPath: "/file%2Etxt"},
wantErr: true,
},
{
name: "double encoding",
url: url.URL{Path: "/public/%2e%2e/private", RawPath: "/public/%252e%252e/private"},
wantErr: true,
},
{name: "percent without raw path", url: url.URL{Path: "/public/%2fprivate"}, wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateAccessPath(&tt.url)
if tt.wantErr {
assert.ErrorIs(t, err, ErrUnsafeRequestPath, "unsafe URL path must be rejected")
return
}
assert.NoError(t, err, "ordinary URL path must remain supported")
})
}
}
func TestValidateAccessPath_RejectsEncodedTraversal(t *testing.T) {
tests := []struct {
name string
rawPath string
}{
{name: "encoded parent segment", rawPath: "/public/%2E%2E/private"},
{
name: "UTF-16LE encoded traversal",
rawPath: "%2f%00%70%00%75%00%62%00%6c%00%69%00%63%00%2f%00" +
"%2e%00%2e%00%2f%00%70%00%72%00%69%00%76%00%61%00%74%00%65%00",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Preserve percent-decoded bytes, including NULs, without transcoding UTF-16.
path, err := url.PathUnescape(tt.rawPath)
require.NoError(t, err)
err = validateAccessPath(&url.URL{Path: path, RawPath: tt.rawPath})
assert.ErrorIs(t, err, ErrUnsafeRequestPath, "encoded traversal must be rejected")
})
}
}
func TestValidateDecodedAccessPath(t *testing.T) {
tests := []struct {
name string
path string
wantErr bool
}{
{name: "root", path: "/"},
{name: "nested path", path: "/public/status"},
{name: "trailing slash", path: "/public/"},
{name: "dots within segments", path: "/.well-known/file..json/..."},
{name: "space boundary", path: "/file\x20name"},
{name: "last printable ASCII", path: "/file\x7e"},
{name: "unicode", path: "/unicode/é"},
{name: "duplicate leading separator", path: "//public", wantErr: true},
{name: "duplicate nested separator", path: "/public//private", wantErr: true},
{name: "backslash", path: "/public\\private", wantErr: true},
{name: "path parameter", path: "/public;private", wantErr: true},
{name: "query delimiter", path: "/public?private", wantErr: true},
{name: "fragment delimiter", path: "/public#private", wantErr: true},
{name: "dot segment", path: "/public/./private", wantErr: true},
{name: "parent segment", path: "/public/../private", wantErr: true},
{name: "terminal dot segment", path: "/public/.", wantErr: true},
{name: "terminal parent segment", path: "/public/..", wantErr: true},
{name: "NUL", path: "/public/\x00private", wantErr: true},
{name: "tab", path: "/public/\tprivate", wantErr: true},
{name: "newline", path: "/public/\nprivate", wantErr: true},
{name: "carriage return", path: "/public/\rprivate", wantErr: true},
{name: "last C0 control", path: "/public/\x1fprivate", wantErr: true},
{name: "DEL", path: "/public/\x7fprivate", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateDecodedAccessPath(tt.path)
if tt.wantErr {
assert.ErrorIs(t, err, ErrUnsafeRequestPath, "unsafe decoded path %q must be rejected", tt.path)
return
}
assert.NoError(t, err, "ordinary decoded path %q must remain supported", tt.path)
})
}
}
func TestValidateEscapedAccessPath(t *testing.T) {
// Malformed escapes and decoded controls normally fail earlier in validateAccessPath.
// Exercise these guards directly so changes to that ordering cannot silently weaken them.
tests := []struct {
name string
path string
wantErr bool
}{
{name: "root", path: "/"},
{name: "unescaped path", path: "/.well-known/file.txt"},
{name: "encoded space boundary", path: "/file%20name"},
{name: "encoded last printable ASCII lowercase", path: "/file%7e"},
{name: "encoded last printable ASCII uppercase", path: "/file%7E"},
{name: "adjacent escapes", path: "/%41%42%39"},
{name: "mixed case unicode escapes", path: "/unicode/%C3%a9"},
{name: "bare escape", path: "/public/%", wantErr: true},
{name: "short escape", path: "/public/%2", wantErr: true},
{name: "invalid high nibble", path: "/public/%G0", wantErr: true},
{name: "invalid low nibble", path: "/public/%0g", wantErr: true},
{name: "encoded slash lowercase", path: "/public%2fprivate", wantErr: true},
{name: "encoded slash uppercase", path: "/public%2Fprivate", wantErr: true},
{name: "encoded backslash lowercase", path: "/public%5cprivate", wantErr: true},
{name: "encoded backslash uppercase", path: "/public%5Cprivate", wantErr: true},
{name: "encoded dot lowercase", path: "/file%2etxt", wantErr: true},
{name: "encoded dot uppercase", path: "/file%2Etxt", wantErr: true},
{name: "encoded percent", path: "/public/%25", wantErr: true},
{name: "double encoding", path: "/public/%252e%252e/private", wantErr: true},
{name: "encoded NUL", path: "/public/%00private", wantErr: true},
{name: "encoded first non-NUL control", path: "/public/%01private", wantErr: true},
{name: "encoded tab", path: "/public/%09private", wantErr: true},
{name: "encoded newline", path: "/public/%0aprivate", wantErr: true},
{name: "encoded carriage return", path: "/public/%0Dprivate", wantErr: true},
{name: "encoded last C0 control lowercase", path: "/public/%1fprivate", wantErr: true},
{name: "encoded last C0 control uppercase", path: "/public/%1Fprivate", wantErr: true},
{name: "encoded DEL lowercase", path: "/public/%7fprivate", wantErr: true},
{name: "encoded DEL uppercase", path: "/public/%7Fprivate", wantErr: true},
{name: "unsafe escape after valid escape", path: "/%41%2fprivate", wantErr: true},
{name: "malformed escape after valid escape", path: "/%41%", wantErr: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateEscapedAccessPath(tt.path)
if tt.wantErr {
assert.ErrorIs(t, err, ErrUnsafeRequestPath, "unsafe escaped path %q must be rejected", tt.path)
return
}
assert.NoError(t, err, "ordinary escaped path %q must remain supported", tt.path)
})
}
}
@@ -0,0 +1,336 @@
package proxy
import (
"errors"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/proxy/internal/middleware"
guardrail "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_guardrail"
)
type resolverRoundTripFunc func(*http.Request) (*http.Response, error)
func (f resolverRoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
return f(req)
}
func resolverMapping(targets map[string]*PathTarget) Mapping {
return Mapping{
ID: "service-1",
AccountID: "account-1",
Host: "example.com",
Paths: targets,
}
}
func mustTargetURL(t *testing.T, rawURL string) *url.URL {
t.Helper()
targetURL, err := url.Parse(rawURL)
require.NoError(t, err)
return targetURL
}
func TestTargetResolver_LongestPrefixPinsTargetAndAction(t *testing.T) {
mapping := resolverMapping(map[string]*PathTarget{
"/": {
URL: mustTargetURL(t, "http://root.internal"),
AccessAction: AccessActionInherit,
},
"/public": {
URL: mustTargetURL(t, "http://public.internal"),
AccessAction: AccessActionBypass,
CustomHeaders: map[string]string{"X-Target": "original"},
},
})
resolver, err := NewTargetResolver(mapping)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "http://example.com/public/status", nil)
resolvedReq, action, err := resolver.ResolveRequest(req)
require.NoError(t, err)
assert.Equal(t, AccessActionBypass, action, "the longest matching target action should be selected")
// Mutating both the source mapping and the live proxy mapping must not
// change the target already selected by the auth snapshot.
mapping.Paths["/public"].URL.Host = "mutated.internal"
mapping.Paths["/public"].CustomHeaders["X-Target"] = "mutated"
liveMapping := resolverMapping(map[string]*PathTarget{
"/": {URL: mustTargetURL(t, "http://replacement.internal")},
})
var gotHost, gotHeader string
rp := NewReverseProxy(resolverRoundTripFunc(func(req *http.Request) (*http.Response, error) {
gotHost = req.URL.Host
gotHeader = req.Header.Get("X-Target")
return &http.Response{
StatusCode: http.StatusNoContent,
Header: make(http.Header),
Body: http.NoBody,
}, nil
}), "auto", nil, nil)
rp.AddMapping(liveMapping)
rec := httptest.NewRecorder()
rp.ServeHTTP(rec, resolvedReq)
assert.Equal(t, http.StatusNoContent, rec.Code)
assert.Equal(t, "public.internal", gotHost, "forwarding must reuse the target pinned before auth")
assert.Equal(t, "original", gotHeader, "resolver-owned target options must be immutable")
}
func TestTargetResolver_PinnedMissDoesNotFallBackToNewMapping(t *testing.T) {
resolver, err := NewTargetResolver(resolverMapping(map[string]*PathTarget{
"/api": {URL: mustTargetURL(t, "http://api.internal")},
}))
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "http://example.com/other", nil)
resolvedReq, action, err := resolver.ResolveRequest(req)
require.NoError(t, err)
assert.Equal(t, AccessActionInherit, action)
called := false
rp := NewReverseProxy(resolverRoundTripFunc(func(*http.Request) (*http.Response, error) {
called = true
return nil, errors.New("unexpected forwarding")
}), "auto", nil, nil)
rp.AddMapping(resolverMapping(map[string]*PathTarget{
"/": {URL: mustTargetURL(t, "http://new.internal")},
}))
rec := httptest.NewRecorder()
rp.ServeHTTP(rec, resolvedReq)
assert.Equal(t, http.StatusNotFound, rec.Code)
assert.False(t, called, "a miss pinned before auth must not be resolved again after an update")
}
func TestReverseProxy_UnpinnedNonDefaultMappingFailsClosed(t *testing.T) {
for _, requestPath := range []string{"/public", "/missing"} {
t.Run(requestPath, func(t *testing.T) {
called := false
rp := NewReverseProxy(resolverRoundTripFunc(func(*http.Request) (*http.Response, error) {
called = true
return nil, errors.New("unexpected forwarding")
}), "auto", nil, nil)
rp.AddMapping(resolverMapping(map[string]*PathTarget{
"/public": {
URL: mustTargetURL(t, "http://public.internal"),
AccessAction: AccessActionBypass,
},
}))
req := httptest.NewRequest(http.MethodGet, "http://example.com"+requestPath, nil)
rec := httptest.NewRecorder()
rp.ServeHTTP(rec, req)
assert.Equal(t, http.StatusServiceUnavailable, rec.Code,
"both target hits and misses must require an auth-owned resolution")
assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"),
"an unavailable routing snapshot must not be cached")
assert.False(t, called, "a target access action must never forward without an auth-owned resolution")
})
}
}
func TestReverseProxy_ConfiguredMiddlewareMissingChainFailsClosed(t *testing.T) {
called := false
manager := middleware.NewManager(0, nil, nil)
rp := NewReverseProxy(resolverRoundTripFunc(func(*http.Request) (*http.Response, error) {
called = true
return nil, errors.New("unexpected forwarding")
}), "auto", nil, nil, WithMiddlewareManager(manager))
rp.AddMapping(resolverMapping(map[string]*PathTarget{
"/": {
URL: mustTargetURL(t, "http://protected.internal"),
Middlewares: []middleware.Spec{{
ID: "required-policy",
Slot: middleware.SlotOnRequest,
Enabled: true,
}},
},
}))
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
rec := httptest.NewRecorder()
rp.ServeHTTP(rec, req)
assert.Equal(t, http.StatusServiceUnavailable, rec.Code)
assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"))
assert.False(t, called, "a missing configured policy chain must never become pass-through")
}
func TestReverseProxy_PinnedTargetFailsClosedAfterMiddlewareRemoval(t *testing.T) {
target := &PathTarget{
URL: mustTargetURL(t, "http://protected.internal"),
AccessAction: AccessActionInherit,
Middlewares: []middleware.Spec{{
ID: guardrail.ID,
Slot: middleware.SlotOnRequest,
Enabled: true,
}},
}
mapping := resolverMapping(map[string]*PathTarget{"/": target})
resolver, err := NewTargetResolver(mapping)
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
resolvedReq, _, err := resolver.ResolveRequest(req)
require.NoError(t, err)
upstreamCalls := 0
registry := middleware.NewRegistry()
require.NoError(t, registry.Register(guardrail.Factory{}))
manager := middleware.NewManager(0, nil, nil)
manager.SetResolver(middleware.NewResolver(registry))
require.NoError(t, manager.Rebuild(string(mapping.ID), []middleware.PathTargetBinding{{
ServiceID: string(mapping.ID),
PathID: "/",
Specs: target.Middlewares,
}}))
require.NotNil(t, manager.ChainFor(string(mapping.ID), "/"), "the policy chain must exist before invalidation")
rp := NewReverseProxy(resolverRoundTripFunc(func(*http.Request) (*http.Response, error) {
upstreamCalls++
return &http.Response{
StatusCode: http.StatusNoContent,
Header: make(http.Header),
Body: http.NoBody,
}, nil
}), "auto", nil, nil, WithMiddlewareManager(manager))
before := httptest.NewRecorder()
rp.ServeHTTP(before, resolvedReq.Clone(resolvedReq.Context()))
require.Equal(t, http.StatusNoContent, before.Code, "the installed policy chain must allow forwarding")
require.Equal(t, 1, upstreamCalls, "the upstream must receive the request before invalidation")
manager.Invalidate(string(mapping.ID))
require.Nil(t, manager.ChainFor(string(mapping.ID), "/"), "invalidation must remove the installed chain")
rec := httptest.NewRecorder()
rp.ServeHTTP(rec, resolvedReq)
assert.Equal(t, http.StatusServiceUnavailable, rec.Code, "a removed policy chain must fail closed")
assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"), "a removed policy chain must not be cached")
assert.Equal(t, 1, upstreamCalls, "a stale pinned target must not bypass a removed policy chain")
}
func TestTargetResolver_RejectsUnsafePathsWhenActionsAreConfigured(t *testing.T) {
resolver, err := NewTargetResolver(resolverMapping(map[string]*PathTarget{
"/public": {
URL: mustTargetURL(t, "http://public.internal"),
AccessAction: AccessActionBypass,
},
"/": {URL: mustTargetURL(t, "http://root.internal")},
}))
require.NoError(t, err)
tests := []struct {
name string
path string
rawPath string
}{
{name: "dot segment", path: "/public/../private"},
{name: "duplicate separator", path: "/public//private"},
{name: "backslash", path: `/public\private`},
{name: "path parameter", path: "/public;ignored/private"},
{name: "encoded query delimiter", path: "/public?ignored", rawPath: "/public%3fignored"},
{name: "encoded fragment delimiter", path: "/public#ignored", rawPath: "/public%23ignored"},
{name: "encoded slash", path: "/public/private", rawPath: "/public%2fprivate"},
{name: "encoded dot", path: "/public/../private", rawPath: "/public/%2e%2e/private"},
{name: "double encoding", path: "/public/%2e%2e/private", rawPath: "/public/%252e%252e/private"},
{name: "escaped NUL", path: "/public/\x00private", rawPath: "/public/%00private"},
{name: "mismatched raw path", path: "/public/safe", rawPath: "/different"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
req.URL.Path = tt.path
req.URL.RawPath = tt.rawPath
_, _, err := resolver.ResolveRequest(req)
require.ErrorIs(t, err, ErrUnsafeRequestPath)
})
}
req := httptest.NewRequest(http.MethodGet, "http://example.com/public/file%20name", nil)
_, action, err := resolver.ResolveRequest(req)
require.NoError(t, err)
assert.Equal(t, AccessActionBypass, action, "ordinary percent-encoded path data should remain supported")
}
func TestTargetResolver_InheritOnlyPreservesLegacyPathHandling(t *testing.T) {
resolver, err := NewTargetResolver(resolverMapping(map[string]*PathTarget{
"/": {URL: mustTargetURL(t, "http://root.internal")},
}))
require.NoError(t, err)
req := httptest.NewRequest(http.MethodGet, "http://example.com/", nil)
req.URL.Path = "/legacy/../path"
resolvedReq, action, err := resolver.ResolveRequest(req)
require.NoError(t, err)
assert.Equal(t, AccessActionInherit, action)
_, pinned := targetResolutionFromContext(resolvedReq.Context())
assert.True(t, pinned, "inherit-only mappings should still pin their target snapshot")
}
func TestNewTargetResolver_RejectsInvalidAccessActions(t *testing.T) {
tests := []struct {
name string
target *PathTarget
}{
{
name: "unknown action",
target: &PathTarget{
URL: mustTargetURL(t, "http://target.internal"),
AccessAction: AccessAction("unknown"),
},
},
{
name: "Agent Network bypass",
target: &PathTarget{
URL: mustTargetURL(t, "http://target.internal"),
AccessAction: AccessActionBypass,
AgentNetwork: true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := NewTargetResolver(resolverMapping(map[string]*PathTarget{"/": tt.target}))
require.Error(t, err)
})
}
}
func TestNewTargetResolver_RejectsNoncanonicalAccessPrefixes(t *testing.T) {
prefixes := []string{
"public",
"/public/../private",
"/public//nested",
`/public\nested`,
"/public;parameter",
"/public%2fprivate",
"/public?query",
"/public#fragment",
"/public\x00nested",
}
for _, prefix := range prefixes {
t.Run(prefix, func(t *testing.T) {
_, err := NewTargetResolver(resolverMapping(map[string]*PathTarget{
prefix: {
URL: mustTargetURL(t, "http://target.internal"),
AccessAction: AccessActionBlock,
},
"/": {URL: mustTargetURL(t, "http://root.internal")},
}))
require.ErrorIs(t, err, ErrUnsafeRequestPath)
})
}
}