Skip to content

Commit 4d8fb1e

Browse files
committed
Two account support, and the compare checks that use it
A site can now hold a second sign in, a second test account, alongside the first. The second account is stored encrypted in its own slot, set from the site page the same way as the first, and never shown again once saved. With two accounts set, the enhanced engine runs the compare checks safely and read only: it finds an object that belongs to the second account from that account's own pages, then reads it as the first account and as a signed out visitor. It reports only when the first account can read the second account's object and a signed out visitor cannot, which is broken object access. It stays silent when the site scopes the object to its owner, when the object is public, and when no second account is set. Three curated rules now run on this: cross account read of a numeric id, of an opaque id, and the general case. They need no source and no ids supplied; the object addresses come from the second account's own pages. The rule that needed an admin account is set aside until that is modelled. The differential grammar gains the helpers these checks need: compare two saved responses, and read a saved response's status.
1 parent e0eddd4 commit 4d8fb1e

10 files changed

Lines changed: 251 additions & 88 deletions

‎app/core/dast_checks/curated/access-control-bola-uuid.json‎

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,26 @@
33
"class": "access-control",
44
"safety": "read",
55
"severity_floor": "high",
6-
"title": "Opaque id can be substituted to read another tenant's object (needs two accounts)",
7-
"summary": "A UUID or opaque id can be swapped for another to read an object that is not the caller's. Needs a second account to prove, so it skips until two accounts are configured.",
6+
"title": "A UUID or opaque object id can be read by another account",
7+
"summary": "An object named by a UUID or opaque id that belongs to the second account can be read by the first account. Guessing the id is hard, but access must still be checked. Needs a second account configured for the site.",
88
"needs": [
9-
"two_accounts",
10-
"code_routes"
9+
"two_accounts"
1110
],
1211
"probe": [
1312
{
1413
"request": {
1514
"method": "GET",
16-
"path": "/{route}"
15+
"path": "/"
1716
}
1817
}
1918
],
2019
"confirm": {
2120
"control": "A path that cannot exist, so the site's not found answer is known.",
22-
"differential": "status==200 and body_differs(control)",
21+
"differential": "status==200 and status_of(b)==200 and same(response, b) and differ(response, anon)",
2322
"retest": 2
2423
},
25-
"reproduce": "As account A, request an object id known to belong to account B.",
26-
"remediate": "Check object ownership on the server for every object level read.",
24+
"reproduce": "Sign in as account A and open an object URL (with account B's UUID) that belongs to account B. If A sees B's object, object access is broken.",
25+
"remediate": "Check ownership on the server for every object, not just that the id is hard to guess.",
2726
"references": [],
2827
"mappings": {
2928
"cwe": "CWE-639",

‎app/core/dast_checks/curated/access-control-horizontal-ab.json‎

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,26 @@
33
"class": "access-control",
44
"safety": "read",
55
"severity_floor": "high",
6-
"title": "One account can read another account's data (needs two accounts)",
7-
"summary": "Account A can read what only account B should see. This is the cleanest proof of broken object authorization, and it needs both accounts.",
6+
"title": "One account can read another account's object",
7+
"summary": "The first account can read an object that belongs to the second account, which a signed out visitor cannot. This is broken object level authorization. Needs a second account configured for the site.",
88
"needs": [
9-
"two_accounts",
10-
"code_routes"
9+
"two_accounts"
1110
],
1211
"probe": [
1312
{
1413
"request": {
1514
"method": "GET",
16-
"path": "/{route}"
15+
"path": "/"
1716
}
1817
}
1918
],
2019
"confirm": {
2120
"control": "A path that cannot exist, so the site's not found answer is known.",
22-
"differential": "status==200 and body_differs(control)",
21+
"differential": "status==200 and status_of(b)==200 and same(response, b) and differ(response, anon)",
2322
"retest": 2
2423
},
25-
"reproduce": "Fetch the same route as account A and account B and compare the bodies.",
26-
"remediate": "Scope every query to the signed in account.",
24+
"reproduce": "Sign in as account A. Open an object URL that belongs to account B (for example /invoice/<B's id>). If A sees B's data, object access is broken.",
25+
"remediate": "Check that the signed in account owns the object on every object read.",
2726
"references": [],
2827
"mappings": {
2928
"cwe": "CWE-639",

‎app/core/dast_checks/curated/access-control-idor-numeric.json‎

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,26 @@
33
"class": "access-control",
44
"safety": "read",
55
"severity_floor": "high",
6-
"title": "Numeric object id can be walked to another user's record (needs two accounts)",
7-
"summary": "A numeric id in a route can be changed to read a record that belongs to someone else. Proving this needs a second test account, so it only runs once two accounts are configured.",
6+
"title": "A numeric object id can be read by another account",
7+
"summary": "An object named by a numeric id that belongs to the second account can be read by the first account, so ids can be walked to read other records. Needs a second account configured for the site.",
88
"needs": [
9-
"two_accounts",
10-
"code_routes"
9+
"two_accounts"
1110
],
1211
"probe": [
1312
{
1413
"request": {
1514
"method": "GET",
16-
"path": "/{route}"
15+
"path": "/"
1716
}
1817
}
1918
],
2019
"confirm": {
2120
"control": "A path that cannot exist, so the site's not found answer is known.",
22-
"differential": "status==200 and body_differs(control)",
21+
"differential": "status==200 and status_of(b)==200 and same(response, b) and differ(response, anon)",
2322
"retest": 2
2423
},
25-
"reproduce": "As account A, request account B's object id over the same route.",
26-
"remediate": "Check the object's owner against the signed in user on every read.",
24+
"reproduce": "Sign in as account A and open an object URL with account B's numeric id (for example /account/<B's id>). If A sees B's record, access is broken.",
25+
"remediate": "Check the object's owner against the signed in account on every read.",
2726
"references": [],
2827
"mappings": {
2928
"cwe": "CWE-639",

‎app/core/dast_checks/curated/access-control-vertical-privilege.json‎

Lines changed: 0 additions & 33 deletions
This file was deleted.

‎app/core/dast_checks/engine.py‎

Lines changed: 101 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from __future__ import annotations
2323

2424
import ast
25+
import re
2526
import secrets
2627
import time
2728
from urllib.parse import urljoin, urlparse
@@ -104,7 +105,7 @@ def request(self, method: str, path: str, query: dict = None,
104105
# ------------------------------------------------------------------ the DSL
105106

106107
_ALLOWED_FUNCS = {"body_differs", "body_contains", "header", "reflected",
107-
"status_in"}
108+
"status_in", "differ", "same", "status_of"}
108109
_ALLOWED_CMP = (ast.Eq, ast.NotEq, ast.Lt, ast.LtE, ast.Gt, ast.GtE,
109110
ast.In, ast.NotIn)
110111

@@ -187,8 +188,12 @@ def _normalize(body: str) -> str:
187188
return re.sub(r"\s+", " ", body).strip()
188189

189190

190-
def _env_for(response: dict, control: dict, oob_hit: bool) -> dict:
191-
"""The names and helpers a differential may use."""
191+
def _env_for(response: dict, control: dict, oob_hit: bool, saved: dict = None) -> dict:
192+
"""The names and helpers a differential may use.
193+
194+
saved holds other responses by name (for example b and anon in a two
195+
account check), so a differential can compare them.
196+
"""
192197
body = response.get("body") or ""
193198
headers = response.get("headers") or {}
194199

@@ -207,14 +212,27 @@ def reflected(marker):
207212
def status_in(*codes):
208213
return response.get("status") in codes
209214

210-
return {
215+
def differ(a, b):
216+
return _normalize((a or {}).get("body") or "") != _normalize((b or {}).get("body") or "")
217+
218+
def same(a, b):
219+
return not differ(a, b)
220+
221+
def status_of(resp):
222+
return (resp or {}).get("status", 0)
223+
224+
env = {
211225
"status": response.get("status", 0),
226+
"response": response,
212227
"control": control,
213228
"oob_hit": oob_hit,
214229
"_funcs": {"body_differs": body_differs, "body_contains": body_contains,
215230
"header": header, "reflected": reflected,
216-
"status_in": status_in},
231+
"status_in": status_in, "differ": differ, "same": same,
232+
"status_of": status_of},
217233
}
234+
env.update(saved or {})
235+
return env
218236

219237

220238
# ------------------------------------------------------------------ running
@@ -277,6 +295,18 @@ def run_rule(spec: dict, ctx: dict) -> dict:
277295
return out
278296

279297
budget = Budget(int(ctx.get("budget") or DEFAULT_BUDGET))
298+
299+
# A rule that compares two accounts runs its own path: it reads an object
300+
# that belongs to the second account and checks the first can read it too.
301+
if "two_accounts" in (spec.get("needs") or []):
302+
try:
303+
out["findings"] = _run_two_account(spec, ctx, domains, budget)
304+
except Refused as exc:
305+
out["status"], out["detail"] = "refused", str(exc)
306+
except Exception as exc: # noqa: BLE001
307+
out["status"], out["detail"] = "error", f"{type(exc).__name__}: {exc}"[:200]
308+
return out
309+
280310
client = Client(base_url, domains, spec["safety"], budget,
281311
allow_private=bool(ctx.get("allow_private")),
282312
headers=ctx.get("headers"))
@@ -320,6 +350,72 @@ def run_rule(spec: dict, ctx: dict) -> dict:
320350
return out
321351

322352

353+
_OBJECT_PATH = re.compile(r"/\d{1,12}(?:/|$)|/[0-9a-fA-F]{8,}(?:/|$)"
354+
r"|[?&][\w-]*id=\w+", re.I)
355+
356+
357+
def _b_objects(client_b: Client, base_url: str, code_routes) -> list:
358+
"""Addresses that look like they point at a specific object of account b.
359+
360+
A cross account read is only meaningful against an object that belongs to
361+
the other account, so this keeps the links from account b's own pages (and
362+
any code routes) that carry an id, and leaves the generic pages out.
363+
"""
364+
out = []
365+
try:
366+
landing = client_b.request("GET", "/")
367+
except Refused:
368+
return out
369+
from app.core.dast_engines import links_in
370+
371+
for url in links_in(base_url, landing.get("body") or ""):
372+
if _OBJECT_PATH.search(url) and url not in out:
373+
out.append(url)
374+
for route in code_routes or []:
375+
if _OBJECT_PATH.search(route) and route not in out:
376+
out.append(route)
377+
return out[:MAX_ROUTES]
378+
379+
380+
def _run_two_account(spec: dict, ctx: dict, domains, budget: Budget) -> list:
381+
"""Account A reads an object that belongs to account B. -> findings.
382+
383+
Reads only. For each object address found in B's own pages: fetch it as B
384+
(to confirm it is really B's and it is a real page), as A, and signed out.
385+
The differential decides, for example: A got 200, it is really B's object,
386+
A sees the same body as B, and the signed out visitor is turned away.
387+
"""
388+
base_url = ctx.get("base_url") or ""
389+
allow_private = bool(ctx.get("allow_private"))
390+
a = Client(base_url, domains, "read", budget, allow_private, ctx.get("headers"))
391+
b = Client(base_url, domains, "read", budget, allow_private, ctx.get("second_headers"))
392+
anon = Client(base_url, domains, "read", budget, allow_private, None)
393+
394+
confirm = spec.get("confirm") or {}
395+
differential = confirm.get("differential") or "False"
396+
retest = int(confirm.get("retest") or 1)
397+
398+
findings = []
399+
for url in _b_objects(b, base_url, ctx.get("code_routes")):
400+
confirmations, resp_a = 0, None
401+
for _attempt in range(retest):
402+
resp_b = b.request("GET", url)
403+
if resp_b.get("status") != 200:
404+
break
405+
resp_a = a.request("GET", url)
406+
resp_anon = anon.request("GET", url)
407+
env = _env_for(resp_a, {}, False,
408+
saved={"b": resp_b, "anon": resp_anon})
409+
if evaluate(differential, env):
410+
confirmations += 1
411+
else:
412+
break
413+
if confirmations == retest and confirmations > 0:
414+
findings.append(_finding(spec, a, resp_a, url))
415+
break # one solid finding per rule
416+
return findings
417+
418+
323419
def _run_probe(spec: dict, client: Client, route: str, oob_value: str) -> dict:
324420
"""Send the probe steps; the last response is what the differential reads.
325421

‎app/core/external.py‎

Lines changed: 35 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,10 @@ def bulk_update(db, assets: list, changes: dict, actor: str, user=None) -> list:
484484
if changes.get("auth") is not None:
485485
kind, values = changes["auth"]
486486
external_auth.save(asset, kind, values, actor, asset.parent_domain)
487+
if changes.get("auth_b") is not None:
488+
kind, values = changes["auth_b"]
489+
external_auth.save(asset, kind, values, actor, asset.parent_domain,
490+
slot="b")
487491
if repos:
488492
mode, picks = repos
489493
if mode == "add":
@@ -1898,7 +1902,19 @@ def _scan_rulesets(scan_id, profile) -> list:
18981902
return ["*"] if profile == "premium" else []
18991903

19001904

1901-
def _run_curated_rules(scan_id, url, wb, session, opts, rulesets) -> tuple:
1905+
def _session_headers(sess: dict) -> dict:
1906+
"""The request headers a built session signs in with: cookie and any headers."""
1907+
out = {}
1908+
if not sess:
1909+
return out
1910+
if sess.get("cookie"):
1911+
out["Cookie"] = sess["cookie"]
1912+
for name, value in sess.get("headers") or []:
1913+
out[name] = value
1914+
return out
1915+
1916+
1917+
def _run_curated_rules(scan_id, asset_id, url, wb, session, opts, rulesets) -> tuple:
19021918
"""Run the enabled curated rules in the chosen rulesets against the site.
19031919
19041920
rulesets is the list the scan profile chose (["*"] for all). -> (findings,
@@ -1908,12 +1924,14 @@ def _run_curated_rules(scan_id, url, wb, session, opts, rulesets) -> tuple:
19081924
"""
19091925
from urllib.parse import urlparse
19101926

1927+
from app.core import external_auth
19111928
from app.core import settings_store as settings_mod
19121929
from app.core.dast_checks import callback, engine, profiles, scope
19131930
from app.core.dast_checks import spec as spec_mod
1914-
from app.core.models import DastRule
1931+
from app.core.models import DastRule, ExternalAsset
19151932

1916-
specs, domains, headers = [], [], {}
1933+
specs, domains = [], []
1934+
second_headers = None
19171935
with session_scope() as db:
19181936
for row in db.query(DastRule).filter(DastRule.enabled.is_(True)).all():
19191937
if not profiles.selected(row.rule_class, row.rulesets, rulesets):
@@ -1925,8 +1943,16 @@ def _run_curated_rules(scan_id, url, wb, session, opts, rulesets) -> tuple:
19251943
domains = scope.company_domains(
19261944
settings_mod.get(db, "web.company_domains") or [])
19271945
oob = callback.for_scan(db)
1928-
if session.get("cookie"):
1929-
headers["Cookie"] = session["cookie"]
1946+
# The second account, for the compare checks. Built only when one is
1947+
# set and at least one enabled rule needs it.
1948+
if any("two_accounts" in (s.get("needs") or []) for s in specs):
1949+
asset = db.get(ExternalAsset, asset_id)
1950+
auth_b = external_auth.load_b(asset) if asset else {}
1951+
if auth_b:
1952+
sess_b = external_auth.session_for(auth_b, opts["allow_private"])
1953+
if sess_b.get("ok"):
1954+
second_headers = _session_headers(sess_b)
1955+
headers = _session_headers(session)
19301956

19311957
host = urlparse(url).hostname or ""
19321958
row = {"tool": "curated", "name": "Premium curated rules", "status": "ok",
@@ -1944,7 +1970,8 @@ def _run_curated_rules(scan_id, url, wb, session, opts, rulesets) -> tuple:
19441970
ctx = {"base_url": url, "host": host, "domains": domains,
19451971
"allow_private": opts["allow_private"],
19461972
"code_routes": list(wb.get("routes") or []),
1947-
"headers": headers or None, "budget": 60, "oob": oob}
1973+
"headers": headers or None, "second_headers": second_headers,
1974+
"budget": 60, "oob": oob}
19481975
started = time.monotonic()
19491976
findings, ran, skipped, confirmed = [], 0, 0, 0
19501977
for spec in specs:
@@ -2060,8 +2087,8 @@ def _builtin_run(scan_id, host, asset_id, profile, opts, auth, proxy) -> None:
20602087
if url and plan:
20612088
rulesets = _scan_rulesets(scan_id, profile)
20622089
if rulesets:
2063-
rule_findings, rule_row = _run_curated_rules(scan_id, url, wb, session,
2064-
opts, rulesets)
2090+
rule_findings, rule_row = _run_curated_rules(scan_id, asset_id, url, wb,
2091+
session, opts, rulesets)
20652092
findings += rule_findings
20662093
engines.append(rule_row)
20672094
_update(scan_id, engines=json.dumps(engines))

0 commit comments

Comments
 (0)