This document describes the Web Application Firewall (WAF) and the distributed per-API-key rate limiter that protect the LedgerLens API.
The implementation includes two main components:
- WAF Middleware: Protects against common web attacks (SQLi, XSS, oversized bodies, slowloris)
- Distributed Rate Limiting: Enforces per-key request-per-minute limits against a single, shared source of truth — correct across horizontally-scaled REST replicas and the separate gRPC process (see "Distributed Rate Limiting" below)
Both components are designed to be lightweight, configurable, and fail-open to avoid self-inflicted DoS.
The WAF middleware is implemented in api/waf_middleware.py and provides the following protections:
- SQL Injection (SQLi) Protection: Blocks requests with known SQLi patterns in query parameters and JSON bodies
- Cross-Site Scripting (XSS) Protection: Blocks requests with known XSS patterns in query parameters and JSON bodies
- Oversized Body Protection: Rejects requests with bodies larger than
WAF_MAX_BODY_BYTES - Slowloris Protection: Times out slow requests (taking longer than
WAF_SLOW_REQUEST_TIMEOUT_SECONDSto send the body) - Fail-Open Design: If the WAF encounters an internal error, it logs the error and allows the request to proceed
- Wallet Address Masking: Blocked request logs have wallet addresses masked to comply with privacy requirements
The WAF is configurable via these environment variables (defined in config/settings.py and .env.example):
WAF_ENABLED: Enable/disable the WAF (default: true)WAF_MAX_BODY_BYTES: Maximum allowed request body size in bytes (default: 1048576 / 1MB)WAF_SLOW_REQUEST_TIMEOUT_SECONDS: Timeout for slow requests in seconds (default: 10)
The WAF middleware is automatically added to the FastAPI application in api/main.py and runs before other middleware.
GET /admin/waf/blocked-requests: Returns recent requests blocked by the WAF (admin-key gated)
Before this fix, "per-API-key rate limiting" was actually three independent, non-communicating in-process counters:
| Enforcement path | Counter | Reachable from a real request? |
|---|---|---|
GatewayMiddleware._check_quota (api/gateway.py) |
its own _rate_windows dict |
No — no route in api/main.py carries the @scope_required/@ScopedRoute annotation ann() requires, so this check never fired on any live route (dead-but-not-obviously-so; the module worked, it was just never reached). Fixed regardless, since it's the long-term intended mechanism per docs/api_gateway.md and any newly-annotated route must inherit the distributed fix automatically. |
api/api_key_router.py's require_scope dependency (the actual live REST enforcement on e.g. GET /v1/scores/{wallet}) |
detection.api_key_store's _rate_windows dict |
Yes |
api/grpc_scoring_service.py's _authenticate |
the same detection.api_key_store dict, but in a separate OS process (cli.py serve-grpc, not the uvicorn api.main:app process) |
Yes |
Two compounding problems fell out of this:
- REST and gRPC don't share a process, so even though both called the same Python function name, each ran against its own copy of the module-level dict — a client alternating between REST and gRPC got up to ~2x its configured budget in a single logical deployment.
- None of the in-process dicts survive horizontal scaling. The
project's own documented topology (
helm/ledgerlens/values.yaml:replicaCount: 2, autoscaling tomaxReplicas: 10,docs/kubernetes_deployment.md) runs multiple independent REST pods. Each pod's dict is invisible to every other pod, so the real ceiling for one key wasconfigured_limit x N_processes_touching_that_key— up tolimit x 20(10 REST replicas + gRPC) with no cross-replica visibility and no alerting, since each replica individually believed it was correctly enforcing the limit.
Separately, api/adaptive_rate_limiter.py ("tighten a key's limit after
repeated 4xx/WAF-block signals") was removed, not fixed. It was
unreachable from any real request (its only caller, api/auth.py's
require_api_key_scope, was itself dead code — never imported by any
router) and was independently broken: it referenced three functions
(_check_rate_limit_redis, _check_rate_limit_local, _rate_check) that
were never defined anywhere, so invoking it would have raised NameError.
Rewiring it into the real request path would have meant standing up a
second, parallel, per-process abuse-signal counter with the exact same
distributed-state problem this fix solves for the primary limiter — and its
original purpose (tightening a key after it racks up 4xx responses) is
largely subsumed once the primary limiter itself correctly and strictly
enforces the configured limit across every replica and protocol. Removal
was the simpler, more maintainable choice; see api/auth.py's module
docstring for the same rationale in code.
Implemented in detection/rate_limiter.py, and consumed by every enforcement
path through detection.api_key_store.check_rate_limit(key_id, limit) — the
single, canonical, shared function called by api/gateway.py,
api/api_key_router.py, and api/grpc_scoring_service.py alike. There is
exactly one enforcement primitive now, not three.
Algorithm: sliding-window counter (two fixed 60s buckets — current and
previous — combined with a linear time-weight), executed as a single atomic
Redis Lua script (EVAL) per check: one round trip, one GET+GET+INCR
executed atomically so there is no INCR/EXPIRE race window. This was chosen
over the alternatives:
- Sliding window log (a Redis ZSET holding every request timestamp) is
exactly accurate but costs O(limit) memory per key and an O(log N)
operation touching potentially thousands of members for a high-limit key —
disproportionate cost for an abuse-prevention control (this is not a
billing-grade metering system; daily/monthly quotas already get exact
accounting via SQLite
COUNTqueries). - Token bucket gives equivalent guarantees but needs fractional token + last-refill-timestamp state and isn't meaningfully simpler or cheaper in Lua than the counter approach.
- Sliding window counter (chosen): O(1) memory (two integers + TTL) and exactly one round trip per check. Worst-case overshoot is mathematically bounded at 2x the configured limit — a full burst at the very end of one window immediately followed by a full burst at the very start of the next; any traffic not adversarially timed to that exact boundary sees much tighter overshoot. This is the standard approach documented in Cloudflare's and Stripe's public rate-limiting engineering writeups and is an appropriate accuracy/cost trade for a per-minute abuse control.
Keys are hash-tagged (ll:ratelimit:{key_id}:<bucket>) so a Redis Cluster
deployment routes both keys of one check to the same slot — required for the
multi-key Lua script to work under Redis Cluster.
Unlike GatewayMiddleware's auth/quota-backend-unreachable path (503, fails
closed, because an unreachable backend there means the caller's identity
cannot be verified at all), an unreachable Redis here degrades — it does not
block traffic. Rate limiting is a cost/abuse control, not an authentication
boundary: taking the whole API down because the shared rate-limit store is
briefly unavailable would be a worse, self-inflicted denial of service than
a temporarily-generous limit. This matches this codebase's existing WAF and
FeatureStore precedent (both fail open).
The fallback is not silent, though:
- A
utils.circuit_breaker.CircuitBreakerguards every Redis call. After 3 consecutive failures it opens (stops attempting Redis for 15s, then probes once) so a Redis outage degrades to per-process-only enforcement — exactly today's pre-fix behavior, never worse — rather than adding latency or failing requests. - Every open/close transition is logged at
WARNING/INFO(ledgerlens.rate_limiterlogger). - Every check served from the fallback increments
ledgerlens_rate_limiter_fallback_total(Prometheus counter). Sustained non-zero values mean cross-replica/cross-protocol enforcement is currently degraded to per-process-only — this is the metric to alert on.
GATEWAY_QUOTA_STORE (also drives the daily/monthly SQLite quota checks,
unchanged by this fix):
redis(default): use the shared, distributed limiter — required for correct enforcement under this project's documented multi-replica topology and the REST/gRPC process split. Falls back to a local in-process window (logged + metered) if Redis is unreachable.sqlite: explicit opt-out — always use the local in-process window, never attempt Redis. Only correct for a genuine single-process deployment (e.g. local dev); the name is kept for backward compatibility with the pre-existing setting.
REDIS_URL is the same setting already used by detection/feature_store.py
— no second, independently-configured Redis client is introduced.
- Daily/monthly quotas (
detection.api_key_store.check_daily_quota/check_monthly_quota) still count rows in thegateway_request_logSQLite table. Under this project's Helm chart, that table lives on aReadWriteOncePVC, which does not aggregate across multiple replica pods either — a real, separate multi-replica correctness gap, but one that requires a different fix (a shared durable counter store or moving this accounting off SQLite entirely), not the per-minute enforcement mechanism this fix addresses. Flagged here as a known limitation, not silently left unaddressed. api/api_keys_router.py(the fully-deprecated legacy router, sunset 2027-01-31) keeps its own independent, SHA-256-keyed, in-process-only rate limiter untouched — it is a structurally separate key namespace (different hash algorithm, different table population) already scheduled for removal, not one of the three counters this issue was scoped against.
Prometheus metrics exposed (defined in api/metrics.py):
ledgerlens_waf_blocks_total{rule, namespace_id}: Total number of requests blocked by the WAF, grouped by rule and namespaceledgerlens_rate_limiter_checks_total{backend}: Total per-key rate-limit checks, split bybackend="redis"(shared) vsbackend="local"(degraded fallback)ledgerlens_rate_limiter_fallback_total: Total checks served from the local fallback because Redis was unavailable — alert on sustained non-zero values
A new alert rule is added in monitoring/alerts.yml:
WAFAbuseDetected: Triggers when more than 10 requests are blocked by the WAF in 5 minutes
For production deployments, consider using an ingress-level WAF like ModSecurity with the OWASP Core Rule Set (CRS) instead of, or in addition to, the in-process WAF. This provides more comprehensive protection and offloads processing from the application.
server {
listen 80;
server_name api.ledgerlens.io;
# Enable ModSecurity
modsecurity on;
modsecurity_rules_file /etc/nginx/modsecurity.conf;
location / {
proxy_pass http://localhost:8000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}WAF tests are in tests/test_waf_middleware.py and cover:
- SQLi/XSS blocking
- Benign request allowance
- Oversized body blocking
Distributed rate limiter tests:
tests/test_rate_limiter.py— atomic correctness under concurrency, the bounded-overshoot guarantee, fail-open fallback (logged warning + metric), theGATEWAY_QUOTA_STORE=sqliteopt-out, and a two-independent-OS-process test (viafakeredis.TcpFakeServer, a real TCP-listening fake Redis one process's client can't distinguish from the real thing) proving combined cross-replica throughput does not exceed the configured limit.tests/test_rate_limit_cross_protocol.py— reproduces, then refutes, the same-process REST-vs-gRPC ~2x bypass by alternating real REST (TestClient) and real gRPC (ScoringServiceStub) calls against one API key and asserting the combined allowed count equals the configured limit.tests/test_grpc_scoring_service.py::test_rate_limit_exceeded_shared_counter— gRPC-only regression coverage for the same shared counter.
To run the tests:
pytest tests/test_waf_middleware.py tests/test_rate_limiter.py \
tests/test_rate_limit_cross_protocol.py tests/test_grpc_scoring_service.py- The WAF is a defense-in-depth measure, not a substitute for proper input validation and sanitization
- Signature-based detection can have false positives; monitor blocked requests and tune if needed
- The WAF fails open to avoid self-inflicted DoS; monitor logs for internal errors
- All blocked request logs have wallet addresses masked using the existing masking logic from
config.correlation - The rate limiter's fail-open fallback is a deliberate availability/strictness tradeoff, not a bypass: a client cannot induce it (it only engages when Redis itself is actually unreachable), and it never grants more than this codebase's pre-fix per-process behavior — see "Distributed Rate Limiting" above.