feat: rate limit per peer to mitigate decrypt burst - #353
feat: rate limit per peer to mitigate decrypt burst#353otimizeinformatica wants to merge 2 commits into
Conversation
WhatsApp sender key rotation on active groups causes msgs to arrive with iterations far ahead of the current sender chain. libsignal-go/groups/GroupCipher.go:getSenderKey ratchets forward up to 2000 steps per message; when many messages from the same peer arrive concurrently, CPU/memory saturate and long payloads get truncated during decrypt. This patch adds a per-peer token bucket rate limiter as a defensive layer in the wuzapi event handler. It does not touch libsignal or whatsmeow — just gates events.Message at the wuzapi wrapper level. ## Changes - ratelimit.go (new): token bucket with sync.Once lazy init, configured via env vars - wmiau.go: guard added at the top of case *events.Message ## Configuration - RATE_LIMIT_PEER_MSG_PER_SEC (default 20) — sustained rate per peer - RATE_LIMIT_PEER_ENABLED (default true) — set false to disable - Capacity = rate * 2 (burst tolerance of 2s) - Peer key = Sender JID (or Chat|Sender for groups) ## Behavior - Peers within limit: unaffected - Peer that exceeds: excess msgs dropped silently, warn log emitted with msg_id + peer for observability - Legitimate conversation (< 5 msgs/sec) always passes - Bombardment during sender key rotation (>50 msgs/sec) is flattened ## Notes This is a defensive layer, not a root cause fix. The underlying issue is in libsignal-go — the ratchet forward loop lacks a cost bound. A proper fix would add a per-sender rate limit in getSenderKey itself, but that requires forking libsignal, whatsmeow and wuzapi. This PR chooses the minimally invasive path (single fork, non-crypto code, easily reversible). Ideally the maintainers of tulir/libsignal or tulir/whatsmeow would accept an upstream fix at the crypto layer, at which point this wrapper-level guard could be removed.
|
Hi @otimizeinformatica , thanks for your contribution. Forking or changing libsignal or whatsmeow is out of the question so I think your approach is fine. Let me ask you, what happens if the rate limit kicks in in those busy groups? Does meta retries silently? Does it disrupt or breaks anything? |
|
Great question, and you nailed the weakest point of this v1 design. Let me be upfront about what actually happens today: Current behavior on dropThe
This is fine for the pathological "sender-key rotation storm" case where dropping is objectively better than crashing, but it's obviously wrong for legitimate high-traffic groups. Both hit the same bucket right now. What I'll change (v2)Instead of dropping, enqueue in memory per peer and drain at the configured rate from a goroutine. Concretely:
Trade-off is latency: bursty messages get processed serially with a small delay rather than all-at-once. That's the whole point — we're smoothing the cost curve of No disruption to normal trafficDefault rate (20 msg/s per peer) is well above any real conversation. Legit chat < 5 msg/s is untouched. Only sender-key-rotation bombardments (typically 50-200 msg/s from a single sender chain) hit the flow control. Will push v2 shortly. Happy to keep the drop-fallback behind a flag if you'd prefer a stricter "never lose a message" default. |
|
Thanks for the feedback! I've implemented v2 that enqueues rather than drops. Branch: ApproachInstead of a separate queue+dispatcher goroutine per peer, I leveraged the fact that whatsmeow spawns a goroutine per event. The rate limiter now blocks the peer's goroutine on the slow path when tokens are unavailable, effectively creating natural backpressure without buffering messages in userspace or losing them below the timeout threshold.
Why not a dispatcher goroutine per peerI considered
Blocking is a smaller change to the handler surface, requires no additional goroutines except one for stats, and has zero risk of message loss below the timeout threshold. Config (new env vars)Legacy TestsAdded
All 7 tests pass with Canary statusv2 is currently running on an isolated test container against a low-traffic chip since 2026-08-18. Happy to iterate if you'd like different defaults, log formats, or extra tests. |
Problem
On active WhatsApp groups, sender key rotation causes messages to arrive with encryption iterations far ahead of the current sender chain. The libsignal-go library
groups/GroupCipher.go:getSenderKeyratchets forward up to 2000 steps per message to catch up.When many messages from the same peer arrive concurrently, this can result in ~200k crypto operations in seconds. CPU/memory saturate and long message payloads get truncated during decrypt — we've observed real user reports of messages arriving with truncated bodies (only the URL kept, description dropped).
Root symptom in production: p50 latency wa→db of 14s, p90 of 850s, max of 3000s during burst events. Recovery only via container restart.
Solution
Per-peer token bucket rate limiter as a defensive layer at the wuzapi wrapper level. No changes to libsignal or whatsmeow.
Changes
ratelimit.gonew: token bucket,sync.Oncelazy init, configured via envwmiau.go: guard at the top ofcase *events.MessageConfiguration
RATE_LIMIT_PEER_MSG_PER_SECRATE_LIMIT_PEER_ENABLEDCapacity = rate × 2 burst tolerance 2s. Peer key = Sender JID or Chat|Sender for groups.
Behavior
msg_idandpeerfor observabilityNot a root cause fix
The underlying issue is in libsignal-go —
getSenderKeylacks a cost bound. A proper fix would add a per-sender rate limit in the crypto layer itself. That path requires forking libsignal, whatsmeow, and wuzapi.This PR chooses the minimally invasive path: single fork, non-crypto code, easily reversible via env flag. Happy to abandon this if a crypto-layer fix lands upstream in libsignal-go.
Test plan
Open as draft while we validate in canary mode.