Skip to content

fix(api): rate-limit /ready and /metrics, expose db pool telemetry - #319

Open
parthrohit22 wants to merge 3 commits into
openshield-org:devfrom
parthrohit22:fix/296-readiness-edge-restriction-pool-telemetry
Open

fix(api): rate-limit /ready and /metrics, expose db pool telemetry#319
parthrohit22 wants to merge 3 commits into
openshield-org:devfrom
parthrohit22:fix/296-readiness-edge-restriction-pool-telemetry

Conversation

@parthrohit22

Copy link
Copy Markdown
Member

What does this PR do?

Closes the remaining scope of #296 (readiness-endpoint pool exhaustion): rate-limits the unauthenticated /ready and /metrics probe/scrape endpoints per source IP, and exposes database connection pool utilization/exhaustion telemetry on /metrics.

Type of change

  • Bug fix
  • API endpoint
  • Documentation

Scope note

#306 already fixed the core connection-checkout leak (g.db + the existing close_db teardown handler) and added /health as 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:

  1. Restrict /ready//metrics at the edge, while keeping an appropriate liveness endpoint. /health already 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" in render.yaml itself — documented that in docs/deployment/render.md under 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, added api.observability.probe_rate_limit: an in-memory, per-process, per-source-IP rate limiter, wired onto /ready (5 requests/10s — half the default DB_POOL_MAX_CONN) and /metrics (20/10s, generous for normal Prometheus scrape intervals). Deliberately not the existing Postgres-backed api.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 own g.db alongside 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.
  2. Expose pool utilization/exhaustion telemetry without publishing sensitive operational data. Added DatabaseManager's sibling get_pool_stats() in api/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 /metrics scrape via a provider callback registered from api/app.py, keeping api/observability.py free of project imports (it's reused by the worker, per its own module docstring).

Testing

  • All CI checks pass
  • No hardcoded credentials or secrets

New file tests/test_readiness_hardening.py covers:

  • 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.
  • /ready actually rejecting a source once its budget is spent, and confirming a rejected request never calls ping() 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.
  • /metrics rendering 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 . and ruff format --check . clean.

Related issue

Closes #296

Checklist

  • Every commit includes a DCO Signed-off-by trailer
  • I have not committed any real Azure credentials
  • My branch name follows the convention: fix/description
  • Matching CLI playbook / compliance framework mappings — not applicable, no scanner rule added

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 m-khan-97 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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>
@parthrohit22

Copy link
Copy Markdown
Member Author

@m-khan-97 Fixed, in two parts:

  1. The gap you flagged. _probe_hits's per-call cleanup only ever pruned the one key the current request touched. Added the periodic global sweep and hard cap you asked for: a 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, plus a hard _PROBE_MAX_TRACKED_KEYS ceiling with deterministic least-recently-touched eviction via an OrderedDict (every hit, including one that just survives a sweep, moves its key to the end). Also added the Retry-After header you suggested as non-blocking.
  2. Separately: a dev merge onto this branch (picking up security: remove the public demo bearer and establish real authorization boundaries #294/fix(api): enforce token expiry, role, and subscription authorization #320's own changes to api/app.py) dropped _READY_MAX_REQUESTS_PER_WINDOW's definition while keeping its usage on /ready, which broke CI (ruff's F821, and Backend Tests for the same reason). Restored it.

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 Retry-After header.

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.

@parthrohit22
parthrohit22 requested a review from m-khan-97 August 29, 2026 13:23

@m-khan-97 m-khan-97 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

@m-khan-97

Copy link
Copy Markdown
Collaborator

@ritiksah141 @TFT444, I completed the blocker rereview and approved 5854316: the limiter now has global stale-key cleanup, a hard state cap, deterministic LRU eviction, and regression coverage. All 20 checks pass. Please provide the required independent review so it can proceed under the two-person rule.

TFT444
TFT444 previously requested changes Aug 31, 2026

@TFT444 TFT444 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

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.

@TFT444
TFT444 dismissed their stale review August 31, 2026 07:16

Dismissing — posted before user review

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.

bug: stop the public readiness endpoint from exhausting the database pool

3 participants