Files
shopdb-flask/plugins/endpoints/api/ratelimit.py
cproudlock 066a19052b
Some checks failed
CI / backend (push) Has been cancelled
CI / naming (push) Has been cancelled
CI / gates (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / lean-build (push) Has been cancelled
Act on the wj architectural review: races, limits, pool and contract gaps
Findings F2, F4, F5, F6, F7, F8, F9, F10, F11 and F13 from
wj-architectural-review.md. F1/F1a (the non-sargable LOWER pattern) and F3
(the warranty sync holding a pooled connection) are being handled separately
and are untouched here.

- A collector check-in that raced another for the same PC lost everything:
  the unique index on the application fingerprint raised, nothing caught it,
  and the whole check-in rolled back, discarding state, events and hardware,
  answering the agent with form copy meant for a person. The insert now runs
  in a savepoint, reconciles what the other transaction already wrote, one row
  per savepoint so a second collision skips a row rather than the batch, and
  the check-in commits with a warning. This is the shape the delete side of
  the same function already had.
- Enrollment is deliberately unauthenticated, so it now carries a per-address
  rate limit and a cap on pending requests, and the cap is a setting an admin
  can see and change. A flood can no longer bury the approval queue or grow
  the table without bound.
- The fleet alert poll no longer formats debug strings on every request.
- BusinessUnit is on the contract surface (0.34.0), so the endpoints plugin
  stops reaching into a core table with raw SQL.
- A test walks every asset type descriptor and proves the endpoint it names
  really refuses a caller without the declared permission.
- The plugin settings routes now need a permission; they were telling anyone
  which modules a site runs and where its components live.
- The connection pool is sized explicitly with a short timeout, and the
  arithmetic against the server's connection ceiling is written next to the
  worker count instead of being left to defaults nobody chose.
- Fleet alerts carry both the correct lowercase keys and the old camelCase
  pair. Trays older than 1.7.0 match keys case-sensitively and would have gone
  quiet on every PC not yet upgraded; the old pair goes when they are.
- The dev server proxy points at localhost again, with environment variables
  for the remote-box workflow, and one developer's address is out of the
  server certificate page.
2026-09-16 08:57:04 -04:00

53 lines
2.1 KiB
Python

"""Fixed-window per-IP rate limiting for the agent-facing routes.
One implementation, several budgets. The bootstrap installer routes and the
enrollment route are both unauthenticated by design - a PC with no agent has
neither a credential nor a user session - so the only thing standing between
them and a loop is a limiter. Each caller gets its own budget name so the two
surfaces cannot spend each other's allowance.
Same shape and same cache extension as the login limiter in
shopdb.core.api.auth. Under the default SimpleCache the counter is per-process,
so with N gunicorn workers the effective budget is N times the configured
maximum; that is defence in depth rather than a hard quota, and a shared cache
backend (Redis/memcached) tightens it to a true global budget.
"""
import time
from flask import current_app, request
from shopdb.api import cache, client_ip, error_response
def caller_ip():
return client_ip(request) or 'unknown'
def ratelimited(budget, configprefix, defaultmax, defaultwindow):
"""True when this caller is over budget for the current window.
budget cache key namespace, e.g. 'endpointenrollratelimit'
configprefix config key stem, e.g. 'ENDPOINTS_ENROLL_RATELIMIT'; reads
<prefix>_ENABLED, <prefix>_MAX and <prefix>_WINDOW_SECONDS
"""
if not current_app.config.get(configprefix + '_ENABLED', True):
return False
window = current_app.config.get(configprefix + '_WINDOW_SECONDS',
defaultwindow)
maxhits = current_app.config.get(configprefix + '_MAX', defaultmax)
# Time bucket makes this a fixed window: the key rolls over at each window
# boundary, so a per-hit set() cannot turn it into a sliding window.
bucket = int(time.time() // window) if window > 0 else 0
key = '{0}:{1}:{2}'.format(budget, caller_ip(), bucket)
count = cache.get(key) or 0
if count >= maxhits:
return True
cache.set(key, count + 1, timeout=window)
return False
def rate_limit_response(message):
"""The standard 429 shape every limiter in the product answers with."""
return error_response('RATE_LIMITED', message, http_code=429)