mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-13 08:59:08 +02:00
[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:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user