Skip to content

Give a worker request the filter input of nobody but itself - #304

Merged
diolektor merged 1 commit into
mainfrom
fix/worker-mode-filter-input-leaks-get-post-cookie-across-requests
Aug 14, 2026
Merged

Give a worker request the filter input of nobody but itself#304
diolektor merged 1 commit into
mainfrom
fix/worker-mode-filter-input-leaks-get-post-cookie-across-requests

Conversation

@diolektor

Copy link
Copy Markdown
Contributor

What was wrong

In worker mode, filter_input(), filter_input_array() and filter_has_var() answered with other requests' query values, session cookies and body fields.

For INPUT_GET, INPUT_POST and INPUT_COOKIE those functions do not read the superglobals. ext/filter keeps 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:

filter_input(INPUT_GET,    'oxfilter_token')  expected null, got 'seed-query-value'
filter_input(INPUT_COOKIE, 'oxfilter_sid')    expected null, got 'seed-session-id'
filter_input(INPUT_POST,   'oxfilter_pw')     expected null, got 'seed-body-secret'
filter_input_array(INPUT_POST)                expected null, got ['oxfilter_pw','oxfilter_user']

$_GET, $_COOKIE and $_POST were 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 via module_registry, rather than through sapi_module.input_filter_init: that public hook only ZVAL_UNDEFs the arrays, while what releases them is the module's RSHUTDOWN — 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 for FILTER_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 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 handler 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, 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_ASSERT was not an option here: 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() 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 a declare(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 took 1.0e-5 s.

Boundaries this documents

Both are worker-mode only and are written up in docs/php/superglobals.md and the changelog entry:

  • Input read through the filter API is not readable after a pause that another request ran in. $_GET, $_POST and $_COOKIE travel with the request and are what to read after a suspension.
  • A FILTER_CALLBACK that does I/O holds the worker thread for its duration instead of multiplexing, so it becomes latency for whatever is queued behind it, and past QUEUE_WAIT_TIMEOUT_MS those queued requests are shed with 529. How long that can go on is set by whatever the callback is talking to and not by the server: default_socket_timeout is 60 seconds out of the box, mysqlnd.net_read_timeout a day. Validation that reaches a database or a cache is better run on the value after filter_input_array() has returned it.

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.

Check Result
hooks 48/48 (45 before, with 1 failure and 1 error on the leak)
fibers 32/32
async 36/36
worker 55/56 — shared/test_channel_async_usevar_stress, a pre-existing flaky stress test
host cargo fmt --check, cargo clippy --no-default-features -D warnings, cargo test --no-default-features clean
docs scripts/check-links.sh clean, llms.txt / llms-full.txt regenerated

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
diolektor merged commit a931b95 into main Aug 14, 2026
7 checks passed
@diolektor
diolektor deleted the fix/worker-mode-filter-input-leaks-get-post-cookie-across-requests branch August 14, 2026 22:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant