Skip to content

Commit 366af17

Browse files
committed
Give a scanner of this site its own rate budget, and check address settings
A web scan aimed at the site arrives through the load balancer. When the balancer is not trusted, every caller shares its request budget, so the scan spent it and sign in answered 429 for everyone. Scanner addresses on the Sign in tab gives a named address its own budget, ten times the general one and never unlimited, so a scan can finish without swamping the app. The balancer itself is refused there, on save and again at request time, since exempting it would lift the limit for everybody behind it. Ranges wider than a /24 or /64 are refused. Load balancer addresses now rejects anything that is not an address or range with a 400 that names it, instead of skipping it silently. The load balancer guide says how to set both and that 0.0.0.0 trusts nothing.
1 parent 18f2acf commit 366af17

7 files changed

Lines changed: 139 additions & 15 deletions

File tree

‎app/core/live.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,3 +138,8 @@ def timezone_name() -> str:
138138

139139
def trusted_proxies() -> list:
140140
return list(_get("security.trusted_proxies", env_settings.trusted_proxies) or [])
141+
142+
143+
def rate_limit_exempt() -> list:
144+
"""Addresses the request rate limit leaves alone. Nothing by default."""
145+
return list(_get("security.rate_limit_exempt", []) or [])

‎app/core/settings_store.py‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,10 @@
307307
"security.force_https": (True, False, "Mark cookies secure and send HSTS. Leave on behind TLS."),
308308
"security.trusted_proxies": ([], False,
309309
"Addresses the load balancer connects from. X-Forwarded-For is believed from these and nowhere else."),
310+
"security.rate_limit_exempt": ([], False,
311+
"Addresses that get their own, ten times wider request "
312+
"budget, such as the scanner that tests this site. Never "
313+
"the load balancer."),
310314

311315
# ---- what the systems being scanned hold ----
312316
"sensitivity.labels": (["PCI", "HIPAA", "SOX", "Customer Data", "PII",
@@ -549,7 +553,7 @@
549553
"oidc.default_role",
550554
"session.hours", "session.idle_minutes", "login.attempt_limit",
551555
"login.lockout_minutes", "security.force_https", "security.trusted_proxies",
552-
"ui.timezone",
556+
"security.rate_limit_exempt", "ui.timezone",
553557
]
554558

555559

@@ -582,7 +586,8 @@
582586
"oidc.default_role"]),
583587
("Sessions", ["session.hours", "session.idle_minutes",
584588
"login.attempt_limit", "login.lockout_minutes",
585-
"security.force_https", "security.trusted_proxies"]),
589+
"security.force_https", "security.trusted_proxies",
590+
"security.rate_limit_exempt"]),
586591
("Display", ["ui.timezone"]),
587592
("Fidelity", ["code.muted_rules"]),
588593
("Sensitivity labels", ["sensitivity.labels"]),

