Compare commits

..
4 Commits
Author SHA1 Message Date
lukaszraczylo 3a6403c30b fix(ratelimit): bound memory and CPU per request
The sustained gate stored one entry for every attempt, denied ones too, and summed all entries on each request. A flood grew memory with rate times interval and made each request slower. Both gates now keep only the newest limit plus one attempt times. A Lean proof shows this decides the same as the full count. One check dropped from 29,502 ns and 11 allocations to 43 ns and none. The go-ratecounter dependency is gone.
2026-11-03 00:55:35 +00:00
lukaszraczylo cff11f2b25 fix(redis): namespace cache keys when no prefix is set
With an empty prefix, Clear deleted every key in the Redis database, including keys of other applications. Cache keys now start with gmp: by default. Existing cache entries become unreachable after the upgrade and expire by TTL.
2026-11-03 00:49:02 +00:00
lukaszraczylo 4c5d01aef3 fix(ws): gate the identity sent in connection_init
The upgrade request passed the ban and rate-limit checks on its own headers. The backend then acted as the identity in the connection_init payload. A banned user could connect without credentials and send their token in the payload. The proxy now runs the same checks on the payload identity.
2026-11-03 00:34:18 +00:00
lukaszraczylo 6e7e2ecfae fix(events): refuse a retention below one day
A retention of zero or less moved the delete cutoff to now or later. The cleaner then deleted all event history. The cleaner now refuses to start and returns an error.
2026-11-03 00:31:53 +00:00
16 changed files with 343 additions and 129 deletions
+6 -1
View File
@@ -143,8 +143,13 @@ func periodicallyReloadBannedUsers(ctx context.Context) {
} }
} }
func checkIfUserIsBanned(c fiber.Ctx, userID string) bool { func isUserBanned(userID string) bool {
_, found := bannedUsersIDs.Load(userID) _, found := bannedUsersIDs.Load(userID)
return found
}
func checkIfUserIsBanned(c fiber.Ctx, userID string) bool {
found := isUserBanned(userID)
cfg.Logger.Debug(&libpack_logger.LogMessage{ cfg.Logger.Debug(&libpack_logger.LogMessage{
Message: "Checking if user is banned", Message: "Checking if user is banned",
+9 -1
View File
@@ -83,7 +83,15 @@ type RedisClientConfig struct {
// entry. // entry.
const defaultTTL = 60 * time.Second const defaultTTL = 60 * time.Second
// DefaultKeyPrefix namespaces cache keys when no prefix is configured, so Clear
// and CountQueries never touch keys of other applications on a shared Redis.
const DefaultKeyPrefix = "gmp:"
func New(redisClientConfig *RedisClientConfig) (*RedisConfig, error) { func New(redisClientConfig *RedisClientConfig) (*RedisConfig, error) {
prefix := redisClientConfig.Prefix
if prefix == "" {
prefix = DefaultKeyPrefix
}
c := &RedisConfig{ c := &RedisConfig{
client: redis.NewClient(&redis.Options{ client: redis.NewClient(&redis.Options{
Addr: redisClientConfig.RedisServer, Addr: redisClientConfig.RedisServer,
@@ -91,7 +99,7 @@ func New(redisClientConfig *RedisClientConfig) (*RedisConfig, error) {
DB: redisClientConfig.RedisDB, DB: redisClientConfig.RedisDB,
}), }),
ctx: context.Background(), ctx: context.Background(),
prefix: redisClientConfig.Prefix, prefix: prefix,
builderPool: &sync.Pool{ builderPool: &sync.Pool{
New: func() any { New: func() any {
return &strings.Builder{} return &strings.Builder{}
+30
View File
@@ -0,0 +1,30 @@
package libpack_cache_redis
import (
"testing"
"github.com/alicebob/miniredis/v2"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Pins theorems/Theorems/RedisClear.lean: with no configured prefix, Clear must still spare foreign keys.
func TestClear_DefaultPrefixSparesForeignKeys(t *testing.T) {
t.Parallel()
s, err := miniredis.Run()
require.NoError(t, err)
t.Cleanup(s.Close)
require.NoError(t, s.Set("other-app:session", "keep"))
c, err := New(&RedisClientConfig{RedisServer: s.Addr()})
require.NoError(t, err)
require.NoError(t, c.Set("q1", []byte("v"), 0))
require.NoError(t, c.Clear())
assert.True(t, s.Exists("other-app:session"), "foreign key was deleted")
_, found, err := c.Get("q1")
require.NoError(t, err)
assert.False(t, found, "own key must be cleared")
}
+4 -4
View File
@@ -131,11 +131,11 @@ func (suite *RedisConfigSuite) TestGetKeys() {
err = suite.redisConfig.Set("otherkey", []byte("othervalue"), ttl) err = suite.redisConfig.Set("otherkey", []byte("othervalue"), ttl)
assert.NoError(suite.T(), err) assert.NoError(suite.T(), err)
keys, _ := suite.redisConfig.client.Keys(suite.redisConfig.ctx, "testkey*").Result() keys, _ := suite.redisConfig.client.Keys(suite.redisConfig.ctx, DefaultKeyPrefix+"testkey*").Result()
expectedKeys := []string{"testkey1", "testkey2"} expectedKeys := []string{DefaultKeyPrefix + "testkey1", DefaultKeyPrefix + "testkey2"}
assert.ElementsMatch(suite.T(), expectedKeys, keys) assert.ElementsMatch(suite.T(), expectedKeys, keys)
suite.redisConfig.client.Del(suite.redisConfig.ctx, "testkey1", "testkey2", "otherkey") suite.redisConfig.client.Del(suite.redisConfig.ctx, DefaultKeyPrefix+"testkey1", DefaultKeyPrefix+"testkey2", DefaultKeyPrefix+"otherkey")
} }
func (suite *RedisConfigSuite) TestGetKeysCount() { func (suite *RedisConfigSuite) TestGetKeysCount() {
@@ -154,5 +154,5 @@ func (suite *RedisConfigSuite) TestGetKeysCount() {
assert.NoError(suite.T(), err) assert.NoError(suite.T(), err)
assert.Equal(suite.T(), int64(3), count3) assert.Equal(suite.T(), int64(3), count3)
suite.redisConfig.client.Del(suite.redisConfig.ctx, "testkey1", "testkey2", "otherkey") suite.redisConfig.client.Del(suite.redisConfig.ctx, DefaultKeyPrefix+"testkey1", DefaultKeyPrefix+"testkey2", DefaultKeyPrefix+"otherkey")
} }
+7
View File
@@ -12,6 +12,9 @@ import (
const ( const (
initialDelay = 60 * time.Second initialDelay = 60 * time.Second
cleanupInterval = 1 * time.Hour cleanupInterval = 1 * time.Hour
// minRetentionDays guards against a cutoff at or after NOW(), which would delete all event history.
minRetentionDays = 1
) )
// Use parameterized queries to prevent SQL injection // Use parameterized queries to prevent SQL injection
@@ -46,6 +49,10 @@ func enableHasuraEventCleaner(ctx context.Context) error {
logger := cfg.Logger logger := cfg.Logger
cfgMutex.RUnlock() cfgMutex.RUnlock()
if clearOlderThan < minRetentionDays {
return fmt.Errorf("HASURA_EVENT_CLEANER_OLDER_THAN must be at least %d day, got %d: event cleaner not started", minRetentionDays, clearOlderThan)
}
logger.Info(&libpack_logger.LogMessage{ logger.Info(&libpack_logger.LogMessage{
Message: "Event cleaner enabled", Message: "Event cleaner enabled",
Pairs: map[string]any{"interval_in_days": clearOlderThan}, Pairs: map[string]any{"interval_in_days": clearOlderThan},
+26
View File
@@ -0,0 +1,26 @@
package main
import (
"context"
"testing"
libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
"github.com/stretchr/testify/assert"
)
// Pins theorems/Theorems/EventCleaner.lean: a retention below one day would delete all event history.
func TestEnableHasuraEventCleaner_RejectsNonPositiveRetention(t *testing.T) {
for _, days := range []int{0, -1, -30} {
cfgMutex.Lock()
cfg = &config{Logger: libpack_logger.New()}
cfg.HasuraEventCleaner.Enable = true
cfg.HasuraEventCleaner.EventMetadataDb = "postgres://fake:[email protected]:1/fake"
cfg.HasuraEventCleaner.ClearOlderThan = days
cfgMutex.Unlock()
ctx, cancel := context.WithCancel(context.Background())
err := enableHasuraEventCleaner(ctx)
cancel()
assert.Error(t, err, "days=%d", days)
}
}
-1
View File
@@ -20,7 +20,6 @@ require (
github.com/graphql-go/graphql v0.8.1 github.com/graphql-go/graphql v0.8.1
github.com/jackc/pgx/v5 v5.12.0 github.com/jackc/pgx/v5 v5.12.0
github.com/lukaszraczylo/ask v0.0.0-20240916204100-6e9ef53a62d9 github.com/lukaszraczylo/ask v0.0.0-20240916204100-6e9ef53a62d9
github.com/lukaszraczylo/go-ratecounter v0.1.13
github.com/lukaszraczylo/go-simple-graphql v1.2.89 github.com/lukaszraczylo/go-simple-graphql v1.2.89
github.com/lukaszraczylo/oss-telemetry v0.2.5 github.com/lukaszraczylo/oss-telemetry v0.2.5
github.com/redis/go-redis/v9 v9.23.0 github.com/redis/go-redis/v9 v9.23.0
-2
View File
@@ -72,8 +72,6 @@ github.com/klauspost/cpuid/v2 v2.4.0 h1:S6Hrbc7+ywsr0r+RLapfGBHfyefhCTwEh3A0tV91
github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU= github.com/klauspost/cpuid/v2 v2.4.0/go.mod h1:19jmZ9mjzoF//ddRSUsv0zfBTJWh3QJh9FNxZTMrGxU=
github.com/lukaszraczylo/ask v0.0.0-20240916204100-6e9ef53a62d9 h1:pL8B9mjv6RPUfKYYGm/uJ7QL6Ndf+z+OEl0qJE6KmEc= github.com/lukaszraczylo/ask v0.0.0-20240916204100-6e9ef53a62d9 h1:pL8B9mjv6RPUfKYYGm/uJ7QL6Ndf+z+OEl0qJE6KmEc=
github.com/lukaszraczylo/ask v0.0.0-20240916204100-6e9ef53a62d9/go.mod h1:M+UVdyqZs++xtEPrascaVmZdOMhCnxjZ2SgH+xHpR0c= github.com/lukaszraczylo/ask v0.0.0-20240916204100-6e9ef53a62d9/go.mod h1:M+UVdyqZs++xtEPrascaVmZdOMhCnxjZ2SgH+xHpR0c=
github.com/lukaszraczylo/go-ratecounter v0.1.13 h1:p1r53oyMb9ZnMbqub6IF4CWhURFiz7k967I0GbUyQMA=
github.com/lukaszraczylo/go-ratecounter v0.1.13/go.mod h1:TqXEOCtFJStk1i0tkipprv1kiDHGon1MVUisjSTBSKM=
github.com/lukaszraczylo/go-simple-graphql v1.2.89 h1:Xbu1Ny+a0lT2Sr2SaSC8mcHmGQDwGD4TJKk4DDd+PwA= github.com/lukaszraczylo/go-simple-graphql v1.2.89 h1:Xbu1Ny+a0lT2Sr2SaSC8mcHmGQDwGD4TJKk4DDd+PwA=
github.com/lukaszraczylo/go-simple-graphql v1.2.89/go.mod h1:PxQYblQDZISmYYj8sNfazAWxAOh1rhAtU208y+uPV8s= github.com/lukaszraczylo/go-simple-graphql v1.2.89/go.mod h1:PxQYblQDZISmYYj8sNfazAWxAOh1rhAtU208y+uPV8s=
github.com/lukaszraczylo/oss-telemetry v0.2.5 h1:W6Qgwp7IY4yBRO361MmnEAmM59motdQpbAFAx8v6fJM= github.com/lukaszraczylo/oss-telemetry v0.2.5 h1:W6Qgwp7IY4yBRO361MmnEAmM59motdQpbAFAx8v6fJM=
+25 -80
View File
@@ -8,17 +8,17 @@ import (
"time" "time"
"github.com/goccy/go-json" "github.com/goccy/go-json"
goratecounter "github.com/lukaszraczylo/go-ratecounter"
libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging" libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
) )
// RateLimitConfig holds the rate limit configuration for a role // RateLimitConfig holds the rate limit configuration for a role
type RateLimitConfig struct { type RateLimitConfig struct {
RateCounterTicker *goratecounter.RateCounter // sustained enforces Req over the full Interval.
sustained *attemptWindow
// burstCounter enforces Burst over a short trailing window, independent // burstCounter enforces Burst over a short trailing window, independent
// of RateCounterTicker's sustained-rate window. Nil when Burst <= 0 // of the sustained window. Nil when Burst <= 0
// (burst disabled). See burstWindow and checkRateLimit for semantics. // (burst disabled). See burstWindow and checkRateLimit for semantics.
burstCounter *burstWindow burstCounter *attemptWindow
Endpoints []string `json:"endpoints,omitempty"` Endpoints []string `json:"endpoints,omitempty"`
Interval time.Duration `json:"interval"` Interval time.Duration `json:"interval"`
Req int `json:"req"` Req int `json:"req"`
@@ -107,51 +107,6 @@ func burstWindowDuration(interval time.Duration) time.Duration {
return w return w
} }
// burstWindow is a minimal, clock-injectable, trailing-window request
// counter used to enforce RateLimitConfig.Burst independently of the
// sustained-rate RateCounterTicker.
//
// [email protected] cannot provide this second, shorter window
// cleanly: the window length lives on *RateCounter itself, not per named
// Counter (helpers.go: GetRate -> c.parent.interval.Seconds()), so two
// different window lengths need two separate RateCounter instances. Each
// instance drives its own background goroutine off wall-clock time.Now with
// no seam to inject a fake clock (helpers.go: start(), cleanUpOldValues(),
// addValue() all call time.Now() directly). That would force any test of
// window rollover to sleep on real time. burstWindow is implemented here
// instead, with a swappable clock, so tests can advance time deterministically.
type burstWindow struct {
mu sync.Mutex
now func() time.Time
window time.Duration
hits []time.Time
}
// newBurstWindow returns a burstWindow that tracks hits over the trailing
// duration window, using the real wall clock.
func newBurstWindow(window time.Duration) *burstWindow {
return &burstWindow{now: time.Now, window: window}
}
// recordAndCount records a hit at the current time, drops hits older than
// the trailing window, and returns the resulting hit count within the
// window. Safe for concurrent use.
func (b *burstWindow) recordAndCount() int {
b.mu.Lock()
defer b.mu.Unlock()
now := b.now()
cutoff := now.Add(-b.window)
live := b.hits[:0]
for _, t := range b.hits {
if t.After(cutoff) {
live = append(live, t)
}
}
live = append(live, now)
b.hits = live
return len(b.hits)
}
// Variable to hold the current load config function - allows for testing // Variable to hold the current load config function - allows for testing
var loadConfigFunc = loadConfigFromPath var loadConfigFunc = loadConfigFromPath
@@ -237,14 +192,12 @@ func loadConfigFromPath(path string) error {
newRateLimits := make(map[string]RateLimitConfig, len(config.RateLimit)) newRateLimits := make(map[string]RateLimitConfig, len(config.RateLimit))
for key, value := range config.RateLimit { for key, value := range config.RateLimit {
value.RateCounterTicker = goratecounter.NewRateCounter().WithConfig(goratecounter.RateCounterConfig{ value.sustained = newAttemptWindow(value.Interval, value.Req)
Interval: value.Interval,
})
// Burst disabled (<=0) leaves burstCounter nil, so checkRateLimit // Burst disabled (<=0) leaves burstCounter nil, so checkRateLimit
// performs the Req-only check exactly as before burst existed. // performs the Req-only check exactly as before burst existed.
if value.Burst > 0 { if value.Burst > 0 {
value.burstCounter = newBurstWindow(burstWindowDuration(value.Interval)) value.burstCounter = newAttemptWindow(burstWindowDuration(value.Interval), value.Burst)
} }
if cfg.LogLevel == "DEBUG" { if cfg.LogLevel == "DEBUG" {
@@ -279,7 +232,7 @@ func rateLimitedRequest(userID, userRole string) bool {
// Try to get config from atomic value first for better performance // Try to get config from atomic value first for better performance
if configInterface := rateLimitConfigAtomic.Load(); configInterface != nil { if configInterface := rateLimitConfigAtomic.Load(); configInterface != nil {
if config, ok := configInterface.(map[string]RateLimitConfig); ok { if config, ok := configInterface.(map[string]RateLimitConfig); ok {
if roleConfig, exists := config[userRole]; exists && roleConfig.RateCounterTicker != nil { if roleConfig, exists := config[userRole]; exists && roleConfig.sustained != nil {
return checkRateLimit(userID, userRole, roleConfig, "") return checkRateLimit(userID, userRole, roleConfig, "")
} }
} }
@@ -290,7 +243,7 @@ func rateLimitedRequest(userID, userRole string) bool {
roleConfig, ok := rateLimits[userRole] roleConfig, ok := rateLimits[userRole]
rateLimitMu.RUnlock() rateLimitMu.RUnlock()
if !ok || roleConfig.RateCounterTicker == nil { if !ok || roleConfig.sustained == nil {
cfg.Logger.Warning(&libpack_logger.LogMessage{ cfg.Logger.Warning(&libpack_logger.LogMessage{
Message: "Rate limit role not found or ticker not initialized - defaulting to deny", Message: "Rate limit role not found or ticker not initialized - defaulting to deny",
Pairs: map[string]any{"user_role": userRole}, Pairs: map[string]any{"user_role": userRole},
@@ -315,45 +268,37 @@ func rateLimitedRequest(userID, userRole string) bool {
// sustained count. Burst >= Req therefore never rejects anything the sustained // sustained count. Burst >= Req therefore never rejects anything the sustained
// gate admits; Burst only takes effect when it is below Req. // gate admits; Burst only takes effect when it is below Req.
func checkRateLimit(userID, userRole string, roleConfig RateLimitConfig, endpoint string) bool { func checkRateLimit(userID, userRole string, roleConfig RateLimitConfig, endpoint string) bool {
roleConfig.RateCounterTicker.Incr(1) debug := cfg.Logger.IsLevelEnabled(libpack_logger.LEVEL_DEBUG)
tickerRate := roleConfig.RateCounterTicker.Get()
logDetails := map[string]any{ if roleConfig.sustained.recordAndExceeds() {
if debug {
cfg.Logger.Debug(&libpack_logger.LogMessage{
Message: "Rate limit exceeded",
Pairs: map[string]any{
"user_role": userRole, "user_role": userRole,
"user_id": userID, "user_id": userID,
"rate": tickerRate,
"config_rate": roleConfig.Req, "config_rate": roleConfig.Req,
"interval": roleConfig.Interval, "interval": roleConfig.Interval,
"endpoint": endpoint, "endpoint": endpoint,
},
})
} }
cfg.Logger.Debug(&libpack_logger.LogMessage{
Message: "Rate limit ticker",
Pairs: map[string]any{"log_details": logDetails},
})
if tickerRate > int64(roleConfig.Req) {
cfg.Logger.Debug(&libpack_logger.LogMessage{
Message: "Rate limit exceeded",
Pairs: map[string]any{"log_details": logDetails},
})
return false return false
} }
if roleConfig.burstCounter != nil { if roleConfig.burstCounter != nil {
burstCount := roleConfig.burstCounter.recordAndCount() if roleConfig.burstCounter.recordAndExceeds() {
burstLogDetails := map[string]any{ if debug {
"user_role": userRole,
"user_id": userID,
"burst_count": burstCount,
"config_burst": roleConfig.Burst,
"endpoint": endpoint,
}
if burstCount > roleConfig.Burst {
cfg.Logger.Debug(&libpack_logger.LogMessage{ cfg.Logger.Debug(&libpack_logger.LogMessage{
Message: "Burst limit exceeded", Message: "Burst limit exceeded",
Pairs: map[string]any{"log_details": burstLogDetails}, Pairs: map[string]any{
"user_role": userRole,
"user_id": userID,
"config_burst": roleConfig.Burst,
"endpoint": endpoint,
},
}) })
}
return false return false
} }
} }
+7 -16
View File
@@ -10,7 +10,6 @@ import (
"time" "time"
"github.com/goccy/go-json" "github.com/goccy/go-json"
goratecounter "github.com/lukaszraczylo/go-ratecounter"
libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging" libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
) )
@@ -65,8 +64,8 @@ func (suite *Tests) Test_loadRatelimitConfig() {
suite.Contains(rateLimits, "user") suite.Contains(rateLimits, "user")
suite.Equal(100, rateLimits["admin"].Req) suite.Equal(100, rateLimits["admin"].Req)
suite.Equal(10, rateLimits["user"].Req) suite.Equal(10, rateLimits["user"].Req)
suite.NotNil(rateLimits["admin"].RateCounterTicker) suite.NotNil(rateLimits["admin"].sustained)
suite.NotNil(rateLimits["user"].RateCounterTicker) suite.NotNil(rateLimits["user"].sustained)
}) })
// Test loading config from non-existent path // Test loading config from non-existent path
@@ -154,21 +153,15 @@ func (suite *Tests) Test_rateLimitedRequest() {
rateLimits = make(map[string]RateLimitConfig) rateLimits = make(map[string]RateLimitConfig)
// Admin role with high limit // Admin role with high limit
adminCounter := goratecounter.NewRateCounter().WithConfig(goratecounter.RateCounterConfig{
Interval: 1 * time.Second,
})
rateLimits["admin"] = RateLimitConfig{ rateLimits["admin"] = RateLimitConfig{
RateCounterTicker: adminCounter, sustained: newAttemptWindow(1*time.Second, 100),
Interval: 1 * time.Second, Interval: 1 * time.Second,
Req: 100, Req: 100,
} }
// User role with low limit // User role with low limit
userCounter := goratecounter.NewRateCounter().WithConfig(goratecounter.RateCounterConfig{
Interval: 1 * time.Second,
})
rateLimits["user"] = RateLimitConfig{ rateLimits["user"] = RateLimitConfig{
RateCounterTicker: userCounter, sustained: newAttemptWindow(1*time.Second, 2),
Interval: 1 * time.Second, Interval: 1 * time.Second,
Req: 2, // Set very low for testing Req: 2, // Set very low for testing
} }
@@ -327,19 +320,17 @@ func (f *fakeClock) Advance(d time.Duration) {
} }
// newBurstTestRoleConfig mirrors the RateLimitConfig construction performed // newBurstTestRoleConfig mirrors the RateLimitConfig construction performed
// by loadConfigFromPath: a real RateCounterTicker for the sustained check, // by loadConfigFromPath: a real sustained window for the Req check,
// and (only when burst > 0) a burstCounter sized by burstWindowDuration. // and (only when burst > 0) a burstCounter sized by burstWindowDuration.
func newBurstTestRoleConfig(interval time.Duration, req, burst int) RateLimitConfig { func newBurstTestRoleConfig(interval time.Duration, req, burst int) RateLimitConfig {
rc := RateLimitConfig{ rc := RateLimitConfig{
RateCounterTicker: goratecounter.NewRateCounter().WithConfig(goratecounter.RateCounterConfig{ sustained: newAttemptWindow(interval, req),
Interval: interval,
}),
Interval: interval, Interval: interval,
Req: req, Req: req,
Burst: burst, Burst: burst,
} }
if burst > 0 { if burst > 0 {
rc.burstCounter = newBurstWindow(burstWindowDuration(interval)) rc.burstCounter = newAttemptWindow(burstWindowDuration(interval), burst)
} }
return rc return rc
} }
+49
View File
@@ -0,0 +1,49 @@
package main
import (
"sync"
"time"
)
// attemptWindow decides "more than limit attempts in the trailing window" while
// storing only the newest limit+1 attempt times. The attempt number limit+1
// counted back from now lies inside the window exactly when the full count
// exceeds limit (see theorems/Theorems/SlidingWindow.lean), so memory and time
// per attempt stay constant however hard a client floods.
type attemptWindow struct {
now func() time.Time
base time.Time // monotonic origin for stored offsets
ring []int64 // offsets from base, grows to capacity then wraps
mu sync.Mutex
window time.Duration
head int // index of the oldest stored attempt once the ring is full
size int // limit+1: attempts kept; the ring grows lazily up to this
}
func newAttemptWindow(window time.Duration, limit int) *attemptWindow {
return &attemptWindow{
now: time.Now,
base: time.Now(),
window: window,
size: max(limit, 0) + 1,
}
}
// recordAndExceeds records an attempt now and reports whether the attempts in
// the trailing window, this one included, outnumber the limit.
func (w *attemptWindow) recordAndExceeds() bool {
w.mu.Lock()
defer w.mu.Unlock()
t := int64(w.now().Sub(w.base))
if len(w.ring) < w.size {
w.ring = append(w.ring, t)
} else {
w.ring[w.head] = t
w.head = (w.head + 1) % len(w.ring)
}
if len(w.ring) < w.size {
return false
}
return w.ring[w.head] > t-int64(w.window)
}
+64
View File
@@ -0,0 +1,64 @@
package main
import (
"math/rand"
"testing"
"time"
)
// naiveExceeds is the exact reference: count every attempt in the trailing window.
func naiveExceeds(attempts []time.Time, now time.Time, window time.Duration, limit int) bool {
cutoff := now.Add(-window)
n := 0
for _, t := range attempts {
if t.After(cutoff) {
n++
}
}
return n > limit
}
// Pins theorems/Theorems/SlidingWindow.lean: storing the newest limit+1 attempts decides like the full count.
func TestAttemptWindow_MatchesExactCount(t *testing.T) {
for _, limit := range []int{0, 1, 3, 10} {
rng := rand.New(rand.NewSource(int64(limit) + 1))
window := 10 * time.Second
fc := newFakeClock(time.Unix(1_000_000, 0))
w := newAttemptWindow(window, limit)
w.now = fc.Now
w.base = fc.Now()
var attempts []time.Time
for i := 0; i < 5000; i++ {
fc.Advance(time.Duration(rng.Intn(3000)) * time.Millisecond)
attempts = append(attempts, fc.Now())
want := naiveExceeds(attempts, fc.Now(), window, limit)
if got := w.recordAndExceeds(); got != want {
t.Fatalf("limit=%d step=%d: got %v, want %v", limit, i, got, want)
}
}
}
}
func TestAttemptWindow_StorageIsBounded(t *testing.T) {
const limit = 10
fc := newFakeClock(time.Unix(1_000_000, 0))
w := newAttemptWindow(time.Hour, limit)
w.now = fc.Now
w.base = fc.Now()
for i := 0; i < 100_000; i++ {
w.recordAndExceeds()
fc.Advance(time.Millisecond)
}
if got := len(w.ring); got > limit+1 {
t.Fatalf("stored %d timestamps, want <= %d", got, limit+1)
}
}
func BenchmarkAttemptWindow(b *testing.B) {
w := newAttemptWindow(time.Hour, 1000)
b.ReportAllocs()
for i := 0; i < b.N; i++ {
w.recordAndExceeds()
}
}
+7 -2
View File
@@ -442,6 +442,11 @@ func processGraphQLRequest(c fiber.Ctx) error {
// never an error; the request stays anonymous, exactly like before // never an error; the request stays anonymous, exactly like before
// verification existed. // verification existed.
func extractUserInfo(c fiber.Ctx) (string, string, error) { func extractUserInfo(c fiber.Ctx) (string, string, error) {
return extractUserInfoFromHeaders(func(key string) string { return c.Get(key) })
}
// extractUserInfoFromHeaders resolves user id and role from request headers via get.
func extractUserInfoFromHeaders(get func(string) string) (string, string, error) {
extractedUserID := defaultValue extractedUserID := defaultValue
extractedRoleName := defaultValue extractedRoleName := defaultValue
@@ -450,7 +455,7 @@ func extractUserInfo(c fiber.Ctx) (string, string, error) {
// Authorization header must still be checked even with no claim path // Authorization header must still be checked even with no claim path
// set, otherwise JWT_VERIFY_SIGNATURE=true alone would never call // set, otherwise JWT_VERIFY_SIGNATURE=true alone would never call
// Verify and the forged-token rejection below would never trigger. // Verify and the forged-token rejection below would never trigger.
if authorization := c.Get("Authorization"); authorization != "" && if authorization := get("Authorization"); authorization != "" &&
(len(cfg.Client.JWTUserClaimPath) > 0 || len(cfg.Client.JWTRoleClaimPath) > 0 || cfg.Client.JWTVerifier != nil) { (len(cfg.Client.JWTUserClaimPath) > 0 || len(cfg.Client.JWTRoleClaimPath) > 0 || cfg.Client.JWTVerifier != nil) {
usr, role, err := extractClaimsFromJWTHeader(authorization) usr, role, err := extractClaimsFromJWTHeader(authorization)
if err != nil { if err != nil {
@@ -461,7 +466,7 @@ func extractUserInfo(c fiber.Ctx) (string, string, error) {
// Override role from header if configured // Override role from header if configured
if cfg.Client.RoleFromHeader != "" { if cfg.Client.RoleFromHeader != "" {
if role := c.Get(cfg.Client.RoleFromHeader); role != "" { if role := get(cfg.Client.RoleFromHeader); role != "" {
extractedRoleName = role extractedRoleName = role
} }
} }
+47
View File
@@ -2,6 +2,7 @@ package main
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"net/http" "net/http"
"sync" "sync"
@@ -231,6 +232,22 @@ func (wsp *WebSocketProxy) handleConnection(ctx context.Context, clientConn *web
// Try to extract headers from connection_init payload (for GraphQL WebSocket protocols) // Try to extract headers from connection_init payload (for GraphQL WebSocket protocols)
enrichedHeaders := wsp.extractAuthFromPayload(message, headers) enrichedHeaders := wsp.extractAuthFromPayload(message, headers)
// The upgrade request was gated on its own headers; the backend acts as the
// identity in enrichedHeaders, so gate that one too.
if authErr := authorizePayloadIdentity(headers, enrichedHeaders); authErr != nil {
wsp.errors.Add(1)
if wsp.logger != nil {
wsp.logger.Warning(&libpack_logger.LogMessage{
Message: "WebSocket connection_init identity rejected",
Pairs: map[string]any{"connection_id": connectionID, "reason": authErr.Error()},
})
}
_ = clientConn.WriteMessage(websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.ClosePolicyViolation, authErr.Error()))
_ = clientConn.Close() // Best-effort cleanup
return
}
// Connect to backend WebSocket with enriched headers // Connect to backend WebSocket with enriched headers
backendConn, err := wsp.dialBackend(ctx, enrichedHeaders) backendConn, err := wsp.dialBackend(ctx, enrichedHeaders)
if err != nil { if err != nil {
@@ -586,6 +603,36 @@ func (wsp *WebSocketProxy) extractAuthFromPayload(message []byte, originalHeader
return enrichedHeaders return enrichedHeaders
} }
var (
errWSUnauthorized = errors.New("invalid or expired authorization token")
errWSBanned = errors.New("user is banned")
errWSRateLimited = errors.New("rate limit exceeded")
)
// authorizePayloadIdentity applies the ban and rate-limit checks of the upgrade
// path to the identity that connection_init supplied. It does nothing when the
// payload left Authorization and the role header unchanged, because the upgrade
// request already passed those checks (and counted against the rate limit).
func authorizePayloadIdentity(upgrade, enriched http.Header) error {
roleHeader := cfg.Client.RoleFromHeader
if upgrade.Get("Authorization") == enriched.Get("Authorization") &&
(roleHeader == "" || upgrade.Get(roleHeader) == enriched.Get(roleHeader)) {
return nil
}
userID, role, err := extractUserInfoFromHeaders(enriched.Get)
if err != nil {
return errWSUnauthorized
}
if isUserBanned(userID) {
return errWSBanned
}
if cfg.Client.RoleRateLimit && !rateLimitedRequest(userID, role) {
return errWSRateLimited
}
return nil
}
// dialBackend establishes a WebSocket connection to the backend // dialBackend establishes a WebSocket connection to the backend
func (wsp *WebSocketProxy) dialBackend(ctx context.Context, headers http.Header) (*gorillaws.Conn, error) { func (wsp *WebSocketProxy) dialBackend(ctx context.Context, headers http.Header) (*gorillaws.Conn, error) {
// Convert http:// to ws:// or https:// to wss:// // Convert http:// to ws:// or https:// to wss://
+43
View File
@@ -0,0 +1,43 @@
package main
import (
"encoding/base64"
"net/http"
"testing"
libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
"github.com/stretchr/testify/assert"
)
func unsignedJWT(sub, role string) string {
enc := base64.RawURLEncoding.EncodeToString
return "Bearer " + enc([]byte(`{"alg":"none"}`)) + "." +
enc([]byte(`{"sub":"`+sub+`","role":"`+role+`"}`)) + "."
}
// Pins theorems/Theorems/WsIdentityGate.lean: the identity the backend acts as is the one that is gated.
func TestAuthorizePayloadIdentity(t *testing.T) {
cfg = &config{Logger: libpack_logger.New()}
cfg.Client.JWTUserClaimPath = "sub"
cfg.Client.JWTRoleClaimPath = "role"
bannedUsersIDs.Store("banned-user", "test")
defer bannedUsersIDs.Delete("banned-user")
tests := []struct {
name string
upgrade http.Header
enriched http.Header
wantErr error
}{
{"payload adds banned identity", http.Header{}, http.Header{"Authorization": {unsignedJWT("banned-user", "user")}}, errWSBanned},
{"payload replaces clean identity with banned", http.Header{"Authorization": {unsignedJWT("ok-user", "user")}}, http.Header{"Authorization": {unsignedJWT("banned-user", "user")}}, errWSBanned},
{"payload identity clean", http.Header{}, http.Header{"Authorization": {unsignedJWT("ok-user", "user")}}, nil},
{"payload does not change identity", http.Header{"Authorization": {unsignedJWT("ok-user", "user")}}, http.Header{"Authorization": {unsignedJWT("ok-user", "user")}}, nil},
{"no authorization anywhere", http.Header{}, http.Header{}, nil},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
assert.ErrorIs(t, authorizePayloadIdentity(tt.upgrade, tt.enriched), tt.wantErr)
})
}
}
+1 -4
View File
@@ -11,7 +11,6 @@ import (
fiber "github.com/gofiber/fiber/v3" fiber "github.com/gofiber/fiber/v3"
gorillaws "github.com/gorilla/websocket" gorillaws "github.com/gorilla/websocket"
goratecounter "github.com/lukaszraczylo/go-ratecounter"
libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging" libpack_logger "github.com/lukaszraczylo/graphql-monitoring-proxy/logging"
libpack_monitoring "github.com/lukaszraczylo/graphql-monitoring-proxy/monitoring" libpack_monitoring "github.com/lukaszraczylo/graphql-monitoring-proxy/monitoring"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -652,9 +651,7 @@ func TestHandleWebSocketOrDefault_RateLimitedUserRejected(t *testing.T) {
rateLimitMu.Lock() rateLimitMu.Lock()
rateLimits = map[string]RateLimitConfig{ rateLimits = map[string]RateLimitConfig{
defaultValue: { defaultValue: {
RateCounterTicker: goratecounter.NewRateCounter().WithConfig(goratecounter.RateCounterConfig{ sustained: newAttemptWindow(1*time.Second, 0),
Interval: 1 * time.Second,
}),
Interval: 1 * time.Second, Interval: 1 * time.Second,
Req: 0, Req: 0,
}, },