mirror of
https://github.com/netbirdio/netbird.git
synced 2026-09-24 15:49:06 +02:00
# Conflicts: # proxy/internal/auth/middleware.go # proxy/internal/auth/middleware_test.go # proxy/internal/auth/tunnel_lookup_test.go # proxy/management_integration_test.go # proxy/server.go # shared/management/proto/proxy_service.pb.go
273 lines
10 KiB
Go
273 lines
10 KiB
Go
package auth
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"net/http/httptrace"
|
|
"net/netip"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
|
|
log "github.com/sirupsen/logrus"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"google.golang.org/grpc"
|
|
|
|
"github.com/netbirdio/netbird/proxy/internal/proxy"
|
|
"github.com/netbirdio/netbird/proxy/internal/restrict"
|
|
"github.com/netbirdio/netbird/shared/management/proto"
|
|
)
|
|
|
|
// switchableTunnelValidator flips the ValidateTunnelPeer verdict between requests.
|
|
type switchableTunnelValidator struct {
|
|
mu sync.Mutex
|
|
valid bool
|
|
}
|
|
|
|
func (s *switchableTunnelValidator) setValid(v bool) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.valid = v
|
|
}
|
|
|
|
func (s *switchableTunnelValidator) ValidateSession(context.Context, *proto.ValidateSessionRequest, ...grpc.CallOption) (*proto.ValidateSessionResponse, error) {
|
|
return nil, errors.New("not used in this test")
|
|
}
|
|
|
|
func (s *switchableTunnelValidator) ValidateTunnelPeer(context.Context, *proto.ValidateTunnelPeerRequest, ...grpc.CallOption) (*proto.ValidateTunnelPeerResponse, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if !s.valid {
|
|
return &proto.ValidateTunnelPeerResponse{Valid: false, DeniedReason: "not_in_group"}, nil
|
|
}
|
|
return &proto.ValidateTunnelPeerResponse{
|
|
Valid: true,
|
|
UserId: "user-1",
|
|
SessionToken: "tunnel-session-token",
|
|
}, nil
|
|
}
|
|
|
|
// testServerHost is the domain key Protect derives from the httptest listener.
|
|
const testServerHost = "127.0.0.1"
|
|
|
|
var testTunnelIP = netip.MustParseAddr("100.90.1.14")
|
|
|
|
// startProtectedServer serves mw.Protect and stamps requests as overlay traffic.
|
|
func startProtectedServer(t *testing.T, mw *Middleware, clientIP netip.Addr, lookup TunnelLookupFunc, h2 bool) *httptest.Server {
|
|
t.Helper()
|
|
protected := mw.Protect(newPassthroughHandler())
|
|
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
cd := proxy.NewCapturedData("")
|
|
cd.SetClientIP(clientIP)
|
|
ctx := proxy.WithCapturedData(r.Context(), cd)
|
|
ctx = WithTunnelLookup(ctx, lookup)
|
|
protected.ServeHTTP(w, r.WithContext(ctx))
|
|
})
|
|
|
|
srv := httptest.NewUnstartedServer(handler)
|
|
if h2 {
|
|
srv.EnableHTTP2 = true
|
|
srv.StartTLS()
|
|
} else {
|
|
srv.Start()
|
|
}
|
|
t.Cleanup(srv.Close)
|
|
return srv
|
|
}
|
|
|
|
// tracedResponse is what a test observes from one client round trip.
|
|
type tracedResponse struct {
|
|
status int
|
|
protoMajor int
|
|
close bool
|
|
connection string
|
|
cacheControl string
|
|
reused bool
|
|
}
|
|
|
|
// doTraced GETs url and reports whether the connection that served it was reused.
|
|
func doTraced(t *testing.T, client *http.Client, url string) tracedResponse {
|
|
t.Helper()
|
|
var reused bool
|
|
trace := &httptrace.ClientTrace{
|
|
GotConn: func(info httptrace.GotConnInfo) { reused = info.Reused },
|
|
}
|
|
req, err := http.NewRequestWithContext(httptrace.WithClientTrace(context.Background(), trace), http.MethodGet, url, nil)
|
|
require.NoError(t, err)
|
|
resp, err := client.Do(req)
|
|
require.NoError(t, err)
|
|
defer func() { require.NoError(t, resp.Body.Close()) }()
|
|
_, err = io.Copy(io.Discard, resp.Body)
|
|
require.NoError(t, err)
|
|
return tracedResponse{
|
|
status: resp.StatusCode,
|
|
protoMajor: resp.ProtoMajor,
|
|
close: resp.Close,
|
|
connection: resp.Header.Get("Connection"),
|
|
cacheControl: resp.Header.Get("Cache-Control"),
|
|
reused: reused,
|
|
}
|
|
}
|
|
|
|
func acceptAllLookup(_ netip.Addr) (PeerIdentity, bool) {
|
|
return PeerIdentity{TunnelIP: testTunnelIP}, true
|
|
}
|
|
|
|
func newPrivateMiddleware(t *testing.T, validator SessionValidator, ipRestrictions *restrict.Filter) *Middleware {
|
|
t.Helper()
|
|
mw := NewMiddleware(log.StandardLogger(), validator, nil)
|
|
kp := generateTestKeyPair(t)
|
|
require.NoError(t, mw.AddDomain(testServerHost, DomainSettings{SessionPublicKey: kp.PublicKey, SessionExpiration: time.Hour, AccountID: "acct-1", ServiceID: "svc-1", IPRestrictions: ipRestrictions, Private: true}))
|
|
return mw
|
|
}
|
|
|
|
// A rejected tunnel peer must emit the exact lowercase "close" token h2 matches on.
|
|
func TestProtect_PrivateService_DeniedSetsCloseHeaders(t *testing.T) {
|
|
mw := newPrivateMiddleware(t, &switchableTunnelValidator{}, nil)
|
|
handler := mw.Protect(newPassthroughHandler())
|
|
|
|
cd := proxy.NewCapturedData("")
|
|
cd.SetClientIP(testTunnelIP)
|
|
req := httptest.NewRequest(http.MethodGet, "http://"+testServerHost+"/", nil)
|
|
req.RemoteAddr = testTunnelIP.String() + ":5000"
|
|
req = req.WithContext(WithTunnelLookup(proxy.WithCapturedData(req.Context(), cd), acceptAllLookup))
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
assert.Equal(t, http.StatusForbidden, rec.Code)
|
|
assert.Equal(t, "close", rec.Header().Get("Connection"), "private denial must ask the client to drop the connection")
|
|
assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"), "private denial must not be cacheable")
|
|
}
|
|
|
|
// A denied client must not keep reusing the warm socket after joining the overlay.
|
|
func TestPrivateDeny_HTTP1_ClosesConnection(t *testing.T) {
|
|
validator := &switchableTunnelValidator{}
|
|
mw := newPrivateMiddleware(t, validator, nil)
|
|
srv := startProtectedServer(t, mw, testTunnelIP, acceptAllLookup, false)
|
|
client := srv.Client()
|
|
|
|
resp := doTraced(t, client, srv.URL)
|
|
assert.Equal(t, http.StatusForbidden, resp.status)
|
|
assert.Equal(t, 1, resp.protoMajor, "plain httptest server must speak HTTP/1.1")
|
|
// The Go client folds "Connection: close" into resp.close and drops the header.
|
|
assert.True(t, resp.close, "private denial must make the client mark the connection as not reusable")
|
|
assert.Equal(t, "no-store", resp.cacheControl, "private denial must not be cacheable")
|
|
|
|
validator.setValid(true)
|
|
resp2 := doTraced(t, client, srv.URL)
|
|
assert.Equal(t, http.StatusOK, resp2.status, "the retry must reach the upstream once the peer is valid")
|
|
assert.False(t, resp2.reused, "the retry must open a new connection")
|
|
}
|
|
|
|
// On HTTP/2 the header becomes a GOAWAY and the retry must use a new connection.
|
|
func TestPrivateDeny_HTTP2_SendsGoAway(t *testing.T) {
|
|
validator := &switchableTunnelValidator{}
|
|
mw := newPrivateMiddleware(t, validator, nil)
|
|
srv := startProtectedServer(t, mw, testTunnelIP, acceptAllLookup, true)
|
|
client := srv.Client()
|
|
|
|
resp := doTraced(t, client, srv.URL)
|
|
require.Equal(t, 2, resp.protoMajor, "test client must negotiate HTTP/2")
|
|
assert.Equal(t, http.StatusForbidden, resp.status)
|
|
assert.Empty(t, resp.connection, "HTTP/2 must not carry a Connection header on the wire")
|
|
assert.Equal(t, "no-store", resp.cacheControl)
|
|
|
|
validator.setValid(true)
|
|
resp2 := doTraced(t, client, srv.URL)
|
|
assert.Equal(t, 2, resp2.protoMajor)
|
|
assert.Equal(t, http.StatusOK, resp2.status, "the retry must reach the upstream once the peer is valid")
|
|
assert.False(t, resp2.reused, "GOAWAY must retire the connection so the retry opens a new one")
|
|
}
|
|
|
|
// Legitimate private traffic keeps its keep-alive connection.
|
|
func TestPrivateAllow_KeepsConnection(t *testing.T) {
|
|
validator := &switchableTunnelValidator{valid: true}
|
|
mw := newPrivateMiddleware(t, validator, nil)
|
|
srv := startProtectedServer(t, mw, testTunnelIP, acceptAllLookup, false)
|
|
client := srv.Client()
|
|
|
|
resp := doTraced(t, client, srv.URL)
|
|
assert.Equal(t, http.StatusOK, resp.status)
|
|
assert.Empty(t, resp.connection, "an allowed private request must not close the connection")
|
|
|
|
resp2 := doTraced(t, client, srv.URL)
|
|
assert.Equal(t, http.StatusOK, resp2.status)
|
|
assert.True(t, resp2.reused, "allowed private traffic must keep reusing the connection")
|
|
}
|
|
|
|
// Public denials keep the connection open; only private services change.
|
|
func TestPublicDeny_KeepsConnection(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(testServerHost, DomainSettings{AccountID: "acct-1", ServiceID: "svc-1", IPRestrictions: filter}))
|
|
srv := startProtectedServer(t, mw, netip.MustParseAddr("192.168.1.1"), nil, false)
|
|
client := srv.Client()
|
|
|
|
resp := doTraced(t, client, srv.URL)
|
|
assert.Equal(t, http.StatusForbidden, resp.status)
|
|
assert.Empty(t, resp.connection, "public denial must not close the connection")
|
|
assert.Empty(t, resp.cacheControl, "public denial must not gain cache headers")
|
|
|
|
resp2 := doTraced(t, client, srv.URL)
|
|
assert.Equal(t, http.StatusForbidden, resp2.status)
|
|
assert.True(t, resp2.reused, "public denials must keep reusing the connection")
|
|
}
|
|
|
|
// IP restriction denials on a private service must close the connection too.
|
|
func TestCheckIPRestrictions_PrivateDenialClosesConnection(t *testing.T) {
|
|
filter := restrict.ParseFilter(restrict.FilterConfig{AllowedCIDRs: []string{"100.64.0.0/16"}})
|
|
mw := newPrivateMiddleware(t, &switchableTunnelValidator{valid: true}, filter)
|
|
handler := mw.Protect(newPassthroughHandler())
|
|
|
|
tests := []struct {
|
|
name string
|
|
remoteAddr string
|
|
}{
|
|
{"denied by CIDR", "100.65.5.6:5000"},
|
|
{"unresolvable client address", "not-an-ip:1234"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "http://"+testServerHost+"/", nil)
|
|
req.RemoteAddr = tt.remoteAddr
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
assert.Equal(t, http.StatusForbidden, rec.Code)
|
|
assert.Equal(t, "close", rec.Header().Get("Connection"), "private IP-restriction denial must close the connection")
|
|
assert.Equal(t, "no-store", rec.Header().Get("Cache-Control"), "private IP-restriction denial must not be cacheable")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestCheckIPRestrictions_PublicDenialKeepsConnection(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(testServerHost, DomainSettings{AccountID: "acct-1", ServiceID: "svc-1", IPRestrictions: filter}))
|
|
handler := mw.Protect(newPassthroughHandler())
|
|
|
|
tests := []struct {
|
|
name string
|
|
remoteAddr string
|
|
}{
|
|
{"denied by CIDR", "192.168.1.1:5000"},
|
|
{"unresolvable client address", "not-an-ip:1234"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
req := httptest.NewRequest(http.MethodGet, "http://"+testServerHost+"/", nil)
|
|
req.RemoteAddr = tt.remoteAddr
|
|
rec := httptest.NewRecorder()
|
|
handler.ServeHTTP(rec, req)
|
|
|
|
assert.Equal(t, http.StatusForbidden, rec.Code)
|
|
assert.Empty(t, rec.Header().Get("Connection"), "public IP-restriction denial must not close the connection")
|
|
assert.Empty(t, rec.Header().Get("Cache-Control"), "public IP-restriction denial must not gain cache headers")
|
|
})
|
|
}
|
|
}
|