‎app/web/routes_api.py‎

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1824,6 +1824,31 @@ async def scan_one_repository(repo_id: int, request: Request,
18241824
# ---------------------------------------------------------------------------
18251825
# Settings
18261826
# ---------------------------------------------------------------------------
1827+
def _address_list(raw: str, label: str, narrow: bool = False) -> list:
1828+
"""A box of addresses and ranges, each checked, or a 400 that names the bad one.
1829+
1830+
narrow refuses anything wider than a /24 (IPv4) or a /64 (IPv6). An
1831+
exemption is meant for one scanner, not a slice of the internet.
1832+
"""
1833+
import ipaddress
1834+
1835+
text = (raw or "").replace("\r", "\n").replace("\n", ",")
1836+
items = [item.strip() for item in text.split(",") if item.strip()]
1837+
if len(items) > 50:
1838+
raise HTTPException(status_code=400, detail=f"{label}: at most 50 entries.")
1839+
for item in items:
1840+
try:
1841+
network = ipaddress.ip_network(item, strict=False)
1842+
except ValueError:
1843+
raise HTTPException(status_code=400, detail=(
1844+
f"{label}: {item[:60]!r} is not an IP address or range."))
1845+
if narrow and network.prefixlen < (24 if network.version == 4 else 64):
1846+
raise HTTPException(status_code=400, detail=(
1847+
f"{label}: {item[:60]} is too wide. Name the scanner's own "
1848+
"address, or a /24 at most."))
1849+
return items
1850+
1851+
18271852
def _csv_list(raw: str) -> list:
18281853
"""Split a comma or newline separated box into a clean list.
18291854
@@ -2221,7 +2246,21 @@ def put(key, value):
22212246
put("ias.enabled", ias_on)
22222247

22232248
put("security.force_https", _checked(form, "security.force_https"))
2224-
put("security.trusted_proxies", _csv_list(text("security.trusted_proxies")))
2249+
proxies = _address_list(text("security.trusted_proxies"),
2250+
"Load balancer addresses")
2251+
put("security.trusted_proxies", proxies)
2252+
# Only when the form sent it. A page without the box must not empty it.
2253+
if "security.rate_limit_exempt" in form:
2254+
exempt = _address_list(text("security.rate_limit_exempt"),
2255+
"Scanner addresses", narrow=True)
2256+
from app.web.security import parse_networks
2257+
lbs = parse_networks(proxies or live.trusted_proxies())
2258+
for entry in parse_networks(exempt):
2259+
if any(entry.version == lb.version and entry.overlaps(lb) for lb in lbs):
2260+
raise HTTPException(status_code=400, detail=(
2261+
"%s is a load balancer address. Exempting it would switch the "
2262+
"rate limit off for everybody behind it." % entry))
2263+
put("security.rate_limit_exempt", exempt)
22252264

22262265
# Check what they just asked for before it takes effect. Turning on a
22272266
# provider that cannot answer would lock everybody out of their own

‎app/web/security.py‎

Lines changed: 46 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,25 @@ def public_base(request: Request) -> str:
5151
return base
5252

5353

54+
def parse_networks(entries) -> list:
55+
"""Addresses and ranges from a settings list. Anything unreadable is skipped."""
56+
networks = []
57+
for entry in entries or []:
58+
try:
59+
networks.append(ipaddress.ip_network(str(entry).strip(), strict=False))
60+
except ValueError:
61+
continue
62+
return networks
63+
64+
65+
def address_in(address: str, networks) -> bool:
66+
try:
67+
parsed = ipaddress.ip_address(address)
68+
except ValueError:
69+
return False
70+
return any(parsed in network for network in networks)
71+
72+
5473
def client_ip(request: Request) -> str:
5574
"""The caller's address.
5675
@@ -63,19 +82,10 @@ def client_ip(request: Request) -> str:
6382
if not proxies:
6483
return peer
6584

66-
networks = []
67-
for entry in proxies:
68-
try:
69-
networks.append(ipaddress.ip_network(entry, strict=False))
70-
except ValueError:
71-
continue
85+
networks = parse_networks(proxies)
7286

7387
def trusted(address: str) -> bool:
74-
try:
75-
parsed = ipaddress.ip_address(address)
76-
except ValueError:
77-
return False
78-
return any(parsed in network for network in networks)
88+
return address_in(address, networks)
7989

8090
if not trusted(peer):
8191
return peer
@@ -246,6 +256,25 @@ def _allowed(self, key: str, limit: int) -> bool:
246256
bucket.append(now)
247257
return True
248258

259+
# How many times the general budget a named scanner gets. 3000 a minute
260+
# at the defaults: enough for a scan to finish, not enough to swamp a
261+
# single process.
262+
SCANNER_FACTOR = 10
263+
264+
@staticmethod
265+
def _exempt(ip: str) -> bool:
266+
"""An address an administrator named, so a scan of this site runs whole.
267+
268+
Never a load balancer. When X-Forwarded-For is not believed, every
269+
caller wears the balancer's address, and exempting it would switch the
270+
limiter off for the whole internet. The save refuses that pairing, and
271+
this refuses it again in case the lists were changed some other way.
272+
"""
273+
exempt = live.rate_limit_exempt()
274+
if not exempt or not address_in(ip, parse_networks(exempt)):
275+
return False
276+
return not address_in(ip, parse_networks(live.trusted_proxies()))
277+
249278
async def dispatch(self, request: Request, call_next):
250279
path = request.url.path
251280
if path.endswith(("/healthz", "/readyz")):
@@ -257,7 +286,12 @@ async def dispatch(self, request: Request, call_next):
257286
# same budget as somebody clicking around the pages would lock the
258287
# pages out. Its own bucket, and a wider one.
259288
is_api = "/api/v1/" in path
260-
if is_api:
289+
if self._exempt(ip):
290+
# A scanner an administrator named gets its own, much wider
291+
# budget, but still a budget. Unlimited would trade a locked out
292+
# sign in page for a scanner that can flood the app itself.
293+
limit, kind = self.general * self.SCANNER_FACTOR, "scan"
294+
elif is_api:
261295
limit, kind = self.general * 4, "api"
262296
elif is_auth_path:
263297
limit, kind = self.auth, "auth"

‎app/web/templates/settings.html‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,18 @@
422422
it came from the balancer, so one person getting their password wrong
423423
locks out everybody.</span>
424424
</label>
425+
<label class="wide">Scanner addresses
426+
<input type="text" name="security.rate_limit_exempt"
427+
value="{{ values['security.rate_limit_exempt'] | join(', ') }}"
428+
placeholder="203.0.113.10">
429+
<span class="faint small">For a scanner testing this site. It gets its
430+
own budget, ten times the normal one, and never uses up anybody
431+
else's. When this
432+
site scans itself, that is this server's public address. Set the load
433+
balancer addresses above first, or the scanner looks like the balancer
434+
and gets limited along with everyone else. The balancer itself is
435+
refused here.</span>
436+
</label>
425437
<label class="check">
426438
<input type="checkbox" name="security.force_https" value="1"
427439
{{ 'checked' if values['security.force_https'] }}> Behind TLS

‎documentation/CHANGELOG.md‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,16 @@ moved.
66

77
## 2026-11-10
88

9+
**A scan of this site no longer locks everybody out.** A web scan aimed at the
10+
site came in through the load balancer, and when the balancer was not listed as
11+
trusted every person shared the balancer's request budget, so the scan used it
12+
up and sign in answered 429 for everyone. The **Load balancer addresses** box
13+
now refuses anything that is not an address or range, and a new **Scanner
14+
addresses** box on the Sign in tab gives a named scanner its own budget, ten
15+
times the normal one and never unlimited. The balancer itself cannot be listed
16+
there. The load balancer guide says how to set both, and that `0.0.0.0` trusts
17+
nothing.
18+
919
**Sites link to the code that serves them, from the code itself.** Every scan now
1020
reads each repository's default branch for the hosts it answers on: ingress and
1121
route hosts, Helm values, nginx, Apache, Traefik and Caddy, a `CNAME` file, CDN

‎documentation/load-balancer.md‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,25 @@ sees, list both. Extra entries cost nothing.
4343

4444
CIDR works: `GCR_TRUSTED_PROXIES=10.0.0.0/24`.
4545

46+
`0.0.0.0` is not a wildcard. It is one address that never connects, so it
47+
trusts nothing and puts everybody back in one shared budget.
48+
49+
The same list is on Settings, Sign in tab, under **Load balancer addresses**.
50+
A value saved there wins over `.env` and takes effect within about 15 seconds,
51+
with no restart, so running scans keep going.
52+
53+
### Scanning this site through the balancer
54+
55+
A web scan of this site, including one it runs on itself, sends thousands of
56+
requests a minute. With the balancer trusted, those count against the
57+
scanner's own address and nobody else's. To give the scanner room to finish,
58+
put its address under **Scanner addresses** on the same tab. When the site
59+
scans itself, that is the server's own public address. A listed address gets
60+
ten times the normal budget, never an unlimited one, so a scan cannot swamp
61+
the app either. Ranges wider than a /24 are refused, and so is any balancer
62+
address, because exempting the balancer would lift the limit for everybody
63+
behind it.
64+
4665
### TLS stops at the balancer
4766

4867
The app speaks plain HTTP. Keep `GCR_FORCE_HTTPS=true`, which marks session

0 commit comments

Comments
 (0)