mirror of
https://github.com/netbirdio/netbird.git
synced 2026-10-13 17:09:07 +02:00
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.
279 lines
8.1 KiB
Go
279 lines
8.1 KiB
Go
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)
|
|
}
|