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.
@@ -0,0 +1,289 @@
1
+ """Check: does caller input reach a database query without being kept separate from it?
2
+
3
+ OWASP API8:2019 Injection. When an endpoint pastes a request value straight into a SQL (or
4
+ NoSQL) query instead of passing it as a bound parameter, the value can change what the query
5
+ does. A search box that builds `... LIKE '%<q>%'` by string formatting will, given a <q> that
6
+ closes the quote, run logic the caller chose instead of the logic the developer wrote.
7
+
8
+ This check DETECTS that flaw; it never uses it to read or change data. For each testable input it
9
+ sends a small set of marker values and watches the response *shape*, not its contents:
10
+
11
+ 1. Error signal. One value carries a single quote (``<marker>'``). If the server answers with a
12
+ database error (a 5xx whose body names SQL / SQLite / MySQL / Postgres / Mongo syntax), the
13
+ quote reached a query engine without being escaped.
14
+ 2. Boolean differential. Two values are sent that, pasted into a string query, are always-true
15
+ vs. always-false (``<marker>' OR '1'='1' -- `` vs. ``<marker>' AND '1'='2' -- ``). If the
16
+ always-true value produces a different result from the always-false one, the input is steering
17
+ the query's logic rather than being treated as plain text.
18
+ 3. NoSQL operator (body fields only). Where a scalar is expected, an operator-shaped object
19
+ (``{"$ne": <marker>}``) is sent. If it is accepted and changes the result, the value is
20
+ reaching a document query as structure, not data.
21
+
22
+ Each signal is confirmed on a second request before it is reported, so a one-off difference is
23
+ never a finding. The markers are inert: the check counts how many rows come back, never reads
24
+ their contents, and writes nothing.
25
+
26
+ What is probed, and what is left alone:
27
+ * Query-string parameters on GET/HEAD endpoints.
28
+ * JSON body fields, but only on endpoints that read or authenticate (login / search / lookup /
29
+ query shaped). Create/update endpoints are never probed with a body, so this check cannot
30
+ create or change data; mass-assignment covers those instead.
31
+
32
+ Severity: HIGH. A confirmed injection point is among the most serious API flaws.
33
+ """
34
+
35
+ import re
36
+ from collections.abc import Callable, Iterator
37
+
38
+ from apifuzz.checks.base import NOT_TESTED, TESTED, Check, ScanContext, fill_path, sample_body
39
+ from apifuzz.endpoint_model import Endpoint
40
+ from apifuzz.finding import Finding, Severity
41
+ from apifuzz.http_client import Exchange
42
+
43
+ _MARKER = "apifuzz" # a harmless base value no real record is expected to equal
44
+ _MAX_FIELDS = 5 # parameters/fields probed per endpoint, to keep the request count small
45
+
46
+ # Payloads appended to the marker. The trailing `-- ` comments out whatever the query puts after
47
+ # the value (a closing quote, a `%`), so the injected clause stands on its own if it is pasted in.
48
+ _QUOTE = _MARKER + "'" # unbalanced quote: breaks a string literal and surfaces a parse error
49
+ _TRUE = _MARKER + "' OR '1'='1' -- " # always true when pasted into a '...' context
50
+ _FALSE = _MARKER + "' AND '1'='2' -- " # always false in the same context
51
+ _OPERATOR = {"$ne": _MARKER} # an object where a scalar is expected: the NoSQL tell
52
+
53
+ # Statuses that mean the request was handled (so a baseline or a probe settles something). A 404
54
+ # for a sample value, or a 5xx with no database signature, settles nothing on its own.
55
+ _HANDLED = {200, 201, 202, 204, 400, 401, 403, 409, 422}
56
+
57
+ # Words (in a path segment or the operation id) that mark an endpoint whose body is a query, not
58
+ # new data: safe to probe with injection markers because nothing is created or changed.
59
+ _BODY_WORDS = {
60
+ "login", "signin", "sign-in", "sign_in", "token", "authenticate", "auth", "session", "sessions",
61
+ "search", "find", "lookup", "query", "filter", "suggest", "autocomplete",
62
+ }
63
+
64
+ # Words that mark a database error leaking back to the caller (matched case-insensitively).
65
+ _DB_ERROR = re.compile(
66
+ r"sql|sqlite|mysql|mariadb|postgres|psql|oracle|odbc|syntax error|unrecognized token|"
67
+ r"unterminated|unclosed quotation|near \"|operationalerror|programmingerror|mongo|bson",
68
+ re.IGNORECASE,
69
+ )
70
+
71
+ _PROBEABLE_TYPES = (None, "string", "integer", "number") # text or numbers, or a type the spec omitted
72
+
73
+
74
+ def _sample(param_type: str | None) -> str:
75
+ return "1" if param_type in ("integer", "number") else _MARKER
76
+
77
+
78
+ def _result_size(exchange: Exchange) -> int | None:
79
+ """How many records the response carries, or None when it has no countable collection.
80
+
81
+ A JSON array is counted directly; a JSON object is searched for its longest list value (for
82
+ bodies shaped like ``{"results": [...]}``). Anything else returns None, and the caller then
83
+ compares only status codes, never body length, so a payload echoed into the body can't be
84
+ mistaken for a bigger result set.
85
+ """
86
+ import json
87
+
88
+ try:
89
+ body = json.loads(exchange.response.body)
90
+ except ValueError:
91
+ return None
92
+ if isinstance(body, list):
93
+ return len(body)
94
+ if isinstance(body, dict):
95
+ lists = [v for v in body.values() if isinstance(v, list)]
96
+ if lists:
97
+ return max(len(v) for v in lists)
98
+ return None
99
+
100
+
101
+ def _is_db_error(exchange: Exchange) -> bool:
102
+ """True when the answer is a server error that names a database engine or a query parse error."""
103
+ return exchange.response.status_code >= 500 and bool(_DB_ERROR.search(exchange.response.body))
104
+
105
+
106
+ def _handled(exchange: Exchange) -> bool:
107
+ return exchange.response.status_code in _HANDLED
108
+
109
+
110
+ def _differs(a: Exchange, b: Exchange) -> bool:
111
+ """True when two answers differ in a way that implies query logic, not just payload text.
112
+
113
+ Preferred signal: a different number of records. Fallback: a different status code. Body
114
+ length is deliberately not used, because the payloads are different strings and an endpoint
115
+ that echoes its input would differ on length alone.
116
+ """
117
+ size_a, size_b = _result_size(a), _result_size(b)
118
+ if size_a is not None and size_b is not None:
119
+ return size_a != size_b
120
+ return a.response.status_code != b.response.status_code
121
+
122
+
123
+ class Injection(Check):
124
+ id = "injection"
125
+ owasp = "API8:2019 Injection"
126
+ description = "Request values must not reach a database query unescaped (SQL/NoSQL injection; detection only)."
127
+
128
+ def run(self, endpoint: Endpoint, ctx: ScanContext) -> Iterator[Finding]:
129
+ headers = None
130
+ if endpoint.requires_auth:
131
+ headers = ctx.auth("a") or ctx.auth("b")
132
+ if not headers:
133
+ # Only record for endpoints this check would otherwise probe, so unrelated
134
+ # endpoints aren't labelled "needs a test account".
135
+ if self._query_targets(endpoint) or self._body_targets(endpoint):
136
+ ctx.record(self.id, endpoint, NOT_TESTED, "needs a test account")
137
+ return
138
+
139
+ if endpoint.method in ("GET", "HEAD"):
140
+ yield from self._run_query(endpoint, ctx, headers)
141
+ elif endpoint.method in ("POST", "PUT", "PATCH"):
142
+ yield from self._run_body(endpoint, ctx, headers)
143
+
144
+ # --- query-string parameters ---------------------------------------------------------------
145
+
146
+ def _query_targets(self, endpoint: Endpoint) -> list:
147
+ if endpoint.method not in ("GET", "HEAD"):
148
+ return []
149
+ return [p for p in endpoint.parameters if p.location == "query" and p.type in _PROBEABLE_TYPES]
150
+
151
+ def _run_query(self, endpoint: Endpoint, ctx: ScanContext, headers) -> Iterator[Finding]:
152
+ targets = self._query_targets(endpoint)[:_MAX_FIELDS]
153
+ if not targets:
154
+ return
155
+ url = ctx.url(fill_path(endpoint))
156
+ base = {p.name: _sample(p.type) for p in endpoint.parameters if p.location == "query" and p.required}
157
+
158
+ tested = False
159
+ for param in targets:
160
+ def send(value: object, name: str = param.name) -> Exchange:
161
+ return ctx.client.request(endpoint.method, url, headers=headers, params={**base, name: value})
162
+
163
+ outcome = self._probe(endpoint, param.name, "query parameter", send, allow_nosql=False)
164
+ if outcome is _UNHANDLED:
165
+ ctx.record(self.id, endpoint, NOT_TESTED, f"a benign value for {param.name!r} was not handled, which settles nothing")
166
+ continue
167
+ tested = True
168
+ if outcome is not None:
169
+ yield outcome
170
+ if tested:
171
+ ctx.record(self.id, endpoint, TESTED)
172
+
173
+ # --- JSON body fields (read/auth endpoints only) -------------------------------------------
174
+
175
+ def _body_targets(self, endpoint: Endpoint) -> list:
176
+ if endpoint.method not in ("POST", "PUT", "PATCH") or not self._is_query_shaped(endpoint):
177
+ return []
178
+ return [f for f in endpoint.request_fields if f.type in _PROBEABLE_TYPES]
179
+
180
+ def _is_query_shaped(self, endpoint: Endpoint) -> bool:
181
+ words = {seg.lower() for seg in endpoint.path.split("/") if seg and "{" not in seg}
182
+ if endpoint.operation_id:
183
+ words |= {w.lower() for w in re.findall(r"[A-Za-z][a-z]+|[A-Z]+(?![a-z])", endpoint.operation_id)}
184
+ return bool(words & _BODY_WORDS)
185
+
186
+ def _run_body(self, endpoint: Endpoint, ctx: ScanContext, headers) -> Iterator[Finding]:
187
+ targets = self._body_targets(endpoint)[:_MAX_FIELDS]
188
+ if not targets:
189
+ return
190
+ url = ctx.url(fill_path(endpoint))
191
+ base = sample_body(endpoint, text=_MARKER)
192
+
193
+ tested = False
194
+ for field in targets:
195
+ def send(value: object, name: str = field.name) -> Exchange:
196
+ return ctx.client.request(endpoint.method, url, headers=headers, json={**base, name: value})
197
+
198
+ outcome = self._probe(endpoint, field.name, "body field", send, allow_nosql=True)
199
+ if outcome is _UNHANDLED:
200
+ ctx.record(self.id, endpoint, NOT_TESTED, f"a benign value for {field.name!r} was not handled, which settles nothing")
201
+ continue
202
+ tested = True
203
+ if outcome is not None:
204
+ yield outcome
205
+ if tested:
206
+ ctx.record(self.id, endpoint, TESTED)
207
+
208
+ # --- the shared decision logic -------------------------------------------------------------
209
+
210
+ def _probe(
211
+ self, endpoint: Endpoint, name: str, where: str, send: Callable[[object], Exchange], *, allow_nosql: bool
212
+ ) -> "Finding | object | None":
213
+ """Probe one input. Returns a Finding, None (clean), or _UNHANDLED (no usable baseline)."""
214
+ baseline = send(_MARKER)
215
+ if not _handled(baseline):
216
+ return _UNHANDLED
217
+
218
+ # 1) Error-based: an unbalanced quote that produces a database error, confirmed on a repeat.
219
+ quote = send(_QUOTE)
220
+ if _is_db_error(quote) and _is_db_error(send(_QUOTE)):
221
+ return self._finding(
222
+ endpoint, name, where, how="a database error",
223
+ detail=(
224
+ f"Sending the {where} {name!r} as {_QUOTE!r} (a value with one extra quote) was answered "
225
+ f"HTTP {quote.response.status_code} with a database error in the body, confirmed on a second "
226
+ "request. The quote reached the query engine unescaped, so the value is placed into the query "
227
+ "text rather than bound as a parameter."
228
+ ),
229
+ evidence=[baseline, quote],
230
+ )
231
+
232
+ # 2) Boolean differential: always-true vs. always-false, each confirmed on a repeat.
233
+ true1, false1 = send(_TRUE), send(_FALSE)
234
+ if _handled(true1) and _handled(false1) and _differs(true1, false1):
235
+ true2, false2 = send(_TRUE), send(_FALSE)
236
+ if _result_size(true2) == _result_size(true1) and _result_size(false2) == _result_size(false1) and _differs(true2, false2):
237
+ return self._finding(
238
+ endpoint, name, where, how="a boolean differential",
239
+ detail=(
240
+ f"An always-true value for the {where} {name!r} ({_TRUE!r}) and an always-false value "
241
+ f"({_FALSE!r}) were answered differently, and the difference repeated on a second pair of "
242
+ "requests. A value treated as plain text would give the same answer both times; a different "
243
+ "answer means the value is changing what the query selects."
244
+ ),
245
+ evidence=[true1, false1],
246
+ )
247
+
248
+ # 3) NoSQL operator object where a scalar is expected (body fields only).
249
+ if allow_nosql:
250
+ op1 = send(_OPERATOR)
251
+ if _handled(op1) and _differs(op1, baseline):
252
+ op2 = send(_OPERATOR)
253
+ if _result_size(op2) == _result_size(op1) and _differs(op2, baseline):
254
+ return self._finding(
255
+ endpoint, name, where, how="a NoSQL operator object",
256
+ detail=(
257
+ f"Sending the {where} {name!r} as the operator object {_OPERATOR!r}, where a plain value "
258
+ "is expected, changed the response compared with a plain value, and the change repeated. "
259
+ "The object is being interpreted as part of a document query rather than rejected as the "
260
+ "wrong type, which is how NoSQL operator injection works."
261
+ ),
262
+ evidence=[baseline, op1],
263
+ )
264
+ return None
265
+
266
+ def _finding(
267
+ self, endpoint: Endpoint, name: str, where: str, *, how: str, detail: str, evidence: list[Exchange]
268
+ ) -> Finding:
269
+ return Finding(
270
+ check=self.id,
271
+ owasp=self.owasp,
272
+ severity=Severity.HIGH,
273
+ title=f"{where.capitalize()} {name!r} reaches a database query unsafely (injection)",
274
+ endpoint=f"{endpoint.method} {endpoint.path}",
275
+ key=name,
276
+ detail=detail + f" This was detected by {how}; no data was read or changed.",
277
+ remediation=(
278
+ "Never build a query by pasting request values into its text. Use parameterized queries (bound "
279
+ "placeholders) or your data layer's query builder, so input is always sent as data, not as part of "
280
+ "the query. For NoSQL, reject operator-shaped objects where a scalar is expected, and validate the "
281
+ "type and range of every value. Do not return raw database errors to the caller."
282
+ ),
283
+ evidence=evidence,
284
+ )
285
+
286
+
287
+ # Sentinel returned by _probe when a benign baseline value was not even handled, so there is
288
+ # nothing to compare a probe against. Distinct from None (probed, clean).
289
+ _UNHANDLED = object()
@@ -0,0 +1,283 @@
1
+ """Check: does an update endpoint save fields the caller should not be able to set?
2
+
3
+ This is OWASP API3 (Broken Object Property Level Authorization), better known as mass
4
+ assignment. The spec says PATCH /users/{id} takes `name` and `email`, but the code copies the
5
+ whole request body onto the user record, so a caller who also sends `role` gets it saved.
6
+
7
+ How the check works, on one of test account A's own objects:
8
+
9
+ 1. Read the object (GET on the same path). Its fields that the spec does NOT list as writable
10
+ are the candidates, e.g. `role`. If none of those sticks, a few common hidden names are
11
+ tried as well (`role`, `is_admin`, `verified`).
12
+ 2. Send the update with exactly one extra field set to a probe value, then read the object back.
13
+ One field per request keeps the result easy to understand.
14
+ 3. If the read-back shows the probe value, and a second read confirms it, the field is writable
15
+ when it should not be: a finding.
16
+ 4. Put the original value back, and read once more to make sure. The finding says whether that
17
+ worked.
18
+
19
+ The probe proves the flaw without using it. A text field such as `role` is set to the marker
20
+ "apifuzz-probe", never to "admin", so the test account gains nothing. A true/false field is
21
+ flipped and a number is raised by one, both only until step 4 restores them.
22
+
23
+ Left alone on purpose, because a change there could not be undone safely:
24
+ - ids, owner fields and timestamps (changing an owner would hand the object to someone else)
25
+ - passwords, tokens and other secrets
26
+
27
+ Create endpoints (POST /orders) are probed only when the spec also has DELETE /orders/{id}, so
28
+ every object the check creates can be removed again: one plain object to learn the fields, then
29
+ one per extra field, each deleted straight after it is read. Without a DELETE the endpoint is
30
+ listed as not tested, because the test data could not be cleaned up.
31
+
32
+ Severity: CRITICAL for a field that controls what the user may do (role, is_admin, verified...),
33
+ HIGH for any other field the spec doesn't list as writable.
34
+ """
35
+
36
+ import json
37
+ from collections.abc import Iterator
38
+
39
+ from apifuzz.checks.base import NOT_TESTED, TESTED, Check, ScanContext, fill_path, sample_body
40
+ from apifuzz.checks.bola import _as_id, own_id
41
+ from apifuzz.endpoint_model import Endpoint
42
+ from apifuzz.finding import Finding, Severity
43
+ from apifuzz.http_client import Exchange
44
+
45
+ PROBE_TEXT = "apifuzz-probe" # the marker written into text fields
46
+ _MAX_FIELDS = 6 # fields of the object tried per endpoint
47
+ _MAX_CREATE_FIELDS = 3 # on create endpoints every try makes (and deletes) an object, so fewer
48
+ _HIDDEN_GUESSES = ("role", "is_admin", "verified") # tried only when no real field sticks
49
+ _MISSING = object() # "the object has no such field", as opposed to a field that is null
50
+
51
+ # Field names (lower case, underscores removed) that decide what a user is allowed to do.
52
+ _PRIVILEGE = {
53
+ "role", "roles", "admin", "isadmin", "isstaff", "superuser", "issuperuser", "permissions", "scope", "scopes",
54
+ "verified", "isverified", "emailverified", "active", "isactive", "plan", "tier", "group", "groups",
55
+ }
56
+ # Parts of a field name that mean "do not touch": identity, ownership, time stamps, secrets.
57
+ _UNTOUCHABLE_PARTS = (
58
+ "owner", "user", "tenant", "account", "created", "updated", "modified", "deleted", "date",
59
+ "password", "passwd", "secret", "token", "key", "hash", "salt",
60
+ )
61
+ def _norm(name: str) -> str:
62
+ return name.lower().replace("_", "").replace("-", "")
63
+
64
+
65
+ def _is_privilege(name: str) -> bool:
66
+ return _norm(name) in _PRIVILEGE
67
+
68
+
69
+ def _probeable(name: str, value: object) -> bool:
70
+ """True if the field can be changed and put back safely (see the module docstring)."""
71
+ lower = name.lower()
72
+ if lower == "id" or lower.endswith("_id") or name.endswith("Id") or lower.endswith("_at"):
73
+ return False
74
+ if any(part in lower for part in _UNTOUCHABLE_PARTS):
75
+ return False
76
+ return value is None or isinstance(value, (str, bool, int, float))
77
+
78
+
79
+ def _probe_value(original: object) -> object:
80
+ """A value that differs from the original, but grants as little as possible."""
81
+ if isinstance(original, bool):
82
+ return not original
83
+ if isinstance(original, (int, float)):
84
+ return original + 1
85
+ return PROBE_TEXT
86
+
87
+
88
+ def _object(exchange: Exchange) -> dict | None:
89
+ if not exchange.response.ok:
90
+ return None
91
+ try:
92
+ body = json.loads(exchange.response.body)
93
+ except ValueError:
94
+ return None
95
+ return body if isinstance(body, dict) else None
96
+
97
+
98
+ class MassAssignment(Check):
99
+ id = "mass-assignment"
100
+ owasp = "API3:2023 Broken Object Property Level Authorization"
101
+ description = "An update must not save fields the spec doesn't list as writable, such as role (needs one account)."
102
+
103
+ def run(self, endpoint: Endpoint, ctx: ScanContext) -> Iterator[Finding]:
104
+ if endpoint.method not in ("PATCH", "PUT", "POST") or not endpoint.requires_auth or not endpoint.request_fields:
105
+ return
106
+ headers = ctx.auth("a")
107
+ if not headers:
108
+ ctx.record(self.id, endpoint, NOT_TESTED, "needs a test account")
109
+ return
110
+ if endpoint.method == "POST":
111
+ yield from self._run_create(endpoint, ctx, headers)
112
+ return
113
+ path_params = [p for p in endpoint.parameters if p.location == "path"]
114
+ if len(path_params) > 1:
115
+ ctx.record(self.id, endpoint, NOT_TESTED, "has more than one path parameter (not supported yet)")
116
+ return
117
+ if not any(e.method == "GET" and e.path == endpoint.path for e in ctx.endpoints):
118
+ ctx.record(self.id, endpoint, NOT_TESTED, "has no GET on the same path to read the object back")
119
+ return
120
+
121
+ values: dict[str, str] = {}
122
+ if path_params:
123
+ param = path_params[0]
124
+ object_id = own_id(ctx, endpoint, param.name)
125
+ if object_id is None or (param.type in ("integer", "number") and not object_id.isdigit()):
126
+ ctx.record(self.id, endpoint, NOT_TESTED, "no object of the test account is known for it")
127
+ return
128
+ values[param.name] = object_id
129
+ url = ctx.url(fill_path(endpoint, values))
130
+
131
+ def read() -> dict | None:
132
+ return _object(ctx.client.request("GET", url, headers=headers))
133
+
134
+ before = read()
135
+ if before is None:
136
+ ctx.record(self.id, endpoint, NOT_TESTED, "the object could not be read before changing it")
137
+ return
138
+
139
+ declared = {f.name for f in endpoint.request_fields}
140
+ present = [k for k, v in before.items() if k not in declared and _probeable(k, v)]
141
+ present.sort(key=lambda k: not _is_privilege(k)) # privilege fields first (stable sort)
142
+ hidden = [n for n in _HIDDEN_GUESSES if n not in before and n not in declared]
143
+
144
+ found = False
145
+ ctx.record(self.id, endpoint, TESTED)
146
+ for field in [*present[:_MAX_FIELDS], *hidden]:
147
+ if found and field in hidden:
148
+ break # a real field already stuck; guessing hidden names would only add noise
149
+ original = before.get(field, _MISSING)
150
+ probe = _probe_value(None if original is _MISSING else original)
151
+ # PATCH changes only what it is given; PUT replaces the object, so send it back whole.
152
+ body = {field: probe} if endpoint.method == "PATCH" else {**before, field: probe}
153
+ write = ctx.client.request(endpoint.method, url, headers=headers, json=body)
154
+ if not write.response.ok:
155
+ continue # refused: what a correct API does with a field it doesn't accept
156
+ readback = ctx.client.request("GET", url, headers=headers)
157
+ after = _object(readback) or {}
158
+ if after.get(field, _MISSING) != probe:
159
+ continue # accepted but not saved: the field was ignored
160
+ confirm = _object(ctx.client.request("GET", url, headers=headers)) or {}
161
+ confirmed = confirm.get(field, _MISSING) == probe
162
+
163
+ # Put things back the way they were, whether or not the result is reported.
164
+ restore_value = None if original is _MISSING else original
165
+ restore_body = {field: restore_value} if endpoint.method == "PATCH" else dict(before)
166
+ restore = ctx.client.request(endpoint.method, url, headers=headers, json=restore_body)
167
+ final = read() or {}
168
+ restored = final.get(field, _MISSING) == original or (original is _MISSING and final.get(field) is None)
169
+ if not confirmed:
170
+ continue # not reproducible; don't report a flaky result
171
+
172
+ found = True
173
+ privilege = _is_privilege(field)
174
+ was = "did not exist" if original is _MISSING else f"was {json.dumps(original)}"
175
+ cleanup = (
176
+ "The original value was put back afterwards."
177
+ if restored
178
+ else f"The original value could NOT be put back: set {field!r} on this object by hand."
179
+ )
180
+ yield Finding(
181
+ check=self.id,
182
+ owasp=self.owasp,
183
+ severity=Severity.CRITICAL if privilege else Severity.HIGH,
184
+ title=f"Field {field!r} can be set by the caller (mass assignment)",
185
+ endpoint=f"{endpoint.method} {endpoint.path}",
186
+ key=field,
187
+ detail=(
188
+ f"The spec does not list {field!r} as a writable field, but sending it in the update "
189
+ f"saved it: it {was}, and the object read back showed {json.dumps(probe)} (confirmed "
190
+ f"on a second read). "
191
+ + ("This field controls what the user may do, so a caller can raise their own privileges. " if privilege else "")
192
+ + cleanup
193
+ ),
194
+ remediation=(
195
+ "Copy only the fields the endpoint is meant to accept from the request onto the object "
196
+ "(an explicit allowlist or a dedicated input model), and ignore or reject everything else. "
197
+ "Never bind the whole request body to the stored record."
198
+ ),
199
+ evidence=[write, readback, restore],
200
+ )
201
+
202
+ def _run_create(self, endpoint: Endpoint, ctx: ScanContext, headers: dict[str, str]) -> Iterator[Finding]:
203
+ """Mass assignment on a create endpoint. Every object created here is deleted again."""
204
+ if "{" in endpoint.path:
205
+ ctx.record(self.id, endpoint, NOT_TESTED, "is a create endpoint under a path parameter (not supported yet)")
206
+ return
207
+ base = endpoint.path.rstrip("/")
208
+ # The single-object path, e.g. "/orders/{id}": it must offer DELETE (cleanup) and GET (read-back).
209
+ item = [e for e in ctx.endpoints if e.path.startswith(base + "/{") and e.path.endswith("}") and e.path.count("{") == 1]
210
+ deleter = next((e for e in item if e.method == "DELETE"), None)
211
+ reader = next((e for e in item if e.method == "GET"), None)
212
+ if deleter is None or reader is None:
213
+ ctx.record(
214
+ self.id, endpoint, NOT_TESTED,
215
+ "has no DELETE and GET for the created object, so test data could not be cleaned up",
216
+ )
217
+ return
218
+ param = next(p.name for p in deleter.parameters if p.location == "path")
219
+ url = ctx.url(endpoint.path)
220
+ leftovers: list[str] = []
221
+
222
+ def create(extra: dict[str, object]) -> tuple[Exchange, dict | None, str | None]:
223
+ ex = ctx.client.request("POST", url, headers=headers, json={**sample_body(endpoint), **extra})
224
+ created = _object(ex)
225
+ return ex, created, _as_id((created or {}).get("id"))
226
+
227
+ def remove(object_id: str) -> Exchange:
228
+ ex = ctx.client.request("DELETE", ctx.url(fill_path(deleter, {param: object_id})), headers=headers)
229
+ if not ex.response.ok:
230
+ leftovers.append(object_id)
231
+ return ex
232
+
233
+ _, plain, plain_id = create({})
234
+ if plain is None or plain_id is None:
235
+ ctx.record(self.id, endpoint, NOT_TESTED, "a plain create did not answer with an object that has an id")
236
+ return
237
+ remove(plain_id)
238
+
239
+ declared = {f.name for f in endpoint.request_fields}
240
+ present = [k for k, v in plain.items() if k not in declared and _probeable(k, v)]
241
+ present.sort(key=lambda k: not _is_privilege(k))
242
+ hidden = [n for n in _HIDDEN_GUESSES if n not in plain and n not in declared]
243
+ ctx.record(self.id, endpoint, TESTED)
244
+ for field in [*present, *hidden][:_MAX_CREATE_FIELDS]:
245
+ original = plain.get(field, _MISSING)
246
+ probe = _probe_value(None if original is _MISSING else original)
247
+ write, _, object_id = create({field: probe})
248
+ if object_id is None:
249
+ continue # refused, or no object came back: nothing was created to look at
250
+ readback = ctx.client.request("GET", ctx.url(fill_path(reader, {param: object_id})), headers=headers)
251
+ cleanup = remove(object_id)
252
+ if (_object(readback) or {}).get(field, _MISSING) != probe:
253
+ continue # the extra field was ignored
254
+ privilege = _is_privilege(field)
255
+ was = "is not normally there" if original is _MISSING else f"is normally {json.dumps(original)}"
256
+ yield Finding(
257
+ check=self.id,
258
+ owasp=self.owasp,
259
+ severity=Severity.CRITICAL if privilege else Severity.HIGH,
260
+ title=f"Field {field!r} can be set by the caller on create (mass assignment)",
261
+ endpoint=f"{endpoint.method} {endpoint.path}",
262
+ key=field,
263
+ detail=(
264
+ f"The spec does not list {field!r} as a field of the create request, but sending it "
265
+ f"saved it: it {was}, and the new object read back showed {json.dumps(probe)}. "
266
+ + ("This field controls what the user may do. " if privilege else "")
267
+ + (
268
+ "The object created for this test was deleted again."
269
+ if cleanup.response.ok
270
+ else f"The object created for this test (id {object_id}) could NOT be deleted: remove it by hand."
271
+ )
272
+ ),
273
+ remediation=(
274
+ "Build the new object only from the fields the endpoint is meant to accept (an explicit "
275
+ "allowlist or a dedicated input model), and set everything else on the server."
276
+ ),
277
+ evidence=[write, readback, cleanup],
278
+ )
279
+ if leftovers:
280
+ notes = ctx.cache.setdefault("leftovers", [])
281
+ notes.append( # type: ignore[attr-defined]
282
+ f"{endpoint.method} {endpoint.path}: test objects could not be deleted, ids {', '.join(leftovers)}"
283
+ )