Skip to content

Comments

fix: use IP-based rate limiting to prevent infinite bucket attacks[ISSUE #100]#126

Open
Yashaswini-V21 wants to merge 2 commits intoAnkanMisra:mainfrom
Yashaswini-V21:fix/rate-limiting-ip-based
Open

fix: use IP-based rate limiting to prevent infinite bucket attacks[ISSUE #100]#126
Yashaswini-V21 wants to merge 2 commits intoAnkanMisra:mainfrom
Yashaswini-V21:fix/rate-limiting-ip-based

Conversation

@Yashaswini-V21
Copy link

@Yashaswini-V21 Yashaswini-V21 commented Feb 20, 2026

Closes #100

Problem

The current rate limiter used nonce-based keying for authenticated requests. Since nonces must be unique per request (to prevent replay attacks), every request created a new rate limit bucket, resulting in:

  • Infinite rate limits for authenticated users
  • Memory leaks from unbounded bucket creation

Solution

Changed to IP-based rate limiting for all requests to prevent infinite-bucket attacks.

Changes

  • Modified getRateLimitKey() to always return IP-based keys
  • Updated TestGetRateLimitKey to validate IP-based behavior
  • Removed nonce-based keying logic

Testing

  • Updated unit tests to reflect new behavior
  • Tests will run in CI pipeline

Summary by CodeRabbit

  • Bug Fixes

    • Rate limiting now uses client IPs for keying, ensuring consistent per-IP throttling instead of per-request identifiers.
  • Tests

    • Updated rate-limit tests to validate IP-based key generation across authentication header scenarios.

Greptile Summary

This PR fixes the infinite bucket creation bug by switching from nonce-based to IP-based rate limiting, but introduces a new critical issue where authenticated users sharing the same IP (corporate NAT, VPNs, proxies) now share rate limit buckets instead of having individual limits.

Key Changes:

  • Removed nonce-based keying logic that created infinite buckets (correctly fixes the memory leak)
  • Changed getRateLimitKey() to always return IP-based keys
  • Updated tests to validate IP-based behavior

Critical Issues Found:

  • Shared rate limits: Multiple authenticated users from same IP share one bucket (e.g., 10 office workers collectively limited to 60 RPM instead of 60 RPM each)
  • Proxy misconfiguration risk: ClientIP() without SetTrustedProxies() may return proxy IP, causing all users behind load balancer to share one bucket
  • Misleading test: TestRateLimitMiddleware_DifferentKeys claims to test separate user buckets but doesn't validate this after the change

Recommended Solution:
Use wallet address (from RecoveredAddress) as the rate limit key for authenticated requests, keeping IP-based limits only for anonymous users.

Confidence Score: 1/5

  • This PR contains a critical logic bug that degrades functionality for authenticated users
  • While the PR correctly fixes the infinite bucket memory leak, it introduces a severe regression where all authenticated users from the same IP share rate limits. This breaks the intended per-user rate limiting for authenticated requests and significantly degrades service quality for users behind corporate networks, VPNs, or proxies. Additionally, the lack of trusted proxy configuration could cause all users behind a load balancer to share a single rate limit bucket.
  • gateway/main.go requires critical attention - the getRateLimitKey() function needs wallet-based keying for authenticated users

Important Files Changed

Filename Overview
gateway/main.go Replaced nonce-based rate limiting with IP-only, creating shared rate limit vulnerability for users behind same IP
gateway/main_test.go Updated tests to validate IP-based behavior, but test names/comments misleading about separate buckets

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[Request Arrives] --> B[RateLimitMiddleware]
    B --> C[getRateLimitKey]
    C --> D{Has Signature<br/>and Nonce?}
    D -->|Before: Yes| E[BEFORE: nonce-based key<br/>Creates NEW bucket<br/>per request]
    D -->|After: Yes/No| F[AFTER: IP-based key<br/>c.ClientIP]
    E --> G[selectRateLimitTier]
    F --> G
    G --> H{Authenticated?}
    H -->|Yes| I[standard tier<br/>60 RPM]
    H -->|No| J[anonymous tier<br/>10 RPM]
    I --> K[limiters map standard]
    J --> L[limiters map anonymous]
    K --> M[Check bucket<br/>limiter.Allow key]
    L --> M
    M --> N{Allowed?}
    N -->|Yes| O[Continue]
    N -->|No| P[429 Rate Limited]
    
    style E fill:#ffcccc
    style F fill:#ccffcc
    style M fill:#ffffcc
Loading

Last reviewed commit: fb674b0

(2/5) Greptile learns from your feedback when you react with thumbs up/down!

- Changed getRateLimitKey to always use IP address instead of nonce
- Nonce-based keying created new buckets for every request (nonces must be unique)
- This fixes infinite rate limits and memory leaks for authenticated users
- Updated TestGetRateLimitKey to expect IP-based keys in all scenarios

Fixes AnkanMisra#100
Copilot AI review requested due to automatic review settings February 20, 2026 06:20
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 20, 2026

📝 Walkthrough

Walkthrough

The rate-limiting strategy was changed to always key off client IP. getRateLimitKey now returns an IP-derived key for all requests; nonce/signature header logic and related branching were removed. Tests were updated to assert IP-based keys in all scenarios.

Changes

Cohort / File(s) Summary
Rate-limit key generation
gateway/main.go
Replaced nonce/signature-based keying with a single IP-based key ("ip:"+c.ClientIP()); removed header/nonce extraction and conditional branches.
Rate-limit key generation tests
gateway/main_test.go
Updated unit tests to expect IP-prefixed keys for all cases (with signature, with nonce, both, neither); removed nonce-specific assertions and branching.

Sequence Diagram(s)

sequenceDiagram
    participant Client as Client
    participant Gateway as Gateway
    participant RL as RateLimiter

    Client->>Gateway: HTTP request (any headers, nonce varies)
    Gateway->>Gateway: getRateLimitKey() -> "ip:" + ClientIP
    Gateway->>RL: check/consume bucket for "ip:<client-ip>"
    RL-->>Gateway: Allow / Deny (429)
    Gateway-->>Client: Response (200 or 429)
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested labels

Medium, SWoC26

Suggested reviewers

  • AnkanMisra

Poem

🐰 I hopped through headers, nonces in tow,
Now I sniff the IP and let buckets grow,
One sniff, one bucket, no wild spree,
Steady hops, safe gates — that's my plea! 🥕

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 66.67% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and specifically describes the main change: switching to IP-based rate limiting to prevent infinite bucket attacks from nonce-based keying.
Linked Issues check ✅ Passed The PR successfully addresses all coding requirements from issue #100: replaced nonce-based keying with IP-based keying in getRateLimitKey, removed nonce logic, updated unit tests, and prevents memory leaks.
Out of Scope Changes check ✅ Passed All changes are directly related to the rate-limiting fix in issue #100. The modifications to gateway/main.go and gateway/main_test.go are focused on implementing IP-based keying as specified.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 files reviewed, 3 comments

Edit Code Review Agent Settings | Greptile

Comment on lines +727 to 734
// getRateLimitKey determines the key for rate limiting (always uses IP)
func getRateLimitKey(c *gin.Context) string {
signature := c.GetHeader("X-402-Signature")
nonce := c.GetHeader("X-402-Nonce")

// Only use nonce-based key if BOTH signature and nonce are present
// This prevents attackers from bypassing IP rate limits with fake nonces
if signature != "" && nonce != "" {
hash := sha256.Sum256([]byte(nonce))
// Use 32 hex chars (128 bits) for better collision resistance
return "nonce:" + hex.EncodeToString(hash[:])[:32]
}

// REMOVED: Nonce-based keying
// Nonces must be unique per request (replay attack prevention),
// which creates infinite buckets and memory leaks.
// ALWAYS use IP for now to prevent infinite-bucket attacks
return "ip:" + c.ClientIP()
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This creates a critical bug: all authenticated users sharing the same IP now share the same rate limit bucket.

Users behind corporate NAT, VPNs, or proxies will unfairly share rate limits. For example, if 10 employees at a company all authenticate with different wallets, they'd collectively hit the 60 RPM limit instead of each getting 60 RPM.

The original nonce-based approach failed because nonces are unique per request. However, you have access to recovered_address from signature verification (gateway/main.go:434, gateway/main.go:51). Consider using wallet address as the rate limit key for authenticated requests:

  • Anonymous: "ip:" + c.ClientIP()
  • Authenticated: "wallet:" + recoveredAddress

This requires either:

  1. Moving signature verification before rate limiting (adds latency)
  2. Using a lightweight address recovery in the rate limiter
  3. Accepting IP-based limits only for anonymous tier, and using wallet-based for authenticated tiers
Prompt To Fix With AI
This is a comment left during a code review.
Path: gateway/main.go
Line: 727-734

Comment:
This creates a critical bug: **all authenticated users sharing the same IP now share the same rate limit bucket**. 

Users behind corporate NAT, VPNs, or proxies will unfairly share rate limits. For example, if 10 employees at a company all authenticate with different wallets, they'd collectively hit the 60 RPM limit instead of each getting 60 RPM.

The original nonce-based approach failed because nonces are unique per request. However, you have access to `recovered_address` from signature verification (gateway/main.go:434, gateway/main.go:51). Consider using wallet address as the rate limit key for authenticated requests:
- Anonymous: `"ip:" + c.ClientIP()`  
- Authenticated: `"wallet:" + recoveredAddress`

This requires either:
1. Moving signature verification before rate limiting (adds latency)
2. Using a lightweight address recovery in the rate limiter
3. Accepting IP-based limits only for anonymous tier, and using wallet-based for authenticated tiers

How can I resolve this? If you propose a fix, please make it concise.

// Nonces must be unique per request (replay attack prevention),
// which creates infinite buckets and memory leaks.
// ALWAYS use IP for now to prevent infinite-bucket attacks
return "ip:" + c.ClientIP()
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Gin's ClientIP() without trusted proxy configuration may return incorrect IPs behind load balancers/proxies.

In main() (line 205), there's no call to gin.Engine.SetTrustedProxies(). If this gateway runs behind a reverse proxy (nginx, AWS ALB, etc.), ClientIP() will return the proxy's IP, not the user's real IP, causing all users to share one rate limit bucket.

Check if running behind a proxy and configure trusted proxies if needed.

Prompt To Fix With AI
This is a comment left during a code review.
Path: gateway/main.go
Line: 733

Comment:
Gin's `ClientIP()` without trusted proxy configuration may return incorrect IPs behind load balancers/proxies. 

In `main()` (line 205), there's no call to `gin.Engine.SetTrustedProxies()`. If this gateway runs behind a reverse proxy (nginx, AWS ALB, etc.), `ClientIP()` will return the proxy's IP, not the user's real IP, causing all users to share one rate limit bucket.

Check if running behind a proxy and configure trusted proxies if needed.

How can I resolve this? If you propose a fix, please make it concise.

@greptile-apps
Copy link

greptile-apps bot commented Feb 20, 2026

Additional Comments (1)

gateway/main_test.go
Test name and comment claim to verify "different users have separate rate limit buckets" (line 158), but after this PR, all authenticated users from the same IP share the same bucket.

This test still passes because both simulated users come from the same test client IP in the test environment, making the test accidentally valid but for the wrong reason. The test no longer validates what it claims to validate.

Either update the test name/comments to reflect IP-based bucketing, or update the test to actually simulate different IPs using req.Header.Set("X-Forwarded-For", ...) if trusted proxies are configured.

Prompt To Fix With AI
This is a comment left during a code review.
Path: gateway/main_test.go
Line: 157-208

Comment:
Test name and comment claim to verify "different users have separate rate limit buckets" (line 158), but after this PR, all authenticated users from the same IP share the same bucket.

This test still passes because both simulated users come from the same test client IP in the test environment, making the test accidentally valid but for the wrong reason. The test no longer validates what it claims to validate.

Either update the test name/comments to reflect IP-based bucketing, or update the test to actually simulate different IPs using `req.Header.Set("X-Forwarded-For", ...)` if trusted proxies are configured.

How can I resolve this? If you propose a fix, please make it concise.

Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes a critical rate limiting vulnerability (issue #100) where nonce-based keying created infinite rate limit buckets, effectively bypassing rate limits and causing memory leaks. The fix changes the rate limiting strategy to use IP-based keys exclusively, preventing the infinite bucket attack.

Changes:

  • Replaced nonce-based rate limiting keys with IP-based keys in getRateLimitKey()
  • Updated test to validate IP-based behavior across all scenarios (with/without auth headers)
  • Removed nonce hashing logic to simplify the implementation

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 4 comments.

File Description
gateway/main.go Modified getRateLimitKey() to always return IP-based keys, removing previous nonce-based logic that created unlimited buckets
gateway/main_test.go Updated TestGetRateLimitKey to expect IP-based keys in all test cases and simplified validation logic

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

// Nonces must be unique per request (replay attack prevention),
// which creates infinite buckets and memory leaks.
// ALWAYS use IP for now to prevent infinite-bucket attacks
return "ip:" + c.ClientIP()
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The use of c.ClientIP() without configuring trusted proxies in Gin makes this vulnerable to IP spoofing via X-Forwarded-For headers. An attacker can bypass rate limits by sending different X-Forwarded-For values with each request.

Gin's ClientIP() method returns the first untrusted IP from X-Forwarded-For by default when no trusted proxies are configured. This means any client can set arbitrary X-Forwarded-For headers to create new rate limit buckets.

To fix this:

  1. Configure trusted proxies using router.SetTrustedProxies() if deployed behind a reverse proxy/load balancer
  2. OR use c.Request.RemoteAddr directly to get the actual TCP connection IP
  3. OR explicitly disable proxy headers with router.SetTrustedProxies(nil)

The application appears to be deployed directly (see docker-compose.yml port 3000:3000) without a reverse proxy, so using c.Request.RemoteAddr or disabling proxy headers is recommended.

Copilot uses AI. Check for mistakes.
Comment on lines +729 to 733
// REMOVED: Nonce-based keying
// Nonces must be unique per request (replay attack prevention),
// which creates infinite buckets and memory leaks.
// ALWAYS use IP for now to prevent infinite-bucket attacks
return "ip:" + c.ClientIP()
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

While IP-based rate limiting resolves the infinite bucket attack, it introduces operational concerns for legitimate users behind shared IPs (corporate NAT, carrier-grade NAT, VPNs, etc.). All users behind the same NAT will share a single rate limit bucket.

Consider documenting this limitation in the code comments or README, especially:

  • Users on mobile networks may share IPs with many others
  • Corporate users will be rate limited collectively
  • The rate limits (10 RPM anonymous, 60 RPM standard) may be too restrictive for shared IPs

Potential improvements for future consideration:

  1. Hybrid approach: Use wallet address (from signature recovery) + IP for authenticated requests
  2. Implement a "burst allowance" multiplier for authenticated users to compensate for shared IP scenarios
  3. Add monitoring/logging to detect when single IPs are hitting limits (could indicate shared IP vs actual attack)

Copilot uses AI. Check for mistakes.
Comment on lines +729 to +732
// REMOVED: Nonce-based keying
// Nonces must be unique per request (replay attack prevention),
// which creates infinite buckets and memory leaks.
// ALWAYS use IP for now to prevent infinite-bucket attacks
Copy link

Copilot AI Feb 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The comment "ALWAYS use IP for now" suggests this is a temporary fix. Consider clarifying whether this is intended to be permanent or if there are plans for a more sophisticated approach (e.g., wallet address + IP hybrid for authenticated users).

Adding a TODO or more specific comment about future plans would help maintainers understand the long-term strategy. For example, the issue mentioned a "Tentative" rate limit based on X-Wallet-Address header as a potential alternative.

Copilot uses AI. Check for mistakes.
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
gateway/main_test.go (1)

157-208: ⚠️ Potential issue | 🔴 Critical

TestRateLimitMiddleware_DifferentKeys will fail — test was not updated for IP-based keying

Both "users" are created with http.NewRequest which leaves req.RemoteAddr empty (""). Gin's c.ClientIP() returns "" for both, so getRateLimitKey produces the same key "ip:" for every request in this test. After User 1 exhausts the RATE_LIMIT_STANDARD_BURST=2 quota, User 2's request hits the same empty bucket and receives 429 — but the assertion at line 205 expects 200, causing a test failure.

Fix: differentiate users via RemoteAddr.

🐛 Proposed fix — assign distinct RemoteAddr values
 // User 1 exhausts their limit
 for i := 0; i < 2; i++ {
     req, _ := http.NewRequest("GET", "/test", nil)
+    req.RemoteAddr = "192.168.1.1:1234"
     req.Header.Set("X-402-Signature", "sig1")
-    req.Header.Set("X-402-Nonce", "user1-11111111") // Different first 8 chars
+    req.Header.Set("X-402-Nonce", "user1-11111111")
     ...
 }

 // User 1 should now be rate limited
 req1, _ := http.NewRequest("GET", "/test", nil)
+req1.RemoteAddr = "192.168.1.1:1234"
 req1.Header.Set("X-402-Signature", "sig1")
 req1.Header.Set("X-402-Nonce", "user1-11111111")
 ...

 // User 2 should still be allowed (different IP bucket)
 req2, _ := http.NewRequest("GET", "/test", nil)
+req2.RemoteAddr = "192.168.1.2:1234"
 req2.Header.Set("X-402-Signature", "sig2")
-req2.Header.Set("X-402-Nonce", "user2-22222222") // Different first 8 chars
+req2.Header.Set("X-402-Nonce", "user2-22222222")

Note: this fix only works correctly once SetTrustedProxies(nil) is applied in main.go (as flagged above), otherwise Gin would still read X-Forwarded-For even in tests.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@gateway/main_test.go` around lines 157 - 208, The test
TestRateLimitMiddleware_DifferentKeys is failing because requests created with
http.NewRequest have empty RemoteAddr so getRateLimitKey (via c.ClientIP())
returns the same "ip:" key; fix by assigning distinct RemoteAddr values on each
http.Request used in this test (the two loop requests for user1, the subsequent
req1, and the req2 for user2) so RateLimitMiddleware/getRateLimitKey produce
different buckets per user; also ensure Gin trusted proxies are set
appropriately (SetTrustedProxies(nil) in main.go) so ClientIP uses RemoteAddr in
tests.
🧹 Nitpick comments (1)
gateway/main_test.go (1)

243-265: expectedKey table field is unused — assertion hardcodes "ip:"

The expectedKey string field is declared in the test struct and populated in every case, but the assertion at Line 263 hardcodes "ip:" instead of using tt.expectedKey. The field is dead code.

Either drive the check from the table, or drop the field entirely.

♻️ Proposed refactor
-            if !strings.HasPrefix(key, "ip:") {
-                t.Errorf("Expected IP-based key, got '%s'", key)
+            if !strings.HasPrefix(key, tt.expectedKey) {
+                t.Errorf("Expected key with prefix '%s', got '%s'", tt.expectedKey, key)
             }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@gateway/main_test.go` around lines 243 - 265, The test table declares an
unused expectedKey field but the assertion hardcodes "ip:"; update the test in
gateway/main_test.go to either use tt.expectedKey in the assertion (e.g., check
strings.HasPrefix(key, tt.expectedKey) or compare key to tt.expectedKey as
intended) or remove the expectedKey field from the test struct and table rows
and keep the hardcoded "ip:" check—locate the test that calls getRateLimitKey(c)
and modify the t.Errorf/HasPrefix check accordingly.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@gateway/main.go`:
- Around line 727-734: getRateLimitKey currently relies on c.ClientIP(), which
can be spoofed via X-Forwarded-For unless Gin's trusted proxies are set; update
main() (after r := gin.Default()) to call r.SetTrustedProxies with a safe value
— either nil to disable proxy header parsing (Engine.SetTrustedProxies(nil)) if
no reverse proxy is used, or the specific proxy CIDR(s) (e.g. "10.0.0.0/8") if
behind a known reverse proxy — so ClientIP() returns a reliable source and
prevents per-request unique IP spoofing; leave getRateLimitKey unchanged to
continue using c.ClientIP() once trusted proxies are properly configured.

---

Outside diff comments:
In `@gateway/main_test.go`:
- Around line 157-208: The test TestRateLimitMiddleware_DifferentKeys is failing
because requests created with http.NewRequest have empty RemoteAddr so
getRateLimitKey (via c.ClientIP()) returns the same "ip:" key; fix by assigning
distinct RemoteAddr values on each http.Request used in this test (the two loop
requests for user1, the subsequent req1, and the req2 for user2) so
RateLimitMiddleware/getRateLimitKey produce different buckets per user; also
ensure Gin trusted proxies are set appropriately (SetTrustedProxies(nil) in
main.go) so ClientIP uses RemoteAddr in tests.

---

Nitpick comments:
In `@gateway/main_test.go`:
- Around line 243-265: The test table declares an unused expectedKey field but
the assertion hardcodes "ip:"; update the test in gateway/main_test.go to either
use tt.expectedKey in the assertion (e.g., check strings.HasPrefix(key,
tt.expectedKey) or compare key to tt.expectedKey as intended) or remove the
expectedKey field from the test struct and table rows and keep the hardcoded
"ip:" check—locate the test that calls getRateLimitKey(c) and modify the
t.Errorf/HasPrefix check accordingly.

Comment on lines +727 to 734
// getRateLimitKey determines the key for rate limiting (always uses IP)
func getRateLimitKey(c *gin.Context) string {
signature := c.GetHeader("X-402-Signature")
nonce := c.GetHeader("X-402-Nonce")

// Only use nonce-based key if BOTH signature and nonce are present
// This prevents attackers from bypassing IP rate limits with fake nonces
if signature != "" && nonce != "" {
hash := sha256.Sum256([]byte(nonce))
// Use 32 hex chars (128 bits) for better collision resistance
return "nonce:" + hex.EncodeToString(hash[:])[:32]
}

// REMOVED: Nonce-based keying
// Nonces must be unique per request (replay attack prevention),
// which creates infinite buckets and memory leaks.
// ALWAYS use IP for now to prevent infinite-bucket attacks
return "ip:" + c.ClientIP()
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

c.ClientIP() can be spoofed via X-Forwarded-For, recreating the infinite-bucket attack

TrustedProxies is enabled by default and trusts all proxies by default. Because no SetTrustedProxies call is made in main() (r := gin.Default() at Line 205), ClientIP() will try to parse the headers X-Forwarded-For and X-Real-IP. A client sending X-Forwarded-For: <unique-IP-per-request> generates a distinct key on every call — the same infinite-bucket root cause this PR aims to fix, just through a different attack header.

Gin's own documentation warns: "Gin trust all proxies by default if you don't specify a trusted proxy using the function above, this is NOT safe. At the same time, if you don't use any proxy, you can disable this feature by using Engine.SetTrustedProxies(nil), then Context.ClientIP() will return the remote address directly."

The cleanest remediation is to configure trusted proxies in main():

🛡️ Proposed fix — restrict trusted proxies in main()
 r := gin.Default()
+// Only trust the direct connection IP (RemoteAddr).
+// If behind a known reverse-proxy (e.g. "10.0.0.1"), list it explicitly.
+r.SetTrustedProxies(nil)

If the gateway is legitimately deployed behind a reverse proxy, restrict to the actual proxy CIDR instead of nil (e.g. r.SetTrustedProxies([]string{"10.0.0.0/8"})) so the real client IP is still retrieved correctly without allowing arbitrary spoofing.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@gateway/main.go` around lines 727 - 734, getRateLimitKey currently relies on
c.ClientIP(), which can be spoofed via X-Forwarded-For unless Gin's trusted
proxies are set; update main() (after r := gin.Default()) to call
r.SetTrustedProxies with a safe value — either nil to disable proxy header
parsing (Engine.SetTrustedProxies(nil)) if no reverse proxy is used, or the
specific proxy CIDR(s) (e.g. "10.0.0.0/8") if behind a known reverse proxy — so
ClientIP() returns a reliable source and prevents per-request unique IP
spoofing; leave getRateLimitKey unchanged to continue using c.ClientIP() once
trusted proxies are properly configured.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
gateway/main_test.go (1)

157-208: ⚠️ Potential issue | 🟠 Major

TestRateLimitMiddleware_DifferentKeys is semantically broken after the IP-based keying switch and will fail in CI

The test distinguishes two "users" purely by their X-402-Signature / X-402-Nonce headers — no distinct client IPs are set. With IP-based keying, getRateLimitKey calls c.ClientIP(), which in turn reads req.RemoteAddr (empty for http.NewRequest) and the X-Forwarded-For / X-Real-IP headers (neither is set here). Gin resolves both "users" to the same IP, placing them in the same rate-limit bucket.

Consequence: after User 1 exhausts RATE_LIMIT_STANDARD_BURST=2, User 2's first request hits the same depleted bucket and receives a 429 — not the 200 asserted at line 205 — causing a CI failure.

The comment on line 181 (// Different first 8 chars) is also a stale artifact of the old nonce-key prefix slicing logic.

Fix: set distinct X-Forwarded-For addresses for each simulated user so they actually map to different IP-based buckets.

🐛 Proposed fix
-	// User 1 exhausts their limit
-	for i := 0; i < 2; i++ {
-		req, _ := http.NewRequest("GET", "/test", nil)
-		req.Header.Set("X-402-Signature", "sig1")
-		req.Header.Set("X-402-Nonce", "user1-11111111") // Different first 8 chars
-		w := httptest.NewRecorder()
+	// User 1 (IP 10.0.0.1) exhausts their limit
+	for i := 0; i < 2; i++ {
+		req, _ := http.NewRequest("GET", "/test", nil)
+		req.Header.Set("X-Forwarded-For", "10.0.0.1")
+		w := httptest.NewRecorder()
 		r.ServeHTTP(w, req)
 		if w.Code != 200 {
 			t.Errorf("User 1 request %d should succeed", i+1)
 		}
 	}
 
 	// User 1 should now be rate limited
 	req1, _ := http.NewRequest("GET", "/test", nil)
-	req1.Header.Set("X-402-Signature", "sig1")
-	req1.Header.Set("X-402-Nonce", "user1-11111111")
+	req1.Header.Set("X-Forwarded-For", "10.0.0.1")
 	w1 := httptest.NewRecorder()
 	r.ServeHTTP(w1, req1)
 	if w1.Code != 429 {
 		t.Error("User 1 should be rate limited")
 	}
 
-	// User 2 should still be allowed (different bucket)
+	// User 2 (IP 10.0.0.2) should still be allowed (different IP bucket)
 	req2, _ := http.NewRequest("GET", "/test", nil)
-	req2.Header.Set("X-402-Signature", "sig2")
-	req2.Header.Set("X-402-Nonce", "user2-22222222") // Different first 8 chars
+	req2.Header.Set("X-Forwarded-For", "10.0.0.2")
 	w2 := httptest.NewRecorder()
 	r.ServeHTTP(w2, req2)
 	if w2.Code != 200 {
 		t.Error("User 2 should not be rate limited (separate bucket)")
 	}

Verify what c.ClientIP() returns in gin when req.RemoteAddr is unset and no forwarding headers are present:

#!/bin/bash
# Check gin's ClientIP resolution when RemoteAddr is empty and no forwarding headers are set.
rg -n "ClientIP\|RemoteIP\|RemoteAddr\|TrustedProxies\|ForwardedByClientIP" --type go -A3 -B3
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@gateway/main_test.go` around lines 157 - 208, The test
TestRateLimitMiddleware_DifferentKeys fails after switching to IP-based keying
because getRateLimitKey uses c.ClientIP(), and the http.NewRequest calls don't
set RemoteAddr or forwarding headers so both requests map to the same IP; update
the test to set distinct X-Forwarded-For (or X-Real-IP) headers on the two
simulated users' requests (e.g., "1.2.3.4" vs "5.6.7.8") so each maps to a
different rate-limit bucket and the assertions expecting separate behavior pass.
🧹 Nitpick comments (1)
gateway/main_test.go (1)

243-265: expectedKey field is dead code — assertion ignores it

The test struct at lines 247–252 declares and populates expectedKey, but the loop body (line 263) hardcodes the prefix check strings.HasPrefix(key, "ip:") instead of using tt.expectedKey. The field has no effect on test outcome. Either use it in the assertion or drop it from the struct entirely.

♻️ Use `tt.expectedKey` in the assertion (and drop the struct field if it stays constant)
 			key := getRateLimitKey(c)
-
-			// After fix: All keys should be IP-based to prevent
-			// infinite bucket attacks from unique nonces
-			if !strings.HasPrefix(key, "ip:") {
-				t.Errorf("Expected IP-based key, got '%s'", key)
+			if !strings.HasPrefix(key, tt.expectedKey) {
+				t.Errorf("Expected key with prefix '%s', got '%s'", tt.expectedKey, key)
 			}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@gateway/main_test.go` around lines 243 - 265, The test struct defines
expectedKey but the assertion always hardcodes "ip:", so expectedKey is unused;
update the test loop to assert against tt.expectedKey (e.g., replace
strings.HasPrefix(key, "ip:") with strings.HasPrefix(key, tt.expectedKey) or an
equality check as appropriate) when calling getRateLimitKey, and if expectedKey
is the same for all cases you can remove the field and keep a single literal;
reference the tests slice and getRateLimitKey to locate where to change the
assertion.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@gateway/main_test.go`:
- Around line 157-208: The test TestRateLimitMiddleware_DifferentKeys fails
after switching to IP-based keying because getRateLimitKey uses c.ClientIP(),
and the http.NewRequest calls don't set RemoteAddr or forwarding headers so both
requests map to the same IP; update the test to set distinct X-Forwarded-For (or
X-Real-IP) headers on the two simulated users' requests (e.g., "1.2.3.4" vs
"5.6.7.8") so each maps to a different rate-limit bucket and the assertions
expecting separate behavior pass.

---

Nitpick comments:
In `@gateway/main_test.go`:
- Around line 243-265: The test struct defines expectedKey but the assertion
always hardcodes "ip:", so expectedKey is unused; update the test loop to assert
against tt.expectedKey (e.g., replace strings.HasPrefix(key, "ip:") with
strings.HasPrefix(key, tt.expectedKey) or an equality check as appropriate) when
calling getRateLimitKey, and if expectedKey is the same for all cases you can
remove the field and keep a single literal; reference the tests slice and
getRateLimitKey to locate where to change the assertion.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Fix Broken Rate Limiting Strategy (Nonce vs IP)

1 participant