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 +13 -0
- apifuzz/__main__.py +5 -0
- apifuzz/checks/__init__.py +9 -0
- apifuzz/checks/auth_required.py +62 -0
- apifuzz/checks/base.py +152 -0
- apifuzz/checks/bola.py +337 -0
- apifuzz/checks/injection.py +289 -0
- apifuzz/checks/mass_assignment.py +283 -0
- apifuzz/checks/rate_limit.py +168 -0
- apifuzz/cli.py +425 -0
- apifuzz/docker_verify.py +168 -0
- apifuzz/endpoint_model.py +180 -0
- apifuzz/errors.py +37 -0
- apifuzz/finding.py +55 -0
- apifuzz/http_client.py +221 -0
- apifuzz/report.py +261 -0
- apifuzz/scanner.py +97 -0
- apifuzz/session_manager.py +146 -0
- apifuzz/settings.py +270 -0
- apifuzz/spec_loader.py +138 -0
- apifuzz-0.2.0.dist-info/METADATA +350 -0
- apifuzz-0.2.0.dist-info/RECORD +25 -0
- apifuzz-0.2.0.dist-info/WHEEL +4 -0
- apifuzz-0.2.0.dist-info/entry_points.txt +2 -0
- apifuzz-0.2.0.dist-info/licenses/LICENSE +21 -0
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
|
+
)
|