Give a worker request the filter input of nobody but itself - #304
Merged
diolektor merged 1 commit intoAug 14, 2026
Merged
Conversation
Security: - filter_input(), filter_input_array() and filter_has_var() answered with other requests' query values, session cookies and body fields in worker mode. The three constants they read for — INPUT_GET, INPUT_POST, INPUT_COOKIE — do not read the superglobals: the filter extension keeps a parsed copy of its own, filled every time the engine rebuilds one of those superglobals and emptied when a request starts up, which a worker does once per worker rather than once per request. Filled per request and emptied per worker, the copy accumulated for the life of the process: a request that sent no query string at all read the ?token= of one served minutes earlier, and a plain GET read the POST body of the last form submitted to that worker, while $_GET, $_COOKIE and $_POST were empty exactly as they should have been. The copy is now given back at the start of every request, before the rebuild that refills it. - The same leak had a second path under fiber multiplexing, since that copy is one set of arrays per thread rather than per request: a request served while another was parked read the parked one's input, and the parked one read that request's after resuming. A resume now gives the copy back too, but only when something else has owned it in the window — a generation counter parked with the fiber answers that — so an ordinary pause with nothing else running does not cost a request its own input. Fix: - The copy is released through ext/filter's own request shutdown, reached via module_registry, rather than through sapi_module.input_filter_init. That hook only undefines the arrays; releasing them is the module's RSHUTDOWN, which a worker runs once, so calling the hook per request would have traded a data leak for one orphaned hash table per input type per request in a pool that is given back only when the worker exits. - filter_input_array() given a per-field definition array can no longer suspend the calling fiber. That form reads the storage through the module-globals slot itself and re-reads it once per field, with userland in between whenever a field asks for FILTER_CALLBACK — so a request parked inside such a callback came back to memory released by the next request's reset. Measured before the guard: the field read after the callback answered null, from a lookup in a released hash table. The handler now holds zend_fiber_switch_block() for that form alone, which every suspend point honours by taking its blocking path. Every other way in reaches the original untouched: filter_has_var() runs no userland, and filter_input() and filter_input_array() without a definition array copy the slot before any filter runs — ZVAL_DUP is their only read of it, on 8.4, 8.5 and master alike — so blocking there would protect nothing while costing a userland scheduler a suspension point for the length of the call. - The reset refuses to run while such a call is reading, and logs why. That the guard suffices rests on an invariant nothing enforces — that every suspend point checks zend_fiber_switch_blocked() — and a fifth one added without the check would park that frame silently, leaving the next request's reset to free the array it holds. A thread-local reader count, raised for the guarded call, turns that from a use-after-free with no witness into a log line and a skipped reset: the request then reads input that is not its own, which is the leak this commit closes, but nothing dereferences freed memory and the log names the real cause. ZEND_ASSERT was not an option — the images run a release build of PHP, where it compiles to nothing. - Hooked sleep() and usleep() no longer return instantly where the fiber cannot be suspended. They are meant to fall back to the native builtin there, as the hooked socket read and stream_select() do, and instead skipped the wait: a throttle stopped throttling and a backoff became a spin, in microseconds and with nothing logged. Reachable before this change from a ticks handler, from pcntl's signal dispatch, while a request's input is being built, and under a userland fiber scheduler; the guard above made it a documented path. Measured: usleep(300000) inside a guarded callback took 1.0e-5 s. Docs: - The superglobals page now describes what filter_input() reads for GET, POST and COOKIE, and both worker-mode boundaries: input is unreadable after a pause another request ran in, and a FILTER_CALLBACK that does I/O holds the worker for its duration — long enough, and requests queued behind it are shed. What bounds that wait is named too, because the server does not: a stream wrapper gives up after default_socket_timeout, 60 seconds by default, while mysqlnd waits mysqlnd.net_read_timeout, a day by default. Tests: - Four lines in the hooks profile, one worker: a POST with a query string and a cookie, a clean GET that must see none of it, a suspension covered from both sides through an inner self-request, and a FILTER_CALLBACK that parks with that inner request waiting. Each fails on a build without the matching half of the fix. 48 tests in the hooks profile (45 before the fix, 1 failed and 1 errored on the leak).
diolektor
deleted the
fix/worker-mode-filter-input-leaks-get-post-cookie-across-requests
branch
August 14, 2026 22:37
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What was wrong
In worker mode,
filter_input(),filter_input_array()andfilter_has_var()answered with other requests' query values, session cookies and body fields.For
INPUT_GET,INPUT_POSTandINPUT_COOKIEthose functions do not read the superglobals.ext/filterkeeps a parsed copy of its own in its module globals, filled by the SAPI input filter every time the engine rebuilds one of those superglobals, and emptied when a request starts up. A worker starts up once — per worker, not per request — while the refill happens on every request. Filled per request and emptied per worker, the copy accumulated for the life of the process.Measured on a single worker, second request carrying no query string, no cookie and no body:
$_GET,$_COOKIEand$_POSTwere empty throughout, exactly as they should have been — so the two ways of reading input disagreed, and the wrong one was the API applications reach for because it is the safe way to read input. Nothing was logged and no error was raised.Traditional, framework and SPA serving were never affected: there each request starts up on its own, which is what empties the copy.
What changed
The copy is given back at the start of every request, before the rebuild that refills it. It is released through
ext/filter's own request shutdown, reached viamodule_registry, rather than throughsapi_module.input_filter_init: that public hook onlyZVAL_UNDEFs the arrays, while what releases them is the module'sRSHUTDOWN— which a worker runs once. Calling the public hook per request would have traded a data leak for one orphaned hash table per input type per request, in a pool given back only when the worker exits.The same leak had a second path under fiber multiplexing, since the copy is one set of arrays per thread rather than per request: a request served while another was parked read the parked one's input, and the parked one read that request's after resuming. A resume now gives the copy back too, but only when something else has owned it in the window — a generation counter parked with the fiber answers that. An ordinary pause with nothing else running on the worker costs a request nothing: it finds its own input where it left it.
filter_input_array()given a per-field definition array can no longer suspend the calling fiber. That form reads the storage through the module-globals slot itself and re-reads it once per field, with userland in between whenever a field asks forFILTER_CALLBACK— so a request parked inside such a callback came back to memory the next request's reset had released. Measured before the guard: the field read after the callback answerednull, from a lookup in a released hash table. The handler now holdszend_fiber_switch_block()for that form alone, which every suspend point honours by taking its blocking path.Every other way in reaches the original handler untouched:
filter_has_var()runs no userland, andfilter_input()andfilter_input_array()without a definition array copy the slot before any filter runs —ZVAL_DUPis their only read of it, identically on 8.4, 8.5 and master — so blocking there would protect nothing while costing a userland fiber scheduler a suspension point for the length of the call.The reset refuses to run while such a call is reading, and logs why. That the guard suffices rests on an invariant nothing enforces — that every suspend point checks
zend_fiber_switch_blocked()— and a fifth suspend point added without that check would park the frame silently, leaving the next request's reset to free the array it holds. A thread-local reader count turns that from a use-after-free with no witness into a log line and a skipped reset.ZEND_ASSERTwas not an option here: the images run a release build of PHP, where it compiles to nothing.Hooked
sleep()andusleep()no longer return instantly where the fiber cannot be suspended. They are meant to fall back to the native builtin there, as the hooked socket read andstream_select()already do, and instead skipped the wait entirely: a throttle stopped throttling and a backoff became a spin, in microseconds and with nothing logged. Reachable before this change from adeclare(ticks)handler, from pcntl's signal dispatch, while a request's input is being built, and under a userland fiber scheduler. Measured:usleep(300000)inside a guarded callback took1.0e-5s.Boundaries this documents
Both are worker-mode only and are written up in
docs/php/superglobals.mdand the changelog entry:$_GET,$_POSTand$_COOKIEtravel with the request and are what to read after a suspension.FILTER_CALLBACKthat does I/O holds the worker thread for its duration instead of multiplexing, so it becomes latency for whatever is queued behind it, and pastQUEUE_WAIT_TIMEOUT_MSthose queued requests are shed with529. How long that can go on is set by whatever the callback is talking to and not by the server:default_socket_timeoutis 60 seconds out of the box,mysqlnd.net_read_timeouta day. Validation that reaches a database or a cache is better run on the value afterfilter_input_array()has returned it.Tests
Four lines in the
hooksprofile, one worker: a POST with a query string and a cookie, a clean GET that must see none of it, a suspension covered from both sides through an inner self-request, and aFILTER_CALLBACKthat parks with that inner request waiting. Each fails on a build without the matching half of the fix.hooksfibersasyncworkershared/test_channel_async_usevar_stress, a pre-existing flaky stress testcargo fmt --check,cargo clippy --no-default-features -D warnings,cargo test --no-default-featurescleanscripts/check-links.shclean,llms.txt/llms-full.txtregenerated