fix(scan): a hook callback's parameters are the registry's arguments, not the request's

The core-hook-argument class from the second PMPro adjudication:
pmpro_membership_history_profile_fields($user) on `edit_user_profile` was
a route entry, so its parameters were attacker input -- and $user is the
WP_User WordPress loaded; its ID became an SQL injection.

A callback registered in a string-keyed registry is handed its arguments by
the matching apply call, not by the request. The mapper now records the
registration call on the entry (`registered_by`), regions carry it, and the
dispatch fact says which registrations DO hand a callback request-borne
arguments (`request_arguments`; WordPress: add_shortcode, whose $atts are
post content). Every other registered callback keeps its call depth but
its parameters stop being sources; request data that reaches an apply
call's arguments is still followed by the hook half of the taint query.
REST routes are not registry entries and keep the parameter rule.

Controls: the fact field is validated against `register`; a hook callback
is not input, a shortcode and a plain route are.

Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01EmM1j46qgKMh3zqD1QMwEo
This commit is contained in:
Marius Ciepluch
2026-09-24 09:04:42 +02:00
co-authored by Claude Opus 5.5
parent 7b8ff3cf44
commit ece4473c2b
6 changed files with 102 additions and 2 deletions
+16
View File
@@ -80,6 +80,11 @@ class DispatchFact:
# came out "tainted" because `$this` was passed as filter context. Empty keeps the engine's default.
returns: tuple[str, ...] = ()
value_arg: int = 0
# The `register` calls whose callbacks are handed REQUEST-BORNE arguments. Every other registration's
# callback receives what the matching apply call passes -- framework or plugin values such as the
# `WP_User` a profile hook gets -- so its parameters are not attacker input; where request data does
# reach an apply call's arguments, the hook half of the taint query follows it from there.
request_arguments: tuple[str, ...] = ()
@dataclass(frozen=True)
@@ -174,6 +179,7 @@ def _dispatches(value: object, language: str, path: Path) -> list[DispatchFact]:
language=language,
returns=_returning(item, path),
value_arg=_value_arg(item, path),
request_arguments=_request_arguments(item, path),
)
)
return facts
@@ -187,6 +193,16 @@ def _returning(item: dict[str, object], path: Path) -> tuple[str, ...]:
return returns
def _request_arguments(item: dict[str, object], path: Path) -> tuple[str, ...]:
calls = _strings(item.get("request_arguments", []), "request_arguments", path)
unknown = set(calls) - set(_strings(item.get("register"), "register", path))
if unknown:
raise FactLoadError(
f"dispatch {item.get('id')!r} in {path}: request_arguments names calls that are not register calls: {sorted(unknown)}"
)
return calls
def _value_arg(item: dict[str, object], path: Path) -> int:
value = item.get("value_arg", 0)
if not isinstance(value, int) or isinstance(value, bool) or value < 0: