[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
+37
View File
@@ -33,6 +33,43 @@ If any authentication methods are registered for the Host domain, then the Proxy
If the user is successfully authenticated, their request will be forwarded through to the Proxy to be proxied to the relevant Peer.
Successful authentication does not guarantee a successful forwarding of the request as there may be failures behind the Proxy, such as with Peer connectivity or the underlying resource.
### Target access control
HTTP targets have an optional `access_action` setting, configured under
**Edit Target → Optional Settings → Access** in the dashboard:
| Value | Behavior |
| --- | --- |
| `inherit` | Use the service's authentication settings. This is the default. A service without authentication remains public. |
| `bypass` | Forward anonymously without prompting for service authentication. |
| `block` | Return HTTP 403, including for authenticated users. |
The longest matching Location prefix selects both the target and its access
action. Prefixes are literal and case-sensitive: `/api` also matches `/apiv2`,
while `/api/` only matches requests beginning with `/api/` and does not match
`/api` itself. Wildcards and regular expressions are not supported. A blocked
request or failed authentication never falls through to another target. Disabling
or deleting a target removes its rule, so a less-specific target may then match.
Service IP, country, and reputation restrictions still apply to bypassed paths.
Bypass is unavailable for private services and Agent Network targets. Bypassed
requests have no NetBird user identity; configured header-auth credentials,
NetBird session cookies, and client-supplied NetBird identity headers are still
removed before forwarding. Custom upstream headers remain target settings, so
consider their effect when making a target public.
Services using target access overrides reject ambiguous request paths, including
encoded separators and traversal segments, before forwarding. Path rewriting
continues to use the selected Location prefix after access checks.
Upgrade all proxies before using target access actions. Older proxies ignore
these actions and continue applying the service's authentication settings.
API updates that omit an existing Location's
`access_action` preserve its setting; send `"access_action": "inherit"` explicitly
to restore service authentication. Requests omitting all access actions cannot
remove or rename a controlled Location; use an updated client and explicitly
include the intended actions when changing those targets.
## TLS
Due to the authentication provided, the Proxy uses HTTPS for its endpoint, even if the underlying service is HTTP.
+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)
})
}
}
+65 -41
View File
@@ -1644,6 +1644,22 @@ func (s *Server) addMapping(ctx context.Context, mapping *proto.ProxyMapping) er
// state and re-applies them from the new mapping.
func (s *Server) modifyMapping(ctx context.Context, mapping *proto.ProxyMapping) error {
if old := s.loadMapping(types.ServiceID(mapping.GetId())); old != nil {
if !types.ServiceMode(old.GetMode()).IsL4() &&
!types.ServiceMode(mapping.GetMode()).IsL4() &&
old.GetDomain() == mapping.GetDomain() &&
len(old.GetPath()) > 0 && len(mapping.GetPath()) > 0 {
if err := s.updateMapping(ctx, mapping); err != nil {
// Fail the whole service closed until a valid update arrives: the old
// policy could retain access this replacement intended to revoke.
// Keep the peer, but withdraw routes and the cached mapping for recovery.
s.cleanupMappingRoutes(old)
s.deleteMapping(types.ServiceID(mapping.GetId()))
return err
}
s.storeMapping(mapping)
s.hijackTracker.CloseByHost(mapping.GetDomain())
return nil
}
s.cleanupMappingRoutes(old)
if mode := types.ServiceMode(old.GetMode()); mode.IsL4() {
s.meter.L4ServiceRemoved(mode)
@@ -2060,13 +2076,16 @@ func (s *Server) addUDPRelay(ctx context.Context, mapping *proto.ProxyMapping, t
}
func (s *Server) updateMapping(ctx context.Context, mapping *proto.ProxyMapping) error {
// Very simple implementation here, we don't touch the existing peer
// connection or any existing TLS configuration, we simply overwrite
// the auth and proxy mappings.
// Note: this does require the management server to always send a
// full mapping rather than deltas during a modification.
accountID := types.AccountID(mapping.GetAccountId())
svcID := types.ServiceID(mapping.GetId())
m, err := s.protoToMapping(ctx, mapping)
if err != nil {
return err
}
resolver, err := proxy.NewTargetResolver(m)
if err != nil {
return fmt.Errorf("prepare target routes: %w", err)
}
var schemes []auth.Scheme
if mapping.GetAuth().GetPassword() {
@@ -2084,19 +2103,19 @@ func (s *Server) updateMapping(ctx context.Context, mapping *proto.ProxyMapping)
s.warnIfGeoUnavailable(mapping.GetDomain(), mapping.GetAccessRestrictions())
maxSessionAge := time.Duration(mapping.GetAuth().GetMaxSessionAgeSeconds()) * time.Second
if err := s.auth.AddDomain(mapping.GetDomain(), schemes, mapping.GetAuth().GetSessionKey(), maxSessionAge, accountID, svcID, ipRestrictions, mapping.GetPrivate(), mapping.GetAuth().GetAllowedGroupIds()); err != nil {
config, err := auth.NewDomainConfig(mapping.GetDomain(), schemes, mapping.GetAuth().GetSessionKey(), maxSessionAge, accountID, svcID, ipRestrictions, mapping.GetPrivate(), mapping.GetAuth().GetAllowedGroupIds(), auth.WithTargetResolver(resolver))
if err != nil {
return fmt.Errorf("auth setup for domain %s: %w", mapping.GetDomain(), err)
}
m := s.protoToMapping(ctx, mapping)
// The chain is published before the route that leads to it. A request
// arriving at a target whose chain has not been rebuilt yet is served
// straight through, so a provider update that added the route first left a
// window in which an inference could complete unrouted and unmetered.
// Rebuilding first inverts that: the worst a request in the window meets is
// the new chain in front of the previous target, which is still counted.
if err := s.rebuildMiddlewareChains(svcID, m); err != nil {
revision, err := s.rebuildMiddlewareChains(svcID, m)
if err != nil {
return err
}
m.MiddlewareRevision = revision
config.TargetResolver = resolver.WithMiddlewareRevision(revision)
// The auth snapshot owns its resolver, so an in-flight request cannot
// authorize an old target and then forward to a replacement target.
s.auth.AddDomainConfig(mapping.GetDomain(), config)
s.meter.AddMapping(m)
s.proxy.AddMapping(m)
return nil
@@ -2161,22 +2180,17 @@ func (s *Server) initMiddlewareManager(ctx context.Context) error {
return nil
}
// rebuildMiddlewareChains converts m into per-path bindings and calls
// Manager.Rebuild. Short-circuits when the middleware manager is unset, which
// is a deployment without middleware rather than a failure to install it.
//
// A rebuild that fails is reported rather than logged: the caller publishes
// the route once this returns, and a route published over chains that were
// not installed serves requests with no policy enforcement and no metering.
func (s *Server) rebuildMiddlewareChains(svcID types.ServiceID, m proxy.Mapping) error {
// rebuildMiddlewareChains installs the service's policies before its routing snapshot.
func (s *Server) rebuildMiddlewareChains(svcID types.ServiceID, m proxy.Mapping) (middleware.Revision, error) {
if s.middlewareManager == nil {
return nil
return 0, nil
}
bindings := buildMiddlewareBindings(svcID, m)
if err := s.middlewareManager.Rebuild(string(svcID), bindings); err != nil {
return fmt.Errorf("rebuild middleware chains for service %s: %w", svcID, err)
revision, err := s.middlewareManager.RebuildSnapshot(string(svcID), bindings)
if err != nil {
return 0, fmt.Errorf("rebuild middleware chains for service %s: %w", svcID, err)
}
return nil
return revision, nil
}
// isLiveService reports whether svcID is currently present in the live
@@ -2261,10 +2275,10 @@ func (s *Server) cleanupMappingRoutes(mapping *proto.ProxyMapping) {
if s.acme != nil {
s.acme.RemoveDomain(d)
}
s.auth.RemoveDomain(host)
if s.proxy.RemoveMapping(proxy.Mapping{Host: host}) {
s.meter.RemoveMapping(proxy.Mapping{Host: host})
}
s.auth.RemoveDomain(host)
// Close hijacked connections (WebSocket) for this domain.
if n := s.hijackTracker.CloseByHost(host); n > 0 {
s.Logger.Debugf("closed %d hijacked connection(s) for %s", n, host)
@@ -2337,23 +2351,33 @@ func (s *Server) deleteMapping(svcID types.ServiceID) *proto.ProxyMapping {
return m
}
func (s *Server) protoToMapping(ctx context.Context, mapping *proto.ProxyMapping) proxy.Mapping {
func (s *Server) protoToMapping(ctx context.Context, mapping *proto.ProxyMapping) (proxy.Mapping, error) {
pathMappings := mapping.GetPath()
actions, strictPaths, err := parseTargetAccessActions(pathMappings)
if err != nil {
return proxy.Mapping{}, err
}
paths := make(map[string]*proxy.PathTarget)
for _, pathMapping := range mapping.GetPath() {
for i, pathMapping := range pathMappings {
path := pathMapping.GetPath()
if strictPaths && path == "" {
path = "/"
}
if strictPaths {
if _, exists := paths[path]; exists {
return proxy.Mapping{}, fmt.Errorf("duplicate target location %q", path)
}
}
targetURL, err := url.Parse(pathMapping.GetTarget())
if err != nil {
s.Logger.WithFields(log.Fields{
"service_id": mapping.GetId(),
"account_id": mapping.GetAccountId(),
"domain": mapping.GetDomain(),
"path": pathMapping.GetPath(),
"target": pathMapping.GetTarget(),
}).WithError(err).Error("failed to parse target URL for path, skipping")
s.notifyError(ctx, mapping, fmt.Errorf("invalid target URL %q for path %q: %w", pathMapping.GetTarget(), pathMapping.GetPath(), err))
continue
return proxy.Mapping{}, fmt.Errorf("parse target URL for location %q: %w", path, err)
}
if targetURL.Host == "" || (targetURL.Scheme != "http" && targetURL.Scheme != "https") {
return proxy.Mapping{}, fmt.Errorf("invalid HTTP target URL for location %q", path)
}
pt := &proxy.PathTarget{URL: targetURL}
pt := &proxy.PathTarget{URL: targetURL, AccessAction: actions[i]}
if opts := pathMapping.GetOptions(); opts != nil {
pt.SkipTLSVerify = opts.GetSkipTlsVerify()
pt.PathRewrite = protoToPathRewrite(opts.GetPathRewrite())
@@ -2370,7 +2394,7 @@ func (s *Server) protoToMapping(ctx context.Context, mapping *proto.ProxyMapping
pt.DisableAccessLog = opts.GetDisableAccessLog()
}
pt.RequestTimeout = s.clampDialTimeout(pt.RequestTimeout)
paths[pathMapping.GetPath()] = pt
paths[path] = pt
}
m := proxy.Mapping{
ID: types.ServiceID(mapping.GetId()),
@@ -2383,7 +2407,7 @@ func (s *Server) protoToMapping(ctx context.Context, mapping *proto.ProxyMapping
for _, ha := range mapping.GetAuth().GetHeaderAuths() {
m.StripAuthHeaders = append(m.StripAuthHeaders, ha.GetHeader())
}
return m
return m, nil
}
func protoToPathRewrite(mode proto.PathRewriteMode) proxy.PathRewriteMode {
+40
View File
@@ -0,0 +1,40 @@
package proxy
import (
"fmt"
"github.com/netbirdio/netbird/proxy/internal/proxy"
"github.com/netbirdio/netbird/shared/management/proto"
)
func parseTargetAccessActions(pathMappings []*proto.PathMapping) ([]proxy.AccessAction, bool, error) {
actions := make([]proxy.AccessAction, len(pathMappings))
strictPaths := false
for i, pathMapping := range pathMappings {
if pathMapping == nil {
return nil, false, fmt.Errorf("nil target mapping")
}
action, err := targetAccessActionFromProto(pathMapping.GetAccessAction())
if err != nil {
return nil, false, err
}
actions[i] = action
if action != proxy.AccessActionInherit {
strictPaths = true
}
}
return actions, strictPaths, nil
}
func targetAccessActionFromProto(action proto.TargetAccessAction) (proxy.AccessAction, error) {
switch action {
case proto.TargetAccessAction_TARGET_ACCESS_ACTION_INHERIT:
return proxy.AccessActionInherit, nil
case proto.TargetAccessAction_TARGET_ACCESS_ACTION_BYPASS:
return proxy.AccessActionBypass, nil
case proto.TargetAccessAction_TARGET_ACCESS_ACTION_BLOCK:
return proxy.AccessActionBlock, nil
default:
return "", fmt.Errorf("unknown target access action %d", action)
}
}
+172
View File
@@ -0,0 +1,172 @@
package proxy
import (
"context"
"crypto/tls"
"net"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel/metric/noop"
"github.com/netbirdio/netbird/proxy/internal/auth"
proxymetrics "github.com/netbirdio/netbird/proxy/internal/metrics"
internalproxy "github.com/netbirdio/netbird/proxy/internal/proxy"
"github.com/netbirdio/netbird/proxy/internal/roundtrip"
nbtcp "github.com/netbirdio/netbird/proxy/internal/tcp"
proxytypes "github.com/netbirdio/netbird/proxy/internal/types"
"github.com/netbirdio/netbird/shared/management/proto"
)
const (
targetAccessAccountID = "test-account-1"
targetAccessCluster = "test.proxy.io"
targetAccessDomain = "target-access.test.proxy.io"
targetAccessHeader = "X-Test-Key"
targetAccessSecret = "valid-secret"
)
type targetAccessRequest struct {
path string
headers http.Header
}
type targetAccessUpstream struct {
mu sync.Mutex
requests []targetAccessRequest
}
func (u *targetAccessUpstream) ServeHTTP(w http.ResponseWriter, r *http.Request) {
u.mu.Lock()
u.requests = append(u.requests, targetAccessRequest{
path: r.URL.Path,
headers: r.Header.Clone(),
})
u.mu.Unlock()
w.WriteHeader(http.StatusOK)
}
func (u *targetAccessUpstream) snapshot() []targetAccessRequest {
u.mu.Lock()
defer u.mu.Unlock()
return append([]targetAccessRequest(nil), u.requests...)
}
func newTargetAccessRuntime(t *testing.T) (*Server, http.Handler) {
t.Helper()
logger := quietLifecycleLogger()
meter, err := proxymetrics.New(t.Context(), noop.Meter{})
require.NoError(t, err)
reverseProxy := internalproxy.NewReverseProxy(http.DefaultTransport, "auto", nil, logger)
authMiddleware := auth.NewMiddleware(logger, nil, nil)
srv := &Server{
ctx: t.Context(),
Logger: logger,
proxy: reverseProxy,
auth: authMiddleware,
meter: meter,
mainRouter: nbtcp.NewRouter(logger, nil, &net.TCPAddr{}),
portRouters: make(map[uint16]*portRouter),
svcPorts: make(map[proxytypes.ServiceID][]uint16),
lastMappings: make(map[proxytypes.ServiceID]*proto.ProxyMapping),
crowdsecServices: make(map[proxytypes.ServiceID]bool),
removePeer: func(context.Context, proxytypes.AccountID, roundtrip.ServiceKey) error {
return nil
},
}
return srv, authMiddleware.Protect(reverseProxy)
}
func applyTargetAccessMapping(t *testing.T, srv *Server, mapping *proto.ProxyMapping) {
t.Helper()
require.NoError(t, srv.updateMapping(t.Context(), mapping))
srv.storeMapping(mapping)
}
func targetAccessRequestTo(handler http.Handler, path, remoteAddr string, headers http.Header) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodGet, "http://"+targetAccessDomain+path, nil)
req.Host = targetAccessDomain
req.RemoteAddr = remoteAddr
for name, values := range headers {
for _, value := range values {
req.Header.Add(name, value)
}
}
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, req)
return recorder
}
func TestModifyHTTPMappingFromEmptyPathsRegistersRoute(t *testing.T) {
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("X-Test-Upstream", "reached")
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(upstream.Close)
runtime, handler := newTargetAccessRuntime(t)
listener, err := net.Listen("tcp", "127.0.0.1:0")
require.NoError(t, err)
t.Cleanup(func() { _ = listener.Close() })
router := nbtcp.NewRouter(runtime.Logger, func(proxytypes.AccountID) (proxytypes.DialContextFunc, error) {
return func(context.Context, string, string) (net.Conn, error) {
return nil, net.ErrClosed
}, nil
}, listener.Addr())
router.SetFallback(nbtcp.Route{
Type: nbtcp.RouteTCP,
AccountID: "fallback-account",
ServiceID: "fallback-service",
Target: "unreachable.test:443",
})
runtime.mainRouter = router
httpServer := &http.Server{
Handler: handler,
TLSConfig: selfSignedTLSConfig(t),
ReadHeaderTimeout: time.Second,
}
t.Cleanup(func() { _ = httpServer.Close() })
go func() { _ = httpServer.ServeTLS(router.HTTPListener(), "", "") }()
go feedRouterFromListener(t.Context(), listener, router, runtime.Logger, proxytypes.AccountID(targetAccessAccountID))
previous := &proto.ProxyMapping{
Id: "empty-to-routable",
AccountId: targetAccessAccountID,
Domain: targetAccessDomain,
}
runtime.storeMapping(previous)
replacement := &proto.ProxyMapping{
Id: previous.GetId(),
AccountId: previous.GetAccountId(),
Domain: previous.GetDomain(),
Path: []*proto.PathMapping{{
Path: "/",
Target: upstream.URL,
}},
}
require.NoError(t, runtime.modifyMapping(t.Context(), replacement))
transport := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS12}, //nolint:gosec
DialContext: func(ctx context.Context, network, _ string) (net.Conn, error) {
return (&net.Dialer{}).DialContext(ctx, network, listener.Addr().String())
},
}
t.Cleanup(transport.CloseIdleConnections)
client := &http.Client{Transport: transport, Timeout: 3 * time.Second}
response, err := client.Get("https://" + targetAccessDomain + "/ready")
require.NoError(t, err, "first target must register the domain with the SNI router")
t.Cleanup(func() { _ = response.Body.Close() })
assert.Equal(t, http.StatusNoContent, response.StatusCode)
assert.Equal(t, "reached", response.Header.Get("X-Test-Upstream"))
}
+151
View File
@@ -0,0 +1,151 @@
package proxy
import (
"net/http"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/proto"
internalproxy "github.com/netbirdio/netbird/proxy/internal/proxy"
managementproto "github.com/netbirdio/netbird/shared/management/proto"
)
func TestTargetAccessActionFromProto(t *testing.T) {
tests := []struct {
name string
wire managementproto.TargetAccessAction
want internalproxy.AccessAction
}{
{"inherit", managementproto.TargetAccessAction_TARGET_ACCESS_ACTION_INHERIT, internalproxy.AccessActionInherit},
{"bypass", managementproto.TargetAccessAction_TARGET_ACCESS_ACTION_BYPASS, internalproxy.AccessActionBypass},
{"block", managementproto.TargetAccessAction_TARGET_ACCESS_ACTION_BLOCK, internalproxy.AccessActionBlock},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := targetAccessActionFromProto(tt.wire)
require.NoError(t, err)
assert.Equal(t, tt.want, got, "wire action must retain its enforcement meaning")
})
}
_, err := targetAccessActionFromProto(managementproto.TargetAccessAction(99))
require.Error(t, err, "an unknown wire action must reject the mapping")
}
func TestProtoToMappingPreservesLegacyDuplicateLocations(t *testing.T) {
runtime, _ := newTargetAccessRuntime(t)
mapping, err := runtime.protoToMapping(t.Context(), &managementproto.ProxyMapping{
Id: "legacy-service", AccountId: "account", Domain: targetAccessDomain,
Path: []*managementproto.PathMapping{
{Path: "/", Target: "http://first.internal"},
{Path: "/", Target: "https://second.internal"},
{Path: "", Target: "http://empty.internal"},
},
})
require.NoError(t, err)
require.Len(t, mapping.Paths, 2, "legacy empty and slash locations must remain distinct")
assert.Equal(t, "second.internal", mapping.Paths["/"].URL.Host,
"legacy duplicate locations must retain last-entry-wins behavior")
assert.Equal(t, "empty.internal", mapping.Paths[""].URL.Host)
}
func TestProtoToMappingRejectsDuplicateLocationsWhenAccessControlIsActive(t *testing.T) {
tests := []struct {
name string
paths []*managementproto.PathMapping
}{
{
name: "duplicate action location",
paths: []*managementproto.PathMapping{
{Path: "/", Target: "http://first.internal"},
{Path: "/", Target: "http://second.internal", AccessAction: managementproto.TargetAccessAction_TARGET_ACCESS_ACTION_BLOCK},
},
},
{
name: "root aliases",
paths: []*managementproto.PathMapping{
{Path: "", Target: "http://first.internal"},
{Path: "/", Target: "http://second.internal", AccessAction: managementproto.TargetAccessAction_TARGET_ACCESS_ACTION_BYPASS},
},
},
{
name: "action elsewhere",
paths: []*managementproto.PathMapping{
{Path: "/", Target: "http://first.internal"},
{Path: "/", Target: "http://second.internal"},
{Path: "/public", Target: "http://public.internal", AccessAction: managementproto.TargetAccessAction_TARGET_ACCESS_ACTION_BYPASS},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
runtime, _ := newTargetAccessRuntime(t)
_, err := runtime.protoToMapping(t.Context(), &managementproto.ProxyMapping{
Id: "guarded-service", AccountId: "account", Domain: targetAccessDomain, Path: tt.paths,
})
assert.ErrorContains(t, err, `duplicate target location "/"`)
})
}
}
func TestModifyHTTPMappingRejectsInvalidReplacement(t *testing.T) {
tests := []struct {
name string
modify func(*managementproto.ProxyMapping)
}{
{"unknown action", func(m *managementproto.ProxyMapping) {
m.Path[0].AccessAction = managementproto.TargetAccessAction(99)
}},
{"duplicate locations", func(m *managementproto.ProxyMapping) {
m.Path = append(m.Path, &managementproto.PathMapping{Path: "/", Target: "http://127.0.0.1:1"})
}},
{"duplicate root aliases", func(m *managementproto.ProxyMapping) {
m.Path = append(m.Path, &managementproto.PathMapping{Target: "http://127.0.0.1:1"})
}},
{"nil target", func(m *managementproto.ProxyMapping) {
m.Path = append(m.Path, nil)
}},
{"malformed URL", func(m *managementproto.ProxyMapping) {
m.Path[0].Target = "http://["
}},
{"missing host", func(m *managementproto.ProxyMapping) {
m.Path[0].Target = "http:/backend"
}},
{"unsupported protocol", func(m *managementproto.ProxyMapping) {
m.Path[0].Target = "ftp://backend"
}},
{"private bypass", func(m *managementproto.ProxyMapping) {
m.Private = true
m.Path[0].AccessAction = managementproto.TargetAccessAction_TARGET_ACCESS_ACTION_BYPASS
}},
{"agent network bypass", func(m *managementproto.ProxyMapping) {
m.Path[0].AccessAction = managementproto.TargetAccessAction_TARGET_ACCESS_ACTION_BYPASS
m.Path[0].Options = &managementproto.PathTargetOptions{AgentNetwork: true}
}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
runtime, handler := newTargetAccessRuntime(t)
original := &managementproto.ProxyMapping{
Id: "guarded-service", AccountId: "account", Domain: targetAccessDomain,
Path: []*managementproto.PathMapping{{
Path: "/", Target: "http://127.0.0.1:1",
AccessAction: managementproto.TargetAccessAction_TARGET_ACCESS_ACTION_BLOCK,
}},
}
applyTargetAccessMapping(t, runtime, original)
replacement := proto.Clone(original).(*managementproto.ProxyMapping)
tt.modify(replacement)
require.Error(t, runtime.modifyMapping(t.Context(), replacement))
response := targetAccessRequestTo(handler, "/protected", "192.0.2.1:1234", nil)
assert.Equal(t, http.StatusNotFound, response.Code,
"invalid replacement must withdraw the route instead of retaining a stale policy")
require.NoError(t, runtime.modifyMapping(t.Context(), original),
"a valid update must reinstall a previously withdrawn route")
recovered := targetAccessRequestTo(handler, "/protected", "192.0.2.1:1234", nil)
assert.Equal(t, http.StatusForbidden, recovered.Code, "the reinstalled block must be enforced")
})
}
}
+115
View File
@@ -0,0 +1,115 @@
package proxy
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
protobuf "google.golang.org/protobuf/proto"
"github.com/netbirdio/netbird/proxy/internal/middleware"
guardrail "github.com/netbirdio/netbird/proxy/internal/middleware/builtin/llm_guardrail"
internalproxy "github.com/netbirdio/netbird/proxy/internal/proxy"
"github.com/netbirdio/netbird/shared/management/proto"
)
func TestUpdateMappingBindsMiddlewareRevisionAcrossPolicyUpdates(t *testing.T) {
oldUpstream := &targetAccessUpstream{}
oldBackend := httptest.NewServer(oldUpstream)
t.Cleanup(oldBackend.Close)
newUpstream := &targetAccessUpstream{}
newBackend := httptest.NewServer(newUpstream)
t.Cleanup(newBackend.Close)
runtime, _ := newTargetAccessRuntime(t)
registry := middleware.NewRegistry()
require.NoError(t, registry.Register(guardrail.Factory{}))
manager := middleware.NewManager(0, nil, runtime.Logger)
manager.SetResolver(middleware.NewResolver(registry))
manager.SetLiveServiceCheck(runtime.isLiveService)
t.Cleanup(manager.InvalidateAll)
runtime.middlewareRegistry = registry
runtime.middlewareManager = manager
runtime.proxy = internalproxy.NewReverseProxy(http.DefaultTransport, "auto", nil, runtime.Logger,
internalproxy.WithMiddlewareManager(manager))
handler := runtime.auth.Protect(runtime.proxy)
mapping := &proto.ProxyMapping{
Id: "middleware-revision-service", AccountId: targetAccessAccountID, Domain: targetAccessDomain,
Path: []*proto.PathMapping{{
Path: "/", Target: oldBackend.URL,
Options: &proto.PathTargetOptions{
AgentNetwork: true,
DirectUpstream: true,
Middlewares: []*proto.MiddlewareConfig{{
Id: guardrail.ID,
Slot: proto.MiddlewareSlot_MIDDLEWARE_SLOT_ON_REQUEST,
Enabled: true,
ConfigJson: []byte(`{"provider_allowlists":{"provider-1":["allowed-model"]}}`),
}},
},
}},
}
applyTargetAccessMapping(t, runtime, mapping)
initial := targetAccessRequestTo(handler, "/v1/chat/completions", "192.0.2.1:1234", nil)
require.Equal(t, http.StatusForbidden, initial.Code,
"the original guardrail must reject a request with no known model")
require.Empty(t, oldUpstream.snapshot(), "the original policy must prevent upstream forwarding")
// Hold the request at the auth/proxy boundary so updateMapping runs after
// authorization has pinned the route but before its middleware is selected.
captureAuthorizedRequest := func() *http.Request {
var pinned *http.Request
capture := runtime.auth.Protect(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
pinned = r
w.WriteHeader(http.StatusNoContent)
}))
response := targetAccessRequestTo(capture, "/v1/chat/completions", "192.0.2.1:1234", nil)
require.Equal(t, http.StatusNoContent, response.Code, "auth must pass the resolved request to the proxy")
require.NotNil(t, pinned, "the request must be captured after auth target resolution")
return pinned
}
staleRequest := captureAuthorizedRequest()
oldChain := manager.ChainFor(mapping.GetId(), "/")
require.NotNil(t, oldChain, "updateMapping must install the original guardrail chain")
replacement := protobuf.Clone(mapping).(*proto.ProxyMapping)
replacement.Path[0].Target = newBackend.URL
replacement.Path[0].Options.Middlewares[0].ConfigJson = []byte(`{}`)
applyTargetAccessMapping(t, runtime, replacement)
newChain := manager.ChainFor(mapping.GetId(), "/")
require.NotNil(t, newChain, "updateMapping must install the replacement guardrail chain")
require.NotSame(t, oldChain, newChain, "a policy update must replace the middleware chain")
staleResponse := httptest.NewRecorder()
runtime.proxy.ServeHTTP(staleResponse, staleRequest)
assert.Equal(t, http.StatusServiceUnavailable, staleResponse.Code,
"a request authorized against the old target must not execute the replacement policy")
assert.Equal(t, "no-store", staleResponse.Header().Get("Cache-Control"), "stale policy failures must not be cached")
assert.Empty(t, oldUpstream.snapshot(), "a replacement policy must never authorize the old upstream")
assert.Empty(t, newUpstream.snapshot(), "a stale request must not forward to the replacement upstream")
fresh := targetAccessRequestTo(handler, "/v1/chat/completions", "192.0.2.1:1234", nil)
require.Equal(t, http.StatusOK, fresh.Code, "fresh auth resolution must use the replacement middleware revision")
require.Len(t, newUpstream.snapshot(), 1, "the replacement target must receive the fresh request")
// Provider changes rebuild middleware instances without replacing the
// service mapping, so requests pinned to its current revision stay valid.
currentRequest := captureAuthorizedRequest()
manager.InvalidateMiddleware(guardrail.ID)
refreshedChain := manager.ChainFor(mapping.GetId(), "/")
require.NotNil(t, refreshedChain, "a live service must retain its chain after provider refresh")
require.NotSame(t, newChain, refreshedChain, "provider refresh must rebuild the concrete guardrail chain")
currentResponse := httptest.NewRecorder()
runtime.proxy.ServeHTTP(currentResponse, currentRequest)
assert.Equal(t, http.StatusOK, currentResponse.Code, "provider refresh must preserve the current request's revision")
assert.Len(t, newUpstream.snapshot(), 2, "the current request must reach the replacement target after refresh")
afterRefresh := targetAccessRequestTo(handler, "/v1/chat/completions", "192.0.2.1:1234", nil)
assert.Equal(t, http.StatusOK, afterRefresh.Code, "new requests must continue after provider refresh")
assert.Len(t, newUpstream.snapshot(), 3, "fresh requests must use the rebuilt chain and current target")
assert.Empty(t, oldUpstream.snapshot(), "no request may reach the retired target")
}