fix(api): rate-limit /ready and /metrics, expose db pool telemetry - #319
Conversation
Closes the remaining scope of openshield-org#296. The connection-checkout leak itself was already fixed by openshield-org#306 (g.db + the teardown handler, with a real PostgreSQL-backed integration test proving connections balance across repeated readiness probes). What was still open: /ready and /metrics are unauthenticated by design (probe/scrape endpoints must never require a token), which also made them the one place an unauthenticated caller could trigger repeated pooled-connection work with no rate limiting at all, and there was no visibility into how close the pool was to exhaustion before it happened. - Add api.observability.probe_rate_limit: an in-memory, per-process, per-source-IP rate limiter for probe/scrape endpoints. Deliberately not the existing Postgres-backed api.rate_limit.rate_limit, which would add a database round trip (and a second, separately-tracked pooled connection under its own g.db) to the exact endpoint whose job is to protect the database from overload. The connection pool it guards is itself process-local under Gunicorn's multi-worker model, so a per-process budget is the matching granularity, not a weaker substitute for a shared one. Wired onto /ready (budget of 5 per 10s per source IP, half the default DB_POOL_MAX_CONN) and /metrics (20 per 10s, generous for normal Prometheus scrape intervals). The check runs before the view body, so a rejected request never reaches the database work it would otherwise trigger. - Add api.models.finding.get_pool_stats(): a point-in-time snapshot of the shared pool's in-use/idle/max-connection counts and utilization percentage. Reports only counts - never the DSN, host, or credentials - so it's safe on a public surface. Returns zeroed stats before any connection has been made instead of raising. - Wire get_pool_stats() into three new Prometheus gauges (openshield_db_pool_connections_in_use/idle/max), refreshed lazily on every /metrics scrape via a provider callback registered from api/app.py - api/observability.py stays free of project imports (its own documented constraint, since the worker reuses it too) by never importing api.models.finding directly. - Document in docs/deployment/render.md that Render's Blueprint format has no path-based access control, so the in-app rate limiter is a defense-in-depth backstop, not a substitute for restricting network reachability to /ready and /metrics at whatever reverse proxy/CDN/ WAF fronts a real deployment - that configuration is operational, outside what render.yaml can express. New tests in tests/test_readiness_hardening.py cover: the rate limiter's budget/window/per-IP-isolation/testing-bypass/key-pruning behavior in isolation, /ready actually rejecting a source once its budget is spent without touching the database for the rejected request, get_pool_stats()'s zero/nonzero/never-leaks-the-dsn behavior, and /metrics rendering the three new gauges (and surviving a broken stats provider without failing the whole scrape). Verified: full backend suite (796 passed, 3 skipped - pre-existing, unrelated), including tests/test_observability.py's real PostgreSQL-backed readiness-leak test run against a local Postgres instance to confirm the new decorator doesn't disturb openshield-org#306's fix; ruff check and format --check clean. Signed-off-by: Parth J Rohit <parthrohit60@gmail.com> Signed-off-by: parthrohit22 <parthrohit60@gmail.com>
m-khan-97
left a comment
There was a problem hiding this comment.
Parth, the overall direction is sound: keeping probe limiting out of PostgreSQL is the right boundary, rejected /ready calls do avoid the database, and the pool metrics expose counts rather than connection details. I also checked the current CI head; all 20 checks are green.
There is one blocking denial-of-service gap in the limiter itself. _probe_hits only removes an expired entry when that exact (remote_addr, path) key is requested again. A caller that continuously rotates source addresses—or spoofed forwarded addresses anywhere the trusted-proxy boundary is misconfigured—creates a new dictionary entry per address, and those one-shot keys are never revisited or removed. The new unauthenticated protection can therefore become an unbounded process-memory sink.
Please add bounded global cleanup rather than only same-key cleanup: for example, periodically sweep expired deques under the lock and enforce a hard maximum number of tracked keys with deterministic eviction. Add a regression test that inserts many distinct one-shot addresses, advances past the window, triggers cleanup through a different address, and proves stale keys are removed and the map remains capped. A Retry-After header on 429 would also make the endpoint friendlier to legitimate probes, but I do not consider that blocking.
Once the state is globally bounded, I will re-review promptly.
Two fixes on top of the dev merge that landed on this branch: 1. The merge into dev (which now carries openshield-org#294/openshield-org#320's own changes to api/app.py) dropped this branch's _READY_MAX_REQUESTS_PER_WINDOW constant definition while keeping its usage on the /ready route, leaving api/app.py with an undefined name that only surfaced at create_app() call time (ruff's F821 caught it as CI's first failure; Backend Tests failed for the same underlying reason). Restored the constant and its comment. 2. m-khan-97's review: probe_rate_limit's per-key cleanup only ever prunes the exact (address, path) key the current request touches. A caller that continuously rotates its source address - or a spoofed forwarded address wherever the trusted-proxy boundary is misconfigured - creates a new one-shot dictionary entry per address that's never revisited and therefore never pruned, making the limiter's own tracking dict an unbounded memory sink. Fixed with the two things asked for: - A periodic global sweep (every _PROBE_SWEEP_INTERVAL calls, not every call - a full-dict scan per request would defeat the point of a cheap in-memory limiter) that prunes every key whose hits have all expired, not just the current request's key. - A hard cap (_PROBE_MAX_TRACKED_KEYS) on distinct tracked keys, with deterministic least-recently-touched eviction via an OrderedDict instead of the previous plain dict - every hit (including one that just survives a sweep) moves its key to the end, so eviction always drops the coldest entry first. Also added the suggested (non-blocking) Retry-After header on 429. New tests in tests/test_readiness_hardening.py cover exactly the scenario m-khan-97 described: many distinct one-shot addresses, advance past the window, trigger cleanup through different addresses, and prove the stale keys are gone and the map stays bounded. Plus direct hard-cap/LRU-eviction tests and the Retry-After header. Verified: full backend suite (862 passed, 5 skipped - pre-existing/ environment-only), including all 17 tests in tests/test_readiness_hardening.py and the pre-existing tests/test_auth.py / tests/test_observability.py suites unaffected. ruff check and format --check clean. Signed-off-by: Parth J Rohit <parthrohit60@gmail.com> Signed-off-by: parthrohit22 <parthrohit60@gmail.com>
|
@m-khan-97 Fixed, in two parts:
New tests cover exactly the scenario you described: many distinct one-shot addresses, advance past the window, trigger cleanup through different addresses, and prove the stale keys are gone and the map stays bounded — plus direct hard-cap/LRU-eviction tests and the Verified: full backend suite (862 passed, 5 skipped — pre-existing/environment-only), all 17 tests in the readiness-hardening file including the 5 new ones, ruff clean. All 20 CI checks are green on the current head. Ready for another look whenever you have a chance. |
m-khan-97
left a comment
There was a problem hiding this comment.
Parth, I verified the current head rather than relying on the update summary. The blocker is closed: stale one-shot source keys are now swept globally, tracked state has a hard 10,000-key ceiling, eviction is deterministic and activity-aware, and the rejected response includes Retry-After. The regression suite covers rotating addresses, global expiry cleanup, hard-cap behavior, LRU touch ordering, and the response header. The merge with #320 also restored the readiness budget constant correctly, and all 20 checks pass. Approving.
|
@ritiksah141 @TFT444, I completed the blocker rereview and approved |
TFT444
left a comment
There was a problem hiding this comment.
BLOCKER — LRU eviction bypass: move_to_end(key) is called unconditionally on every request including rejected ones, keeping an attacker's slot permanently warm while legitimate monitoring IPs that go quiet drift toward eviction. Once a legitimate IP's entry is evicted its hit history resets and it gets a fresh budget; the attacker's key never ages out — directly defeating the eviction-based memory cap; guard move_to_end(key) with if allowed.
BLOCKER — Shared sweep uses wrong window: _probe_hits is a module-level dict shared across all decorated endpoints, but the sweep passes the triggering endpoint's window_seconds closure to _sweep_expired_probe_hits, applying it to every key including entries owned by endpoints with different windows. Both endpoints currently default to 10 s so the bug is dormant, but the first non-default-window endpoint added will cause premature budget resets or lingering stale entries across all endpoints — store window_seconds per key so the sweep applies the correct cutoff per entry.
MAJOR — ProxyFix(x_for=1) makes remote_addr fully attacker-controlled if the origin is reachable directly (Render origin IPs surface in CT logs and Shodan); an attacker sending arbitrary X-Forwarded-For headers gets a fresh rate-limit bucket on every request, fully neutralising per-IP enforcement — add an explicit code comment stating the trusted-proxy assumption and what fails when it is violated.
What does this PR do?
Closes the remaining scope of #296 (readiness-endpoint pool exhaustion): rate-limits the unauthenticated
/readyand/metricsprobe/scrape endpoints per source IP, and exposes database connection pool utilization/exhaustion telemetry on/metrics.Type of change
Scope note
#306 already fixed the core connection-checkout leak (
g.db+ the existingclose_dbteardown handler) and added/healthas a DB-free liveness probe, with a real PostgreSQL-backed integration test (test_ready_returns_every_real_connection_to_the_pool) proving checkout/return counts stay balanced across repeated readiness probes, including on the failure path. That work is not touched or duplicated here. This PR picks up the two acceptance criteria from #296 that were still open after #306:/ready//metricsat the edge, while keeping an appropriate liveness endpoint./healthalready stays public and DB-free (unchanged). Render's Blueprint format has no path-based access control, so this repository can't express "restrict this path" inrender.yamlitself — documented that indocs/deployment/render.mdunder a new "Restricting probe/scrape endpoints at the edge" section, with the operational recommendation for whoever runs a reverse proxy/CDN/WAF in front of a real deployment. As the in-app backstop, addedapi.observability.probe_rate_limit: an in-memory, per-process, per-source-IP rate limiter, wired onto/ready(5 requests/10s — half the defaultDB_POOL_MAX_CONN) and/metrics(20/10s, generous for normal Prometheus scrape intervals). Deliberately not the existing Postgres-backedapi.rate_limit.rate_limit: that does its own DB round trip per check, which would add database load to the exact endpoint meant to protect the database from overload, and risks checking out a second, separately-tracked pooled connection under its owng.dbalongside the one/ready's own handler manages — only one of which the teardown handler would return. The rejection happens before the view body runs, so a request over budget never reaches the database work it would otherwise trigger.DatabaseManager's siblingget_pool_stats()inapi/models/finding.py, returning only in-use/idle/max-connection counts and a utilization percentage — never the DSN, host, or credentials. Wired into three new Prometheus gauges (openshield_db_pool_connections_in_use/idle/max) refreshed on every/metricsscrape via a provider callback registered fromapi/app.py, keepingapi/observability.pyfree of project imports (it's reused by the worker, per its own module docstring).Testing
New file
tests/test_readiness_hardening.pycovers:probe_rate_limit's budget/window-reset/per-source-IP-isolation/testing-mode-bypass/stale-key-pruning behavior, in isolation against a minimal Flask app./readyactually rejecting a source once its budget is spent, and confirming a rejected request never callsping()at all.get_pool_stats()'s zero-before-any-connection case, a populated-pool case, the zero-division guard, and that its output never contains a DSN, host, or credential substring./metricsrendering the three new gauges with correct values, never leaking the DSN into the scrape body, and surviving a broken stats provider without failing the rest of the scrape.Verified: full backend suite (796 passed, 3 skipped — pre-existing, unrelated), plus
tests/test_observability.py's real PostgreSQL-backed readiness-leak test run against a local Postgres instance to confirm the new decorator doesn't disturb #306's fix.ruff check .andruff format --check .clean.Related issue
Closes #296
Checklist
Signed-off-bytrailerfix/description