fix(mapping): record every callback of a register_rest_route call as a route

The mapper took the FIRST 'callback' in a 12-line window. One registration
commonly carries an array of endpoints; PMPro's /discount_code has a GET
and a POST handler, so the writer, pmpro_rest_api_set_discount_code, was an
ordinary `function` entry. Since da0ee57 such an entry's parameters are
not attacker input, so its $request stopped being a source and the REST
route that injects SQL through the discount-code save() had no source at
all.

The argument list of the call is now read to its balancing parenthesis,
every callback in it is a route, and each keeps the permission callback of
its own endpoint (the first after it before the next callback, else the
last before it). PMPro: 15 -> 18 REST routes.

This restores the source, not the finding: traced unfiltered, the engine
does not carry $discount_code->code = $code (written in the handler) into
$this->code inside save() once other methods have been called on the
object; the one path it reports detours through a prepared query's result.
Recorded as the next recall gap: a cross-file field join keyed by class.

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 11:08:22 +02:00
co-authored by Claude Opus 5.5
parent ece4473c2b
commit 53d20691c3
2 changed files with 67 additions and 4 deletions
+23
View File
@@ -73,3 +73,26 @@ def test_a_hook_callback_gets_the_registrys_arguments_not_the_requests(tmp_path)
# A route that did not come from a registry keeps its parameters as input.
route = dataclasses.replace(regions["show_history"], entry_registry="", entry_kind="route")
assert scan._parameters_are_input(route)
def test_every_callback_of_one_rest_registration_is_a_route(tmp_path) -> None:
"""PMPro registers a GET and a POST handler in one `register_rest_route`; taking the first callback left
the writer an ordinary function, so its `$request` was no source."""
from openultrasast.mapping import analyze_entry_points
from openultrasast.preprocess import build_file_target, enumerate_source_files
(tmp_path / "rest.php").write_text(
"<?php\nclass Routes {\n function register() {\n"
" register_rest_route( $ns, '/code',\n array(\n"
" array( 'methods' => 'GET', 'callback' => array( $this, 'read_code' ),\n"
" 'permission_callback' => array( $this, 'can_read' ) ),\n"
" array( 'methods' => 'POST', 'callback' => array( $this, 'write_code' ), 'permission_callback' => '__return_true' ),\n"
" ));\n }\n"
" function read_code($request) { return 1; }\n function write_code($request) { return 2; }\n}\n"
)
files = enumerate_source_files(tmp_path)
entries = analyze_entry_points(tmp_path, [build_file_target(tmp_path, p) for p in files])
routes = {e.function_name: e for e in entries if e.name == "wp:rest_route"}
assert set(routes) == {"read_code", "write_code"}
# Each endpoint keeps its OWN permission callback.
assert routes["write_code"].access_level == "public" and routes["read_code"].access_level != "public"