Files
Open_Ultra_SAST/tests/test_dispatch_value.py
T
Marius CiepluchandClaude Opus 5.5 ece4473c2b 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
2026-09-24 09:04:42 +02:00

76 lines
3.4 KiB
Python

"""A registry read returns its VALUE argument, not its context: a fact, loaded and checked."""
import dataclasses
import pytest
from openultrasast.model.specs import taint_specs
from openultrasast.semantic.facts import FactLoadError, load_facts
def _write(tmp_path, dispatch: str) -> None:
(tmp_path / "php.toml").write_text('language = "php"\n[[source]]\nid = "g"\npatterns = ["$_GET"]\n' + dispatch)
def test_the_shipped_facts_say_which_argument_a_filter_returns() -> None:
spec = taint_specs(language="php")["injection"]
assert "apply_filters:2" in spec.dispatch_value and "do_action:0" in spec.dispatch_value
def test_a_dispatch_row_declares_returns_with_a_position(tmp_path) -> None:
row = '[[dispatch]]\nid = "hooks"\nregister = ["add_filter"]\napply = ["apply_filters", "do_action"]\n'
_write(tmp_path, row + 'returns = ["apply_filters"]\nvalue_arg = 2\n')
(fact,) = load_facts(tmp_path).dispatches
assert fact.returns == ("apply_filters",) and fact.value_arg == 2
@pytest.mark.parametrize(
"row",
[
'returns = ["apply_filters"]\n', # a returning call without the value's position
'returns = ["not_an_apply_call"]\nvalue_arg = 2\n',
"value_arg = -1\n",
],
)
def test_a_malformed_dispatch_row_is_refused(tmp_path, row: str) -> None:
_write(tmp_path, '[[dispatch]]\nid = "hooks"\nregister = ["add_filter"]\napply = ["apply_filters"]\n' + row)
with pytest.raises(FactLoadError):
load_facts(tmp_path)
def test_request_arguments_must_name_register_calls(tmp_path) -> None:
row = '[[dispatch]]\nid = "hooks"\nregister = ["add_action"]\napply = ["do_action"]\n'
_write(tmp_path, row + 'request_arguments = ["register_rest_route"]\n')
with pytest.raises(FactLoadError):
load_facts(tmp_path)
def test_a_hook_callback_gets_the_registrys_arguments_not_the_requests(tmp_path) -> None:
"""`edit_user_profile` hands its callback the WP_User WordPress loaded; a shortcode's $atts are content."""
from openultrasast.mapping import analyze_entry_points
from openultrasast.model import scan
from openultrasast.model.regions import regions_for
from openultrasast.preprocess import build_file_target, enumerate_source_files
(tmp_path / "plugin.php").write_text(
"<?php\n"
"add_action('edit_user_profile', 'show_history');\n"
'function show_history($user) { global $wpdb; return $wpdb->get_var("SELECT 1 WHERE id = \'" . $user->ID . "\'"); }\n'
"add_shortcode('box', 'render_box');\n"
"function render_box($atts) { global $wpdb; return $wpdb->get_var(\"SELECT 1 WHERE n = '\" . $atts['n'] . \"'\"); }\n"
)
files = enumerate_source_files(tmp_path)
targets = [build_file_target(tmp_path, p) for p in files]
entries = analyze_entry_points(tmp_path, targets)
assert {e.function_name: e.registered_by for e in entries if e.registered_by} == {
"show_history": "add_action",
"render_box": "add_shortcode",
}
regions = {r.function: r for r in regions_for(entries, targets)}
assert regions["show_history"].entry_registry == "add_action"
assert not scan._parameters_are_input(regions["show_history"])
assert scan._parameters_are_input(regions["render_box"])
# 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)