Skip to content

Add per-IP rate limit and body size caps to relay endpoints#39

Merged
json9512 merged 1 commit into
mainfrom
fix/issue-6-rate-limit-body-cap
May 13, 2026
Merged

Add per-IP rate limit and body size caps to relay endpoints#39
json9512 merged 1 commit into
mainfrom
fix/issue-6-rate-limit-body-cap

Conversation

@json9512
Copy link
Copy Markdown
Owner

Summary

  • Per-IP rate limit — new rate.go implements an in-process token-bucket limiter via golang.org/x/time/rate (5 rps, burst 10). Stale entries (>10 min lastSeen) get swept every 5 min. /subscribe and /n/<id> register through the middleware; over-cap returns 429. Cloud Run's max-instances=3 means the effective ceiling is 15 rps total — fine for the abuse vectors (subscriber spam, leaked-URL spam), which require orders of magnitude more to be effective.
  • Client IP extractionclientIP reads the leftmost X-Forwarded-For entry (Cloud Run sets this) and falls back to r.RemoteAddr for local/direct requests.
  • Body size capshttp.MaxBytesReader wraps r.Body in both handlers: 1 KiB on /subscribe, 4 KiB on /n/<id> (APNs payload max). Over-cap returns 413 instead of falling through to the generic 400.

Deliberately deferred (filed as #38)

Notify-URL rotation. Largest scope-per-payoff (Store interface change + iOS button + confirmation flow + agent re-setup prompt re-copy) for the smallest threat — rate limit + body cap already cap the damage from a leaked URL. Recovery today remains uninstall+reinstall. Tracked as a follow-up.

Test plan

  • go test ./... from server/sshido-relay/ — 7/7 pass:
    • TestSubscribeBodyCap, TestNotifyBodyCap — 2 MiB valid-JSON bodies return 413
    • TestSubscribeHappyPath — normal flow still returns 200 with the expected body shape
    • TestRateLimitReturns429 — 10 rapid requests yield a mix of 200 (burst) and 429 (throttled)
    • TestRateLimitIsPerIP — independent buckets per source IP
    • TestClientIPParsesXForwardedFor, TestClientIPFallsBackToRemoteAddr — IP extraction
  • Manual after redeploy: for i in $(seq 1 20); do curl -s -o /dev/null -w "%{http_code}\n" -X POST https://push.sshido.com/n/nonexistent; done | sort | uniq -c should show some 429s; a 2 MiB body to /subscribe should return 413.

Implementation notes

  • The body-cap tests use valid-JSON oversized bodies ({"deviceToken": "<2MiB of a>"}) rather than junk bytes: a bare aaaa... body trips the JSON syntax error before MaxBytesReader gets a chance to fire.
  • The rate limiter sweep runs as a goroutine started by newIPLimiter. The sweep tick is 5 minutes, TTL 10 minutes — so an idle IP's entry gets garbage-collected within ~15 min. Fine for memory; Cloud Run instances rarely live long enough to accumulate problem-scale state.

Closes #6.

🤖 Generated with Claude Code

Before this change /subscribe and /n/<id> had no per-IP throttling, no
body cap, and a bare json.NewDecoder on the request body. Three concrete
attacks per issue #6:

1. Subscriber-spam — POST random device tokens to /subscribe → unbounded
   Firestore writes → operator billing exposure.
2. Notify-URL pwning — anyone holding a leaked notify URL spams pushes,
   each one costing a Cloud Run invocation + Firestore write + APNs
   call.
3. Memory pressure — a multi-MiB JSON body could OOM the 256Mi Cloud
   Run instance before the decoder finishes parsing.

Mitigations land here:

- New rate.go owns an in-process per-IP token bucket
  (`golang.org/x/time/rate`, 5 rps with burst 10). Stale entries
  (>10min lastSeen) are swept every 5min. The /subscribe and /n/<id>
  routes register through `limiter.middleware`; over-cap returns 429.
- Cloud Run's max-instances=3 means the actual ceiling is 15 rps total,
  not 5 — fine for the abuse vectors, which need orders of magnitude
  more to be effective. `clientIP` reads the leftmost X-Forwarded-For
  entry (Cloud Run sets this) and falls back to r.RemoteAddr.
- http.MaxBytesReader wraps r.Body in both handlers: 1 KiB on
  /subscribe (deviceToken is ~64 hex chars), 4 KiB on /n/<id> (APNs
  payload max). Over-cap now returns 413 (RequestEntityTooLarge)
  rather than falling through to the generic 400 "bad body".

Tests (relay_test.go) cover:
- /subscribe and /n/<id> with 2 MiB valid-JSON bodies return 413
  (junk-byte garbage trips a syntax error before MaxBytesReader gets
  a chance, so the test builds realistic oversized JSON instead).
- 10 rapid requests from the same source IP yield both 200s (burst
  capacity) and 429s (after exhaustion).
- Two different source IPs maintain independent buckets — one being
  throttled doesn't affect the other.
- clientIP parses X-Forwarded-For chains correctly and falls back to
  r.RemoteAddr when the header is absent.

Notify-URL rotation (the issue's third suggested fix) is deliberately
deferred: it's the largest scope-per-payoff piece (Store interface
change + iOS UI button + confirmation flow + agent re-setup prompt
re-copy) for the smallest threat (a leaked URL can be spammed but
the rate limit + body cap already cap the damage). Recovery today
remains uninstall + reinstall. Filed as a follow-up issue.

Closes #6.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@json9512 json9512 merged commit 7bee889 into main May 13, 2026
@json9512 json9512 deleted the fix/issue-6-rate-limit-body-cap branch May 13, 2026 02:35
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.

[High] Relay /subscribe and /n/<id> have no auth, rate limit, or body size cap

1 participant