Merge remote-tracking branch 'origin/main' into jnfrati/ubi-proxy

This commit is contained in:
jnfrati
2026-09-23 15:43:36 +02:00
44 changed files with 2456 additions and 231 deletions
+26
View File
@@ -0,0 +1,26 @@
# PIN and password authentication limits
PIN and password credentials are accepted only in a POST form body. Query-string
credentials and credentials on other HTTP methods are ignored.
The proxy permits a burst of five credential checks per account and service,
then replenishes one check every six seconds (ten per minute). PIN and password
checks share the same budget. Five failed checks from one client IP in a
rolling five-minute window block that source for fifteen minutes. In-flight checks
reserve failure slots; blocked requests do not extend the cooldown. Successful
authentication clears that source's failure history. Infrastructure failures
consume the service budget without counting as incorrect credentials.
Throttled requests return HTTP 429 with a `Retry-After` delay in seconds. The
login page displays that delay. Existing authenticated sessions and other
authentication methods do not consume these credential budgets.
The client IP comes from the existing trusted-proxy resolution. Deployments
behind a load balancer must configure trusted proxies correctly; otherwise
visitors share the load balancer's source budget. Visitors behind the same NAT
also share a source budget for a service.
State is held in memory per proxy process and resets on restart. Multiple
replicas have independent budgets. State is bounded to 16,384 source entries and
4,096 service entries; when capacity is exhausted, new checks are denied until
idle entries expire. Active blocks are never evicted to admit a new source.
+100
View File
@@ -0,0 +1,100 @@
package auth
import (
"errors"
"math"
"net/http"
"strconv"
"time"
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"github.com/netbirdio/netbird/proxy/auth"
"github.com/netbirdio/netbird/proxy/internal/proxy"
)
var errCredentialClientIP = errors.New("invalid client address")
type credentialLimitError struct {
retryAfter time.Duration
}
func (e *credentialLimitError) Error() string {
return "too many authentication attempts"
}
func credentialFormValue(r *http.Request, field string) string {
if r.Method != http.MethodPost {
return ""
}
return r.PostFormValue(field)
}
func (mw *Middleware) authenticateScheme(r *http.Request, config DomainConfig, scheme Scheme) (string, string, error) {
method := scheme.Type()
if (method != auth.MethodPIN && method != auth.MethodPassword) || !wasCredentialSubmitted(r, method) {
return scheme.Authenticate(r)
}
ip := mw.resolveClientIP(r).Unmap()
if !ip.IsValid() {
return "", "", errCredentialClientIP
}
source, retry := mw.credentials.begin(credentialSourceKey{
service: credentialServiceKey{accountID: config.AccountID, serviceID: config.ServiceID},
ip: ip,
})
if retry > 0 {
return "", "", &credentialLimitError{retryAfter: retry}
}
token, prompt, err := scheme.Authenticate(r)
outcome := credentialUnavailable
if err == nil {
outcome = credentialRejected
if token != "" {
outcome = credentialAccepted
}
}
mw.credentials.finish(source, outcome)
return token, prompt, err
}
func credentialRetryAfter(err error) time.Duration {
var limitErr *credentialLimitError
if errors.As(err, &limitErr) {
return limitErr.retryAfter
}
s := status.Convert(err)
if s.Code() != codes.ResourceExhausted {
return 0
}
for _, detail := range s.Details() {
if info, ok := detail.(*errdetails.RetryInfo); ok && info.RetryDelay != nil && info.RetryDelay.CheckValid() == nil {
if delay := info.RetryDelay.AsDuration(); delay > 0 {
return delay
}
}
}
return credentialCheckInterval
}
func (mw *Middleware) writeAuthenticationError(w http.ResponseWriter, r *http.Request, method auth.Method, err error) {
if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
cd.SetOrigin(proxy.OriginAuth)
cd.SetAuthMethod(method.String())
}
if retry := credentialRetryAfter(err); retry > 0 {
// RFC 6585 section 4 forbids caching 429 responses.
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Retry-After", strconv.FormatInt(int64(math.Ceil(retry.Seconds())), 10))
http.Error(w, "too many authentication attempts; try again later", http.StatusTooManyRequests)
return
}
if errors.Is(err, errCredentialClientIP) {
http.Error(w, "invalid client address", http.StatusBadRequest)
return
}
mw.logger.WithField("scheme", method.String()).Warnf("authentication infrastructure error: %v", err)
http.Error(w, "authentication service unavailable", http.StatusBadGateway)
}
+169
View File
@@ -0,0 +1,169 @@
package auth
import (
"net/netip"
"sync"
"time"
"golang.org/x/time/rate"
"github.com/netbirdio/netbird/proxy/internal/types"
)
const (
credentialFailureLimit = 5
credentialFailureWindow = 5 * time.Minute
credentialBlockDuration = 15 * time.Minute
credentialCheckInterval = 6 * time.Second
credentialCheckBurst = 5
credentialMaxSources = 16384
credentialMaxServices = 4096
credentialCleanupInterval = time.Minute
)
type credentialServiceKey struct {
accountID types.AccountID
serviceID types.ServiceID
}
type credentialSourceKey struct {
service credentialServiceKey
ip netip.Addr
}
type credentialSource struct {
failures []time.Time
pending int
expiresAt time.Time
blockedUntil time.Time
}
type credentialService struct {
limiter *rate.Limiter
lastUsed time.Time
}
type credentialOutcome string
const (
credentialUnavailable credentialOutcome = "unavailable"
credentialRejected credentialOutcome = "rejected"
credentialAccepted credentialOutcome = "accepted"
)
// State is local to this proxy process. Active blocks are never evicted to
// make room for a new source; exhausting capacity denies new checks.
type credentialLimiter struct {
mu sync.Mutex
now func() time.Time
sources map[credentialSourceKey]*credentialSource
services map[credentialServiceKey]*credentialService
nextCleanup time.Time
}
func newCredentialLimiter() *credentialLimiter {
return &credentialLimiter{
now: time.Now,
sources: make(map[credentialSourceKey]*credentialSource),
services: make(map[credentialServiceKey]*credentialService),
}
}
func (l *credentialLimiter) begin(key credentialSourceKey) (*credentialSource, time.Duration) {
l.mu.Lock()
defer l.mu.Unlock()
now := l.now()
l.cleanup(now)
source := l.sources[key]
if source != nil {
if now.Before(source.blockedUntil) {
return nil, source.blockedUntil.Sub(now)
}
if source.pending == 0 && !now.Before(source.expiresAt) {
*source = credentialSource{}
}
source.expireFailures(now)
// Reserve the failure budget before verification so concurrent guesses
// cannot all pass a check against the same completed failure count.
if len(source.failures)+source.pending >= credentialFailureLimit {
return nil, time.Second
}
} else if len(l.sources) >= credentialMaxSources {
return nil, credentialCleanupInterval
}
if retry := l.allowService(key.service, now); retry > 0 {
return nil, retry
}
if source == nil {
source = &credentialSource{}
l.sources[key] = source
}
if source.expiresAt.IsZero() {
source.expiresAt = now.Add(credentialFailureWindow)
}
source.pending++
return source, 0
}
func (l *credentialLimiter) allowService(key credentialServiceKey, now time.Time) time.Duration {
service := l.services[key]
if service == nil {
if len(l.services) >= credentialMaxServices {
return credentialCleanupInterval
}
service = &credentialService{limiter: rate.NewLimiter(rate.Every(credentialCheckInterval), credentialCheckBurst)}
l.services[key] = service
}
service.lastUsed = now
if service.limiter.AllowN(now, 1) {
return 0
}
return max(time.Nanosecond, time.Duration((1-service.limiter.TokensAt(now))*float64(credentialCheckInterval)))
}
func (l *credentialLimiter) finish(source *credentialSource, outcome credentialOutcome) {
l.mu.Lock()
defer l.mu.Unlock()
source.pending--
now := l.now()
source.expireFailures(now)
switch outcome {
case credentialRejected:
source.failures = append(source.failures, now)
source.expiresAt = now.Add(credentialFailureWindow)
if len(source.failures) >= credentialFailureLimit && source.blockedUntil.IsZero() {
source.blockedUntil = now.Add(credentialBlockDuration)
source.expiresAt = source.blockedUntil
}
case credentialAccepted:
if !now.Before(source.blockedUntil) {
source.failures = nil
source.expiresAt = now.Add(credentialFailureWindow)
}
case credentialUnavailable:
// Transport failures consume the service budget, but are not bad guesses.
}
}
func (s *credentialSource) expireFailures(now time.Time) {
for len(s.failures) > 0 && !now.Before(s.failures[0].Add(credentialFailureWindow)) {
s.failures = s.failures[1:]
}
}
func (l *credentialLimiter) cleanup(now time.Time) {
if now.Before(l.nextCleanup) {
return
}
l.nextCleanup = now.Add(credentialCleanupInterval)
for key, source := range l.sources {
if source.pending == 0 && !now.Before(source.expiresAt) {
delete(l.sources, key)
}
}
for key, service := range l.services {
if now.Sub(service.lastUsed) >= credentialBlockDuration {
delete(l.services, key)
}
}
}
@@ -0,0 +1,190 @@
package auth
import (
"net/netip"
"strconv"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/netbirdio/netbird/proxy/internal/types"
)
func TestCredentialLimiterCooldown(t *testing.T) {
l := newCredentialLimiter()
now := time.Now()
l.now = func() time.Time { return now }
key := credentialSourceKey{service: credentialServiceKey{"account", "service"}, ip: netip.MustParseAddr("192.0.2.1")}
for range credentialFailureLimit {
attempt, retry := l.begin(key)
require.Zero(t, retry, "initial guesses must reach verification")
l.finish(attempt, credentialRejected)
}
_, retry := l.begin(key)
assert.Equal(t, credentialBlockDuration, retry, "five failures must start a fifteen-minute block")
now = now.Add(credentialBlockDuration - time.Second)
_, retry = l.begin(key)
assert.Equal(t, time.Second, retry, "blocked requests must not extend the deadline")
now = now.Add(time.Second)
attempt, retry := l.begin(key)
require.Zero(t, retry, "the source must recover when its block expires")
l.finish(attempt, credentialAccepted)
}
func TestCredentialLimiterFailureWindowAndSuccess(t *testing.T) {
for _, outcome := range []credentialOutcome{credentialAccepted, credentialUnavailable} {
t.Run(map[credentialOutcome]string{credentialAccepted: "success", credentialUnavailable: "infrastructure error"}[outcome], func(t *testing.T) {
l := newCredentialLimiter()
now := time.Now()
l.now = func() time.Time { return now }
key := credentialSourceKey{service: credentialServiceKey{"account", "service"}, ip: netip.MustParseAddr("192.0.2.1")}
for range 4 {
attempt, retry := l.begin(key)
require.Zero(t, retry, "four failures must fit the budget")
l.finish(attempt, credentialRejected)
}
attempt, retry := l.begin(key)
require.Zero(t, retry, "fifth check must be allowed")
l.finish(attempt, outcome)
now = now.Add(credentialCheckInterval)
attempt, retry = l.begin(key)
require.Zero(t, retry, "success or infrastructure error must not start a block")
l.finish(attempt, credentialRejected)
now = now.Add(credentialCheckInterval)
attempt, retry = l.begin(key)
if outcome == credentialUnavailable {
assert.Greater(t, retry, time.Duration(0), "infrastructure errors must preserve earlier failures")
return
}
require.Zero(t, retry, "success must clear earlier failures")
l.finish(attempt, credentialRejected)
now = now.Add(credentialFailureWindow)
for range credentialFailureLimit {
attempt, retry = l.begin(key)
require.Zero(t, retry, "old failures must expire")
l.finish(attempt, credentialRejected)
}
})
}
}
func TestCredentialLimiterRollingWindow(t *testing.T) {
l := newCredentialLimiter()
now := time.Now()
l.now = func() time.Time { return now }
key := credentialSourceKey{service: credentialServiceKey{"account", "service"}, ip: netip.MustParseAddr("192.0.2.1")}
attempt, retry := l.begin(key)
require.Zero(t, retry, "the first failure starts the history")
l.finish(attempt, credentialRejected)
now = now.Add(4 * time.Minute)
for range 3 {
attempt, retry = l.begin(key)
require.Zero(t, retry, "three more failures must fit the budget")
l.finish(attempt, credentialRejected)
}
now = now.Add(time.Minute + time.Second)
for range 2 {
attempt, retry = l.begin(key)
require.Zero(t, retry, "only the oldest failure must have expired")
l.finish(attempt, credentialRejected)
}
_, retry = l.begin(key)
assert.Equal(t, credentialBlockDuration, retry, "five recent failures must block even across the first window boundary")
}
func TestCredentialLimiterServiceBudget(t *testing.T) {
l := newCredentialLimiter()
now := time.Now()
l.now = func() time.Time { return now }
key := credentialSourceKey{service: credentialServiceKey{"account", "service"}, ip: netip.MustParseAddr("192.0.2.1")}
for range credentialCheckBurst {
attempt, retry := l.begin(key)
require.Zero(t, retry, "initial checks must fit the service burst")
l.finish(attempt, credentialAccepted)
key.ip = key.ip.Next()
}
_, retry := l.begin(key)
assert.Equal(t, credentialCheckInterval, retry, "changing IP must not bypass the service budget")
other := key
other.service.accountID = "another-account"
attempt, retry := l.begin(other)
require.Zero(t, retry, "accounts must have separate budgets")
l.finish(attempt, credentialAccepted)
other = key
other.service.serviceID = "another-service"
attempt, retry = l.begin(other)
require.Zero(t, retry, "services must have separate budgets")
l.finish(attempt, credentialAccepted)
now = now.Add(credentialCheckInterval)
attempt, retry = l.begin(key)
require.Zero(t, retry, "one check must refill every six seconds")
l.finish(attempt, credentialAccepted)
_, retry = l.begin(key)
assert.Equal(t, credentialCheckInterval, retry, "refill must only grant one new check")
}
func TestCredentialLimiterConcurrentReservations(t *testing.T) {
l := newCredentialLimiter()
now := time.Now()
l.now = func() time.Time { return now }
key := credentialSourceKey{service: credentialServiceKey{"account", "service"}, ip: netip.MustParseAddr("192.0.2.1")}
var attempts []*credentialSource
for range credentialFailureLimit {
attempt, retry := l.begin(key)
require.Zero(t, retry, "initial requests must reserve the failure budget")
attempts = append(attempts, attempt)
}
// Refill the service budget while earlier verification calls are still running.
now = now.Add(time.Minute)
var admitted atomic.Int32
var wg sync.WaitGroup
for range 100 {
wg.Go(func() {
attempt, retry := l.begin(key)
if retry == 0 {
admitted.Add(1)
l.finish(attempt, credentialRejected)
}
})
}
wg.Wait()
assert.Zero(t, admitted.Load(), "in-flight guesses must reserve the failure budget despite a refilled service budget")
for _, attempt := range attempts {
wg.Go(func() { l.finish(attempt, credentialRejected) })
}
wg.Wait()
_, retry := l.begin(key)
assert.Equal(t, credentialBlockDuration, retry, "concurrent failures must activate the block")
}
func TestCredentialLimiterCapacityAndCleanup(t *testing.T) {
for _, fullSources := range []bool{true, false} {
t.Run(map[bool]string{true: "sources", false: "services"}[fullSources], func(t *testing.T) {
l := newCredentialLimiter()
now := time.Now()
l.now = func() time.Time { return now }
key := credentialSourceKey{service: credentialServiceKey{"account", "service"}, ip: netip.MustParseAddr("192.0.2.1")}
if fullSources {
ip := netip.MustParseAddr("198.18.0.1")
for range credentialMaxSources {
l.sources[credentialSourceKey{service: key.service, ip: ip}] = &credentialSource{expiresAt: now.Add(credentialBlockDuration), blockedUntil: now.Add(credentialBlockDuration)}
ip = ip.Next()
}
} else {
for i := range credentialMaxServices {
l.services[credentialServiceKey{serviceID: key.service.serviceID, accountID: types.AccountID(strconv.Itoa(i))}] = &credentialService{lastUsed: now}
}
}
_, retry := l.begin(key)
assert.Positive(t, retry, "full state must deny new checks without evicting active entries")
now = now.Add(credentialBlockDuration)
attempt, retry := l.begin(key)
require.Zero(t, retry, "expired state must release capacity")
l.finish(attempt, credentialAccepted)
})
}
}
+196
View File
@@ -0,0 +1,196 @@
package auth
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"net/netip"
"net/url"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/protobuf/types/known/durationpb"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service"
servicemanager "github.com/netbirdio/netbird/management/internals/modules/reverseproxy/service/manager"
"github.com/netbirdio/netbird/management/internals/modules/reverseproxy/sessionkey"
nbgrpc "github.com/netbirdio/netbird/management/internals/shared/grpc"
"github.com/netbirdio/netbird/management/server/store"
mgmttypes "github.com/netbirdio/netbird/management/server/types"
proxyauth "github.com/netbirdio/netbird/proxy/auth"
"github.com/netbirdio/netbird/proxy/internal/proxy"
"github.com/netbirdio/netbird/shared/management/proto"
)
// localCredentialClient replaces the transport while keeping the real service
// store, credential verification, and session signing.
type localCredentialClient struct {
server *nbgrpc.ProxyServiceServer
}
func (c localCredentialClient) Authenticate(ctx context.Context, req *proto.AuthenticateRequest, _ ...grpc.CallOption) (*proto.AuthenticateResponse, error) {
return c.server.Authenticate(ctx, req)
}
func credentialHandler(t *testing.T, field string) (*Middleware, http.Handler) {
t.Helper()
ctx := context.Background()
s, err := store.NewStore(ctx, mgmttypes.SqliteStoreEngine, t.TempDir(), nil, false)
require.NoError(t, err)
t.Cleanup(func() { assert.NoError(t, s.Close(ctx)) })
require.NoError(t, s.SaveAccount(ctx, &mgmttypes.Account{Id: "account"}))
keys := generateTestKeyPair(t)
svc := &service.Service{
ID: "service", AccountID: "account", Name: "test", Domain: "example.com",
Enabled: true, SessionPrivateKey: keys.PrivateKey, SessionPublicKey: keys.PublicKey,
Auth: service.AuthConfig{
PinAuth: &service.PINAuthConfig{Enabled: true, Pin: "842716"},
PasswordAuth: &service.PasswordAuthConfig{Enabled: true, Password: "842716"},
},
}
require.NoError(t, svc.Auth.HashSecrets())
require.NoError(t, s.CreateService(ctx, svc))
server := nbgrpc.NewProxyServiceServer(nil, nil, nil, nbgrpc.ProxyOIDCConfig{}, nil, nil, nil, nil, nil)
t.Cleanup(server.Close)
server.SetServiceManager(servicemanager.NewManager(s, nil, nil, nil, nil, nil))
client := localCredentialClient{server: server}
var scheme Scheme = NewPin(client, "service", "account")
if field == "password" {
scheme = NewPassword(client, "service", "account")
}
mw := NewMiddleware(nil, nil, nil)
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, keys.PublicKey, time.Hour, "account", "service", nil, false, nil))
return mw, mw.Protect(newPassthroughHandler())
}
func credentialRequest(method, field, value string) *http.Request {
r := httptest.NewRequest(method, "https://example.com/", strings.NewReader(url.Values{field: {value}}.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.RemoteAddr = "198.51.100.25:12345"
return r
}
func TestCredentialAuthPOSTOnly(t *testing.T) {
for _, field := range []string{"pin", "password"} {
t.Run(field, func(t *testing.T) {
_, handler := credentialHandler(t, field)
for _, method := range []string{http.MethodGet, http.MethodPut, http.MethodPatch, http.MethodDelete, http.MethodPost} {
r := credentialRequest(method, field, "")
r.URL.RawQuery = url.Values{field: {"842716"}}.Encode()
resp := httptest.NewRecorder()
handler.ServeHTTP(resp, r)
assert.Equal(t, http.StatusUnauthorized, resp.Code, "%s query credentials must not authenticate", method)
assert.Empty(t, resp.Result().Cookies(), "query credentials must not issue a session")
}
for _, method := range []string{http.MethodGet, http.MethodPut, http.MethodPatch, http.MethodDelete} {
resp := httptest.NewRecorder()
handler.ServeHTTP(resp, credentialRequest(method, field, "842716"))
assert.Equal(t, http.StatusUnauthorized, resp.Code, "%s body credentials must not authenticate", method)
}
resp := httptest.NewRecorder()
handler.ServeHTTP(resp, credentialRequest(http.MethodPost, field, "842716"))
assert.Equal(t, http.StatusSeeOther, resp.Code, "POST body credentials must authenticate")
})
}
}
func TestCredentialAuthThrottling(t *testing.T) {
for _, field := range []string{"pin", "password"} {
t.Run(field, func(t *testing.T) {
_, handler := credentialHandler(t, field)
for range 5 {
resp := httptest.NewRecorder()
handler.ServeHTTP(resp, credentialRequest(http.MethodPost, field, "000000"))
require.Equal(t, http.StatusUnauthorized, resp.Code, "initial wrong credentials must be rejected")
}
resp := httptest.NewRecorder()
handler.ServeHTTP(resp, credentialRequest(http.MethodPost, field, "842716"))
assert.Equal(t, http.StatusTooManyRequests, resp.Code, "even correct credentials must wait for the block to expire")
assert.Equal(t, "900", resp.Header().Get("Retry-After"), "five failures must block the source for fifteen minutes")
assert.Empty(t, resp.Result().Cookies(), "blocked credentials must not issue a session")
})
}
}
func TestCredentialAuthSessionAndClientIP(t *testing.T) {
keys := generateTestKeyPair(t)
token, err := sessionkey.SignToken(keys.PrivateKey, "pin-user", "", "example.com", proxyauth.MethodPIN, nil, nil, time.Hour)
require.NoError(t, err)
mw := NewMiddleware(nil, nil, nil)
now := time.Now()
mw.credentials.now = func() time.Time { return now }
scheme := &stubScheme{method: proxyauth.MethodPIN, promptID: "pin"}
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, keys.PublicKey, 0, "account", "service", nil, false, nil))
handler := mw.Protect(newPassthroughHandler())
for range credentialFailureLimit {
resp := httptest.NewRecorder()
handler.ServeHTTP(resp, credentialRequest(http.MethodPost, "pin", "000000"))
require.Equal(t, http.StatusUnauthorized, resp.Code, "bad PIN must consume the failure budget")
}
now = now.Add(credentialCheckInterval)
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, keys.PublicKey, 0, "account", "service", nil, false, nil))
r := credentialRequest(http.MethodPost, "pin", "000000")
r.RemoteAddr = "[::ffff:198.51.100.25]:45678"
r.Header.Set("X-Forwarded-For", "192.0.2.5")
r.Header.Set("X-Real-IP", "192.0.2.6")
resp := httptest.NewRecorder()
handler.ServeHTTP(resp, r)
assert.Equal(t, http.StatusTooManyRequests, resp.Code, "mapped addresses and untrusted forwarding headers must not bypass the source block")
assert.Equal(t, "no-store", resp.Header().Get("Cache-Control"), "rate limits must not be cached")
r.AddCookie(&http.Cookie{Name: proxyauth.SessionCookieName, Value: token})
resp = httptest.NewRecorder()
handler.ServeHTTP(resp, r)
assert.Equal(t, http.StatusOK, resp.Code, "an existing session must pass even with credentials in the request")
assert.Equal(t, "backend", resp.Body.String(), "the authenticated request must reach the application")
r = credentialRequest(http.MethodPost, "pin", "000000")
cd := proxy.NewCapturedData("test")
cd.SetClientIP(netip.MustParseAddr("192.0.2.9"))
r = r.WithContext(proxy.WithCapturedData(r.Context(), cd))
resp = httptest.NewRecorder()
handler.ServeHTTP(resp, r)
assert.Equal(t, http.StatusUnauthorized, resp.Code, "a client resolved by the trusted-proxy middleware must get its own source budget")
r = credentialRequest(http.MethodPost, "pin", "000000")
r.RemoteAddr = "invalid"
resp = httptest.NewRecorder()
handler.ServeHTTP(resp, r)
assert.Equal(t, http.StatusBadRequest, resp.Code, "an unresolvable client address must fail closed")
now = now.Add(credentialBlockDuration)
scheme.token = token
resp = httptest.NewRecorder()
handler.ServeHTTP(resp, credentialRequest(http.MethodPost, "pin", "842716"))
assert.Equal(t, http.StatusSeeOther, resp.Code, "credentials must work again after cooldown")
}
func TestCredentialAuthManagementThrottling(t *testing.T) {
s, err := status.New(codes.ResourceExhausted, "rate limited").WithDetails(&errdetails.RetryInfo{RetryDelay: durationpb.New(2500 * time.Millisecond)})
require.NoError(t, err)
for _, tc := range []struct {
name string
err error
code int
retry string
}{
{"retry info", fmt.Errorf("authenticate PIN: %w", s.Err()), http.StatusTooManyRequests, "3"},
{"missing retry info", status.Error(codes.ResourceExhausted, "rate limited"), http.StatusTooManyRequests, "6"},
{"unavailable", status.Error(codes.Unavailable, "unavailable"), http.StatusBadGateway, ""},
} {
t.Run(tc.name, func(t *testing.T) {
keys := generateTestKeyPair(t)
mw := NewMiddleware(nil, nil, nil)
scheme := &stubScheme{method: proxyauth.MethodPIN, authFn: func(*http.Request) (string, string, error) { return "", "", tc.err }}
require.NoError(t, mw.AddDomain("example.com", []Scheme{scheme}, keys.PublicKey, 0, "account", "service", nil, false, nil))
resp := httptest.NewRecorder()
mw.Protect(newPassthroughHandler()).ServeHTTP(resp, credentialRequest(http.MethodPost, "pin", "000000"))
assert.Equal(t, tc.code, resp.Code, "management errors must keep their HTTP meaning")
assert.Equal(t, tc.retry, resp.Header().Get("Retry-After"), "retry hints must round up to whole seconds")
})
}
}
+29 -11
View File
@@ -87,6 +87,7 @@ type Middleware struct {
sessionValidator SessionValidator
geo restrict.GeoResolver
tunnelCache *tunnelValidationCache
credentials *credentialLimiter
}
// NewMiddleware creates a new authentication middleware. The sessionValidator is
@@ -101,6 +102,7 @@ func NewMiddleware(logger *log.Logger, sessionValidator SessionValidator, geo re
sessionValidator: sessionValidator,
geo: geo,
tunnelCache: newTunnelValidationCache(),
credentials: newCredentialLimiter(),
}
}
@@ -133,7 +135,7 @@ func (mw *Middleware) Protect(next http.Handler) http.Handler {
if mw.forwardWithTunnelPeer(w, r, host, config, next) {
return
}
http.Error(w, "Forbidden", http.StatusForbidden)
denyPrivate(w)
return
}
@@ -228,7 +230,7 @@ func (mw *Middleware) checkIPRestrictions(w http.ResponseWriter, r *http.Request
clientIP := mw.resolveClientIP(r)
if !clientIP.IsValid() {
mw.logger.Debugf("IP restriction: cannot resolve client address for %q, denying", r.RemoteAddr)
http.Error(w, "Forbidden", http.StatusForbidden)
denyForbidden(w, config)
return false
}
@@ -263,10 +265,30 @@ func (mw *Middleware) checkIPRestrictions(w http.ResponseWriter, r *http.Request
reason := verdict.String()
mw.blockIPRestriction(r, reason)
http.Error(w, "Forbidden", http.StatusForbidden)
denyForbidden(w, config)
return false
}
// denyForbidden writes a 403, dropping the client connection when the
// domain is private so a later retry cannot reuse it.
func denyForbidden(w http.ResponseWriter, config DomainConfig) {
if config.Private {
denyPrivate(w)
return
}
http.Error(w, "Forbidden", http.StatusForbidden)
}
// denyPrivate writes a 403 and closes the connection, so a client refused
// before joining the overlay cannot keep retrying on the same warm socket.
// Go's HTTP/2 server turns the exact lowercase "close" token into a GOAWAY.
func denyPrivate(w http.ResponseWriter) {
h := w.Header()
h.Set("Connection", "close")
h.Set("Cache-Control", "no-store")
http.Error(w, "Forbidden", http.StatusForbidden)
}
// resolveClientIP extracts the real client IP from CapturedData, falling back to r.RemoteAddr.
func (mw *Middleware) resolveClientIP(r *http.Request) netip.Addr {
if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
@@ -523,13 +545,9 @@ func (mw *Middleware) authenticateWithSchemes(w http.ResponseWriter, r *http.Req
var attemptedMethod string
for _, scheme := range config.Schemes {
token, promptData, err := scheme.Authenticate(r)
token, promptData, err := mw.authenticateScheme(r, config, scheme)
if err != nil {
mw.logger.WithField("scheme", scheme.Type().String()).Warnf("authentication infrastructure error: %v", err)
if cd := proxy.CapturedDataFromContext(r.Context()); cd != nil {
cd.SetOrigin(proxy.OriginAuth)
}
http.Error(w, "authentication service unavailable", http.StatusBadGateway)
mw.writeAuthenticationError(w, r, scheme.Type(), err)
return
}
@@ -630,9 +648,9 @@ func setSessionCookie(w http.ResponseWriter, token string, expiration time.Durat
func wasCredentialSubmitted(r *http.Request, method auth.Method) bool {
switch method {
case auth.MethodPIN:
return r.FormValue("pin") != ""
return credentialFormValue(r, pinFormId) != ""
case auth.MethodPassword:
return r.FormValue("password") != ""
return credentialFormValue(r, passwordFormId) != ""
case auth.MethodOIDC:
return r.URL.Query().Get("session_token") != ""
}
+1 -1
View File
@@ -35,7 +35,7 @@ func (Password) Type() auth.Method {
// so that it can be injected into a request from the UI so that
// authentication may be successful.
func (p Password) Authenticate(r *http.Request) (string, string, error) {
password := r.FormValue(passwordFormId)
password := credentialFormValue(r, passwordFormId)
if password == "" {
// No password submitted; return the form ID so the UI can prompt the user.
+1 -1
View File
@@ -35,7 +35,7 @@ func (Pin) Type() auth.Method {
// so that it can be injected into a request from the UI so that
// authentication may be successful.
func (p Pin) Authenticate(r *http.Request) (string, string, error) {
pin := r.FormValue(pinFormId)
pin := credentialFormValue(r, pinFormId)
if pin == "" {
// No PIN submitted; return the form ID so the UI can prompt the user.
+272
View File
@@ -0,0 +1,272 @@
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, nil, kp.PublicKey, time.Hour, "acct-1", "svc-1", ipRestrictions, true, nil))
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, nil, "", 0, "acct-1", "svc-1", filter, false, nil))
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, nil, "", 0, "acct-1", "svc-1", filter, false, nil))
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")
})
}
}
+6 -6
View File
File diff suppressed because one or more lines are too long
+6
View File
@@ -68,6 +68,12 @@ function App() {
if (res.type === "opaqueredirect" || res.status === 0) {
setSubmitting("redirect");
globalThis.location.reload();
} else if (res.status === 429) {
const seconds = Number(res.headers.get("Retry-After"));
const wait = Number.isFinite(seconds) && seconds > 0
? ` Try again in ${Math.ceil(seconds)} seconds.`
: " Please try again later.";
handleAuthError(method, `Too many authentication attempts.${wait}`);
} else {
handleAuthError(method, "Authentication failed. Please try again.");
}