apifuzz 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
apifuzz/__init__.py ADDED
@@ -0,0 +1,13 @@
1
+ """apifuzz: an OpenAPI-driven security scanner for authorized staging APIs."""
2
+
3
+ # This file makes the folder "apifuzz" a Python package (so `import apifuzz.cli` works).
4
+ # It also exposes __version__, which `apifuzz --version` prints.
5
+
6
+ from importlib.metadata import PackageNotFoundError, version
7
+
8
+ try:
9
+ # Read the version from the installed package metadata, which comes from pyproject.toml.
10
+ # This way the version number is written in exactly one place.
11
+ __version__ = version("apifuzz")
12
+ except PackageNotFoundError: # running from a source tree that was never installed
13
+ __version__ = "0.0.0"
apifuzz/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ # Python runs this file when you type `python -m apifuzz`. It does the same thing as the
2
+ # `apifuzz` command: hand control to the CLI app defined in cli.py.
3
+ from apifuzz.cli import app
4
+
5
+ app(prog_name="apifuzz") # prog_name makes help text say "apifuzz" instead of "__main__.py"
@@ -0,0 +1,9 @@
1
+ """The security checks. Importing this package registers every check it contains.
2
+
3
+ To add a check: create a module here with a Check subclass, then import it below.
4
+ """
5
+
6
+ from apifuzz.checks import auth_required, bola, injection, mass_assignment, rate_limit # noqa: F401 (imported for their registration side effect)
7
+ from apifuzz.checks.base import NOT_TESTED, TESTED, Check, ScanContext, all_checks, fill_path, select_checks
8
+
9
+ __all__ = ["NOT_TESTED", "TESTED", "Check", "ScanContext", "all_checks", "fill_path", "select_checks"]
@@ -0,0 +1,62 @@
1
+ """Check: does an endpoint that the spec says needs a login actually refuse anonymous callers?
2
+
3
+ This is OWASP API2 (Broken Authentication) in its simplest form. It sends one request per
4
+ endpoint with no credentials, plus a second one to confirm when the first succeeds, and only for
5
+ read-only methods (GET/HEAD), so it cannot change any data on the target.
6
+ """
7
+
8
+ from collections.abc import Iterator
9
+
10
+ from apifuzz.checks.base import NOT_TESTED, TESTED, Check, ScanContext, fill_path
11
+ from apifuzz.endpoint_model import Endpoint
12
+ from apifuzz.finding import Finding, Severity
13
+
14
+ # Methods that by HTTP's own rules must not change server state. Anything else is skipped here,
15
+ # because an unauthenticated POST/DELETE that *succeeded* would have done real damage.
16
+ _SAFE_METHODS = {"GET", "HEAD"}
17
+
18
+
19
+ class AuthRequired(Check):
20
+ id = "auth-required"
21
+ owasp = "API2:2023 Broken Authentication"
22
+ description = "Endpoints the spec marks as needing a login must reject a request with no credentials."
23
+
24
+ def run(self, endpoint: Endpoint, ctx: ScanContext) -> Iterator[Finding]:
25
+ if not endpoint.requires_auth or endpoint.method not in _SAFE_METHODS:
26
+ return # nothing to check: open by design, or not safe to probe this way
27
+
28
+ # No headers at all: in particular, no credentials.
29
+ url = ctx.url(fill_path(endpoint))
30
+ exchange = ctx.client.request(endpoint.method, url)
31
+ status = exchange.response.status_code
32
+
33
+ # Only a success counts as a finding. 401/403 is the correct answer. Anything else (a 404
34
+ # for a missing sample object, a 5xx from a broken route) means we can't tell, so we stay
35
+ # quiet rather than guess, and the report says the endpoint was not tested.
36
+ if status in (401, 403):
37
+ ctx.record(self.id, endpoint, TESTED)
38
+ return
39
+ if not exchange.response.ok:
40
+ ctx.record(self.id, endpoint, NOT_TESTED, f"answered HTTP {status} to the sample request, which settles nothing")
41
+ return
42
+ ctx.record(self.id, endpoint, TESTED)
43
+ # Confirmation: the same request again. Only a reproducible result is reported.
44
+ confirm = ctx.client.request(endpoint.method, url)
45
+ if confirm.response.ok:
46
+ yield Finding(
47
+ check=self.id,
48
+ owasp=self.owasp,
49
+ severity=Severity.HIGH,
50
+ title="Endpoint answers without authentication",
51
+ endpoint=f"{endpoint.method} {endpoint.path}",
52
+ detail=(
53
+ f"The spec says this endpoint requires a login, but a request with no "
54
+ f"credentials got HTTP {status} (confirmed on a second request). Anyone may be "
55
+ f"able to call it."
56
+ ),
57
+ remediation=(
58
+ "Require a valid login on this route (for example with your framework's auth "
59
+ "middleware or dependency) and answer 401 when the credentials are missing."
60
+ ),
61
+ evidence=[exchange, confirm],
62
+ )
apifuzz/checks/base.py ADDED
@@ -0,0 +1,152 @@
1
+ """The shape every security check follows, and the registry that collects them.
2
+
3
+ A check is a small class with an id, a description, and a `run` method. `run` receives one
4
+ Endpoint and a ScanContext (the HTTP client and settings it may use) and yields Findings. Checks
5
+ must send only minimal, non-destructive requests, and only through the context's client, which
6
+ enforces the host allowlist.
7
+ """
8
+
9
+ import re # find "{name}" placeholders in a path template
10
+ from collections.abc import Iterator # a check yields findings, so its return type is an Iterator
11
+ from urllib.parse import quote # make a path value safe to put in a URL
12
+
13
+ from apifuzz.endpoint_model import Endpoint
14
+ from apifuzz.finding import Finding
15
+ from apifuzz.http_client import HttpClient
16
+ from apifuzz.settings import Settings
17
+
18
+
19
+ class ScanContext:
20
+ """Everything a check is allowed to use: the HTTP client, the settings, and the base URL.
21
+
22
+ A check never builds its own HttpClient. It calls ctx.url(path) to turn an endpoint's path
23
+ template into a full URL, and ctx.client.request(...) to send a request through the allowlist.
24
+ """
25
+
26
+ def __init__(self, client: HttpClient, settings: Settings, endpoints: list[Endpoint] | None = None) -> None:
27
+ self.client = client
28
+ self.settings = settings
29
+ # Every endpoint in the scan (run_scan fills this in), so a check can do recon on others.
30
+ self.endpoints: list[Endpoint] = endpoints or []
31
+ # Scratch space shared across one scan, e.g. ids a check collected once and reuses.
32
+ self.cache: dict[str, object] = {}
33
+ # What each check did with each endpoint: {(check id, "GET /path"): (status, reason)}.
34
+ # The report turns this into a coverage table, so "not tested" never looks like "safe".
35
+ self.outcomes: dict[tuple[str, str], tuple[str, str]] = {}
36
+
37
+ def record(self, check_id: str, endpoint: Endpoint, status: str, reason: str = "") -> None:
38
+ """Note what a check did with an endpoint: TESTED, or NOT_TESTED with the reason why."""
39
+ self.outcomes[(check_id, f"{endpoint.method} {endpoint.path}")] = (status, reason)
40
+
41
+ def auth(self, account: str) -> dict[str, str] | None:
42
+ """The login header for test account "a" or "b", or None if that account has no token."""
43
+ token = self.settings.token_a if account == "a" else self.settings.token_b
44
+ return self.settings.auth_headers(token) if token else None
45
+
46
+ def url(self, path: str) -> str:
47
+ """Join the base URL and an endpoint path: ("https://x", "/orders") -> "https://x/orders"."""
48
+ return f"{self.settings.base_url}/{path.lstrip('/')}"
49
+
50
+
51
+ _PLACEHOLDER = re.compile(r"\{([^{}]+)\}") # matches "{id}" and captures "id"
52
+
53
+ # What a check did with an endpoint (see ScanContext.record). An endpoint a check records nothing
54
+ # for counts as "not applicable": the check has nothing to look for there.
55
+ TESTED = "tested" # the check got a clear answer, finding or not
56
+ NOT_TESTED = "not tested" # the check applies here but could not reach a verdict; a reason is given
57
+
58
+
59
+ def sample_value(type_label: str | None) -> str:
60
+ """A harmless stand-in for a path parameter of the given type: "1" for numbers, else "test"."""
61
+ return "1" if type_label in ("integer", "number") else "test"
62
+
63
+
64
+ def fill_path(endpoint: Endpoint, values: dict[str, str] | None = None) -> str:
65
+ """Turn "/orders/{id}" into "/orders/1".
66
+
67
+ Each placeholder takes its value from `values` if given, otherwise a sample of the right type
68
+ (see sample_value). Values are URL-encoded, so a value can't add extra path segments.
69
+ """
70
+ values = values or {}
71
+ types = {p.name: p.type for p in endpoint.parameters if p.location == "path"}
72
+
73
+ def replace(match: re.Match[str]) -> str:
74
+ name = match.group(1)
75
+ value = values.get(name, sample_value(types.get(name)))
76
+ return quote(str(value), safe="") # safe="" also encodes "/", so it stays one segment
77
+
78
+ return _PLACEHOLDER.sub(replace, endpoint.path)
79
+
80
+
81
+ def sample_body(endpoint: Endpoint, text: str = "test") -> dict[str, object]:
82
+ """The smallest body the endpoint should accept: a harmless value for each required field.
83
+
84
+ When the spec marks no field as required, every declared field is filled in instead, so the
85
+ body is never empty. Text fields get `text`; other types get a neutral value of that type.
86
+ """
87
+ fields = [f for f in endpoint.request_fields if f.required] or endpoint.request_fields
88
+ samples: dict[str, object] = {"integer": 1, "number": 1, "boolean": False, "object": {}}
89
+ body: dict[str, object] = {}
90
+ for field in fields:
91
+ kind = field.type or "string"
92
+ body[field.name] = [] if kind.startswith("array") else samples.get(kind, text)
93
+ return body
94
+
95
+
96
+ class Check:
97
+ """Base class for a security check. Subclasses set `id` and `description` and implement `run`.
98
+
99
+ Subclasses are registered automatically (see __init_subclass__), so importing the checks
100
+ package is enough for `all_checks()` to find them. `default` can be set to False for a check
101
+ that should exist but not run unless asked for by id.
102
+ """
103
+
104
+ id: str = "" # a short stable name, e.g. "auth-required"; shown in reports and --only/--skip
105
+ owasp: str = "" # the OWASP API Security Top 10 (2023) category the check covers
106
+ description: str = "" # one line explaining what the check looks for
107
+ default: bool = True # whether the check runs in a normal scan (vs. only when named)
108
+
109
+ # A class-level list of every subclass, in definition order. Shared by all subclasses.
110
+ _registry: list[type["Check"]] = []
111
+
112
+ def __init_subclass__(cls, **kwargs: object) -> None:
113
+ # Runs once for each subclass when Python defines it. We record concrete checks (those
114
+ # that set an id); an intermediate base class without an id is skipped.
115
+ super().__init_subclass__(**kwargs)
116
+ if cls.id:
117
+ Check._registry.append(cls)
118
+
119
+ def run(self, endpoint: Endpoint, ctx: ScanContext) -> Iterator[Finding]:
120
+ """Yield a Finding for each problem found on `endpoint`. Yield nothing if all is well."""
121
+ raise NotImplementedError # every concrete check must implement this
122
+
123
+
124
+ def all_checks() -> list[type[Check]]:
125
+ """Every registered check class, in the order they were defined."""
126
+ return list(Check._registry)
127
+
128
+
129
+ def select_checks(only: list[str] | None = None, skip: list[str] | None = None) -> list[type[Check]]:
130
+ """Choose which checks to run: start from the defaults (or `only`), then drop any in `skip`.
131
+
132
+ `only` names an exact set by id (ignoring the `default` flag). `skip` removes ids from
133
+ whatever the starting set is. Unknown ids raise KeyError so a typo is caught, not ignored.
134
+ """
135
+ by_id = {cls.id: cls for cls in all_checks()}
136
+ _reject_unknown(only, by_id)
137
+ _reject_unknown(skip, by_id)
138
+
139
+ if only:
140
+ chosen = [by_id[i] for i in only]
141
+ else:
142
+ chosen = [cls for cls in all_checks() if cls.default]
143
+
144
+ skip_set = set(skip or [])
145
+ return [cls for cls in chosen if cls.id not in skip_set]
146
+
147
+
148
+ def _reject_unknown(ids: list[str] | None, by_id: dict[str, type[Check]]) -> None:
149
+ unknown = [i for i in (ids or []) if i not in by_id]
150
+ if unknown:
151
+ known = ", ".join(sorted(by_id)) or "(none)"
152
+ raise KeyError(f"unknown check id: {', '.join(unknown)}. Known checks: {known}")
apifuzz/checks/bola.py ADDED
@@ -0,0 +1,337 @@
1
+ """Check: can one logged-in user read another user's objects? (BOLA/IDOR, OWASP API1.)
2
+
3
+ Broken Object Level Authorization means an endpoint like GET /orders/{id} checks that you are
4
+ logged in but not that the order is yours. The check uses two test accounts, A and B (token
5
+ swapping):
6
+
7
+ 1. Recon: once per scan, call the protected GET endpoints that take no path parameter (e.g.
8
+ /me, /orders) as A and as B, and note which ids appear in each account's answers, and on
9
+ which endpoint. Those real ids are the candidates; for numeric ids, 1-5 are added as guesses.
10
+ 2. Cross-access: every candidate is requested as A and as B, so both directions are covered.
11
+ If the API enforces ownership, each object answers 2xx for at most one account.
12
+ 3. Same object: the two answers must be the same object. An API that answers 200 with an error
13
+ message to the wrong user is not leaking anything.
14
+ 4. Guard: an object both accounts can read is requested with no token. If that also succeeds,
15
+ the endpoint is simply unauthenticated: the auth-required check's finding, not BOLA.
16
+ 5. Confirmation: B's request is repeated. Only a reproducible result is reported.
17
+
18
+ Severity follows how strong the evidence is that the object belongs to one user:
19
+ CRITICAL proven: the id is listed in only one account's own collection (GET /orders shows it
20
+ to A and not to B), yet both could read it at GET /orders/{id}.
21
+ HIGH likely: the object has an owner field (owner, user_id...). That shows who made it,
22
+ not that it is private; a public post names its author too.
23
+ LOW not proven: both accounts can read it and nothing says whose it is.
24
+ An object that the API itself lists to both accounts is shared by design and is not reported
25
+ (unless it has an owner field). Paths the user marks as shared (--shared) are skipped.
26
+
27
+ Ids are attributed per collection: an id seen in /orders proves ownership only for /orders/{id},
28
+ never for /products/{id}, where the same number is a different object.
29
+
30
+ Nested paths such as /users/{userId}/orders/{orderId} are probed on the last id. The ids before
31
+ it are filled with account A's own (its user id from /me, say), so the question becomes "can B
32
+ read an object under A's part of the tree?". The listing is then GET /users/{A}/orders.
33
+
34
+ Limits: only read-only GET endpoints, and at most _MAX_IDS candidate ids. Recon sends only GET requests, so it changes nothing on the target.
35
+ """
36
+
37
+ import json
38
+ import re
39
+ from collections.abc import Iterator
40
+ from fnmatch import fnmatchcase
41
+
42
+ from apifuzz.checks.base import NOT_TESTED, TESTED, Check, ScanContext, fill_path
43
+ from apifuzz.endpoint_model import Endpoint
44
+ from apifuzz.errors import RequestError
45
+ from apifuzz.finding import Finding, Severity
46
+ from apifuzz.http_client import Exchange
47
+ from apifuzz.session_manager import IDENTITY_NAMES
48
+
49
+ _GUESS_IDS = ("1", "2", "3", "4", "5") # small ids a test system is likely to hold
50
+ _MAX_IDS = 12 # cap on candidate ids per endpoint, so the check stays a handful of requests
51
+ # Response fields that say "this object belongs to a particular user".
52
+ _OWNER_FIELDS = {
53
+ "owner", "owner_id", "ownerid", "user_id", "userid", "account_id", "accountid", "customer_id", "customerid",
54
+ }
55
+ # A string id we are willing to put in a URL: a UUID, a slug, a number. Nothing with spaces or slashes.
56
+ _SAFE_ID = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.:-]{0,63}$")
57
+
58
+ _REMEDIATION = (
59
+ "Before returning an object, check that it belongs to the caller (or that the caller is "
60
+ "allowed to see it), and answer 403 or 404 otherwise. Looking objects up through the caller "
61
+ "(e.g. WHERE id = ? AND owner_id = current_user) makes the check hard to forget."
62
+ )
63
+
64
+ Ids = tuple[set[str], set[str]] # (ids seen by account A, ids seen by account B)
65
+
66
+
67
+ def _json(exchange: Exchange) -> object:
68
+ try:
69
+ return json.loads(exchange.response.body)
70
+ except ValueError:
71
+ return None
72
+
73
+
74
+ def _has_owner_field(exchange: Exchange) -> bool:
75
+ """True if the JSON body is an object with an owner-like key, e.g. {"owner": 2}."""
76
+ body = _json(exchange)
77
+ return isinstance(body, dict) and any(k.lower() in _OWNER_FIELDS for k in body)
78
+
79
+
80
+ def _same_object(as_a: Exchange, as_b: Exchange) -> bool:
81
+ """True if both accounts received the same object, not just the same status code.
82
+
83
+ Identical bodies count. So do two JSON objects that agree on most of their fields, since an
84
+ API may add a per-viewer field such as "is_mine". An empty body gives nothing away.
85
+ """
86
+ a, b = _json(as_a), _json(as_b)
87
+ if a is None or b is None:
88
+ text = as_a.response.body.strip()
89
+ return text not in ("", "null") and text == as_b.response.body.strip()
90
+ if a == b:
91
+ return a not in ({}, [], "")
92
+ if isinstance(a, dict) and isinstance(b, dict):
93
+ same = sum(1 for key in a.keys() & b.keys() if a[key] == b[key])
94
+ return same >= 1 and same * 2 >= len(a.keys() | b.keys())
95
+ return False
96
+
97
+
98
+ def _as_id(value: object) -> str | None:
99
+ """An id value as text, or None if it isn't one: a positive whole number or a short safe string."""
100
+ if isinstance(value, bool):
101
+ return None
102
+ if isinstance(value, int):
103
+ return str(value) if 0 < value < 10**12 else None
104
+ if isinstance(value, str) and _SAFE_ID.match(value):
105
+ return value
106
+ return None
107
+
108
+
109
+ def _is_id_key(key: str) -> bool:
110
+ k = key.lower()
111
+ return k == "id" or k.endswith("_id") or key.endswith("Id") # id, order_id, orderId
112
+
113
+
114
+ def _ids_in(value: object, wanted=_is_id_key, depth: int = 0) -> set[str]:
115
+ """Id values (as text) under keys accepted by `wanted`, looking two levels deep."""
116
+ found: set[str] = set()
117
+ if depth > 2:
118
+ return found
119
+ if isinstance(value, list):
120
+ for item in value[:50]:
121
+ found |= _ids_in(item, wanted, depth + 1)
122
+ elif isinstance(value, dict):
123
+ for key, item in value.items():
124
+ if isinstance(item, (dict, list)):
125
+ found |= _ids_in(item, wanted, depth + 1)
126
+ elif wanted(key) and (text := _as_id(item)):
127
+ found.add(text)
128
+ return found
129
+
130
+
131
+ def _recon(ctx: ScanContext) -> dict[str, tuple[object, object]]:
132
+ """Once per scan: each parameterless protected GET's JSON answer for A and for B, by path."""
133
+ if "bola_recon" not in ctx.cache:
134
+ seen: dict[str, tuple[object, object]] = {}
135
+ for e in ctx.endpoints:
136
+ if e.method != "GET" or not e.requires_auth or "{" in e.path:
137
+ continue
138
+ if any(p.required and p.location in ("path", "query") for p in e.parameters):
139
+ continue # we'd have to invent a value; skip it
140
+ answers: list[object] = []
141
+ for account in ("a", "b"):
142
+ if not ctx.auth(account):
143
+ answers.append(None) # no such test account: nothing to ask as
144
+ continue
145
+ try:
146
+ ex = ctx.client.request("GET", ctx.url(e.path), headers=ctx.auth(account))
147
+ except RequestError:
148
+ answers.append(None) # recon is best-effort; the probes themselves still run
149
+ continue
150
+ answers.append(_json(ex) if ex.response.ok else None)
151
+ seen[e.path.rstrip("/") or "/"] = (answers[0], answers[1])
152
+ ctx.cache["bola_recon"] = seen
153
+ return ctx.cache["bola_recon"] # type: ignore[return-value]
154
+
155
+
156
+ def _collection(endpoint: Endpoint, name: str) -> str:
157
+ """The path that lists this endpoint's objects: "/orders/{id}" -> "/orders"."""
158
+ return endpoint.path.split("{" + name + "}", 1)[0].rstrip("/") or "/"
159
+
160
+
161
+ def _listed(recon: dict[str, tuple[object, object]], collection: str, name: str) -> Ids | None:
162
+ """Ids each account sees in its own listing of this collection, or None if there is no listing."""
163
+ if collection not in recon:
164
+ return None
165
+ own = name.lower().replace("_", "") # "order_id" and "orderId" both become "orderid"
166
+
167
+ def wanted(key: str) -> bool:
168
+ return key.lower().replace("_", "") in ("id", own)
169
+
170
+ answer_a, answer_b = recon[collection]
171
+ if answer_a is None or answer_b is None:
172
+ return None # one listing is missing, so "only A lists it" can't be claimed
173
+ return _ids_in(answer_a, wanted), _ids_in(answer_b, wanted)
174
+
175
+
176
+ # Collections whose objects are the users themselves: there, "my object" is the caller's own id.
177
+ _USER_COLLECTIONS = {"users", "user", "accounts", "account", "profiles", "members", "customers"}
178
+
179
+
180
+ def own_id(ctx: ScanContext, endpoint: Endpoint, name: str) -> str | None:
181
+ """The id of an object that belongs to test account A, for path parameter `name`."""
182
+ recon = _recon(ctx)
183
+ collection = _collection(endpoint, name)
184
+ own = name.lower().replace("_", "")
185
+ listing = recon.get(collection, (None, None))[0] # what account A sees at e.g. GET /orders
186
+ ids = sorted(_ids_in(listing, lambda key: key.lower().replace("_", "") in ("id", own))) if listing is not None else []
187
+ if ids:
188
+ return ids[0]
189
+ if collection.rsplit("/", 1)[-1].lower() in _USER_COLLECTIONS:
190
+ # /users/{id}: the caller's own id, from a "who am I" endpoint such as GET /me.
191
+ for path, (answer, _) in recon.items():
192
+ if path.rsplit("/", 1)[-1].lower() in IDENTITY_NAMES and isinstance(answer, dict):
193
+ if found := _as_id(answer.get("id")):
194
+ return found
195
+ return None
196
+
197
+
198
+ def _nested_listing(ctx: ScanContext, endpoint: Endpoint, name: str, values: dict[str, str]) -> Ids | None:
199
+ """For a nested path: the ids A and B see at the listing under A's own parent ids.
200
+
201
+ For /users/{userId}/orders/{orderId} with userId = A's id, that is GET /users/<A>/orders.
202
+ B being refused there (401/403/404) counts as "B is shown none of them".
203
+ """
204
+ template = _collection(endpoint, name)
205
+ lister = next((e for e in ctx.endpoints if e.method == "GET" and e.path.rstrip("/") == template), None)
206
+ if lister is None:
207
+ return None
208
+ own = name.lower().replace("_", "")
209
+ url = ctx.url(fill_path(lister, values))
210
+ seen: list[set[str]] = []
211
+ for account in ("a", "b"):
212
+ try:
213
+ ex = ctx.client.request("GET", url, headers=ctx.auth(account))
214
+ except RequestError:
215
+ return None
216
+ if ex.response.ok:
217
+ seen.append(_ids_in(_json(ex), lambda key: key.lower().replace("_", "") in ("id", own)))
218
+ elif account == "b" and ex.response.status_code in (401, 403, 404):
219
+ seen.append(set())
220
+ else:
221
+ return None
222
+ return seen[0], seen[1]
223
+
224
+
225
+ class Bola(Check):
226
+ id = "bola"
227
+ owasp = "API1:2023 Broken Object Level Authorization"
228
+ description = "One user must not be able to read another user's object by changing its id (needs two accounts)."
229
+
230
+ def run(self, endpoint: Endpoint, ctx: ScanContext) -> Iterator[Finding]:
231
+ path_params = [p for p in endpoint.parameters if p.location == "path"]
232
+ # Only read-only requests are safe to repeat as another user.
233
+ if endpoint.method != "GET" or not endpoint.requires_auth or not path_params:
234
+ return
235
+ headers_a, headers_b = ctx.auth("a"), ctx.auth("b")
236
+ if not headers_a or not headers_b:
237
+ ctx.record(self.id, endpoint, NOT_TESTED, "needs two test accounts")
238
+ return
239
+ if any(fnmatchcase(endpoint.path, pattern) for pattern in ctx.settings.shared_paths):
240
+ ctx.record(self.id, endpoint, NOT_TESTED, "marked as shared on purpose (--shared)")
241
+ return
242
+ # The last id in the path is the one probed; any ids before it are account A's own.
243
+ path_params.sort(key=lambda p: endpoint.path.find("{" + p.name + "}"))
244
+ *parents, target = path_params
245
+ name = target.name
246
+ numeric = target.type in ("integer", "number")
247
+ recon = _recon(ctx)
248
+ values: dict[str, str] = {}
249
+ for parent in parents:
250
+ mine = own_id(ctx, endpoint, parent.name)
251
+ if mine is None:
252
+ ctx.record(self.id, endpoint, NOT_TESTED, f"no id of test account A is known for {{{parent.name}}}")
253
+ return
254
+ values[parent.name] = mine
255
+ if parents:
256
+ listed = _nested_listing(ctx, endpoint, name, values)
257
+ else:
258
+ listed = _listed(recon, _collection(endpoint, name), name)
259
+ own_a, own_b = listed or (set(), set())
260
+ everything = set().union(*(_ids_in(answer) for pair in recon.values() for answer in pair))
261
+ # Ids that only one account lists come first: they are the ones that can prove ownership.
262
+ ordered = [*sorted(own_a ^ own_b), *sorted(own_a & own_b), *sorted(everything)]
263
+ if numeric:
264
+ ordered = [i for i in ordered if i.isdigit()] + list(_GUESS_IDS) # guessing only works for numbers
265
+ candidates = list(dict.fromkeys(ordered))[:_MAX_IDS]
266
+ if not candidates:
267
+ ctx.record(self.id, endpoint, NOT_TESTED, "no real id is known for it, and its ids can't be guessed")
268
+ return
269
+
270
+ readable = False # did any candidate answer 2xx to at least one account?
271
+ for object_id in candidates:
272
+ url = ctx.url(fill_path(endpoint, {**values, name: object_id}))
273
+ as_a = ctx.client.request("GET", url, headers=headers_a)
274
+ as_b = ctx.client.request("GET", url, headers=headers_b)
275
+ readable = readable or as_a.response.ok or as_b.response.ok
276
+ if not (as_a.response.ok and as_b.response.ok):
277
+ continue # readable by one account or neither: what a correct API does
278
+ if not _same_object(as_a, as_b):
279
+ continue # two different answers (e.g. a 200 that carries an error): nothing leaked
280
+
281
+ anonymous = ctx.client.request("GET", url)
282
+ if anonymous.response.ok:
283
+ break # open to everyone: auth-required reports that, not BOLA
284
+ confirm = ctx.client.request("GET", url, headers=headers_b)
285
+ if not confirm.response.ok:
286
+ continue # not reproducible; don't report a flaky result
287
+
288
+ in_a, in_b = object_id in own_a, object_id in own_b
289
+ if in_a != in_b:
290
+ who, other = ("A", "B") if in_a else ("B", "A")
291
+ severity, title = Severity.CRITICAL, "One user can read another user's object"
292
+ note = (
293
+ f"GET {_collection(endpoint, name)} lists this id only for account {who}, "
294
+ f"yet account {other} could read it."
295
+ )
296
+ fix = _REMEDIATION
297
+ elif _has_owner_field(as_a):
298
+ severity, title = Severity.HIGH, "Object with an owner field is readable by both test accounts"
299
+ note = (
300
+ "The object names an owner, so it likely belongs to one user. This is not proof: "
301
+ "an object can name its author and still be public. If it is meant to be shared, "
302
+ "mark the path with --shared."
303
+ )
304
+ fix = _REMEDIATION
305
+ elif in_a and in_b:
306
+ continue # the API itself lists it to both accounts: shared by design
307
+ else:
308
+ severity, title = Severity.LOW, "Object readable by both test accounts (ownership not proven)"
309
+ note = "Nothing shows it belongs to one user, so it may be shared on purpose. Review it."
310
+ fix = (
311
+ "If the object belongs to one user, check that it belongs to the caller before "
312
+ "returning it and answer 403 or 404 otherwise. If it is meant to be shared, no "
313
+ "change is needed; mark the path with --shared to stop this note."
314
+ )
315
+ ctx.record(self.id, endpoint, TESTED)
316
+ yield Finding(
317
+ check=self.id,
318
+ owasp=self.owasp,
319
+ severity=severity,
320
+ title=title,
321
+ endpoint=f"{endpoint.method} {endpoint.path}",
322
+ detail=(
323
+ f"Object {name}={object_id} gave the same answer to test account A and test account B "
324
+ f"(confirmed on a second request), while a request without a token was refused. {note}"
325
+ ),
326
+ remediation=fix,
327
+ evidence=[as_a, as_b, anonymous, confirm],
328
+ )
329
+ return # one finding per endpoint is enough
330
+
331
+ if readable:
332
+ ctx.record(self.id, endpoint, TESTED)
333
+ else:
334
+ ctx.record(
335
+ self.id, endpoint, NOT_TESTED,
336
+ f"neither account could read any of the {len(candidates)} ids tried, so there was nothing to compare",
337
+ )