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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user