test: security regression — IDOR payment-check scoping, CORS preflight 403, rate limiter pruning, services error leakage

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
2026-08-22 00:34:51 +01:00
co-authored by Sisyphus
parent a358c8c0ea
commit 4d179385e8
4 changed files with 302 additions and 2 deletions
+77
View File
@@ -950,3 +950,80 @@ func TestProgressiveRateLimit_RejectsOnlyTopTier(t *testing.T) {
t.Errorf("the 10s tier must reject immediately, not sleep (took %s)", elapsed)
}
}
// ============================================================
// ProgressiveRateLimiter pruning: timestamps pruned before counting
// ============================================================
// TestProgressiveRateLimiter_TimestampsBoundedAfterManyCalls verifies that
// calling Check() many times does not cause unbounded growth of the timestamps
// slice. The pruning step in Check() should keep the slice bounded to at most
// the number of timestamps that fit within the 60-second window.
func TestProgressiveRateLimiter_TimestampsBoundedAfterManyCalls(t *testing.T) {
prl := NewProgressiveRateLimiter()
ip := "198.51.100.77"
// Make 100 rapid calls — each appends a timestamp, then the pruning step
// removes anything older than 60s. Since all 100 calls happen within much
// less than 60s, the slice should contain at most 100 entries after pruning.
for i := 0; i < 100; i++ {
_ = prl.Check(ip)
}
prl.mu.RLock()
state, exists := prl.requests[ip]
prl.mu.RUnlock()
if !exists {
t.Fatal("expected ip state to exist after 100 calls")
}
if len(state.timestamps) > 100 {
t.Errorf("timestamps unbounded after 100 calls: got %d entries", len(state.timestamps))
}
// The slice should not have more than 120 entries (the sustained limit)
// after rapid calling — the pruning in Check() keeps it bounded.
maxExpected := 120
if len(state.timestamps) > maxExpected {
t.Errorf("expected at most %d timestamps after pruning, got %d", maxExpected, len(state.timestamps))
}
}
// TestProgressiveRateLimiter_OldTimestampsPrunedOnCheck verifies that
// timestamps older than 60 seconds are pruned when Check() runs. This is the
// core of the fix: the pruning happens BEFORE counting, so stale timestamps
// from burst traffic cannot inflate the sustained count.
func TestProgressiveRateLimiter_OldTimestampsPrunedOnCheck(t *testing.T) {
prl := NewProgressiveRateLimiter()
ip := "198.51.100.78"
// Seed timestamps: 10 recent (within 5s) + 100 old (65-120s ago).
// After pruning, only the 10 recent timestamps should remain.
now := clock.Now()
ts := make([]time.Time, 0, 110)
for i := 0; i < 10; i++ {
ts = append(ts, now.Add(-time.Second))
}
for i := 0; i < 100; i++ {
ts = append(ts, now.Add(-time.Duration(65+i)*time.Second))
}
prl.mu.Lock()
prl.requests[ip] = &ipProgressiveState{timestamps: ts}
prl.mu.Unlock()
// Check() should prune timestamps before counting.
_ = prl.Check(ip)
prl.mu.RLock()
state := prl.requests[ip]
count := len(state.timestamps)
prl.mu.RUnlock()
// After pruning, we should have at most 11 entries (10 recent + the one
// just appended by Check()). All 100 old timestamps should be gone.
if count > 20 {
t.Errorf("expected old timestamps to be pruned — got %d entries, expected <= 11", count)
}
if count < 5 {
t.Errorf("expected recent timestamps to survive pruning — got only %d entries", count)
}
}