mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-13 17:09:07 +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:
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
+65
-41
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user