Skip to content

fix: trusted request coroutine safety#384

Merged
binaryfire merged 7 commits into
0.4from
fix/trusted-requests
May 12, 2026
Merged

fix: trusted request coroutine safety#384
binaryfire merged 7 commits into
0.4from
fix/trusted-requests

Conversation

@binaryfire
Copy link
Copy Markdown
Collaborator

This PR makes trusted proxy and trusted host request state coroutine-safe while preserving the same public API and end-user behavior as Laravel / Symfony.

Symfony stores trusted proxy and host configuration in static properties on Request. That fine for the FPM request lifecycle, but is unsafe in Hypervel's long-running Swoole workers where multiple requests run concurrently in the same process. One request can update trusted proxy state while another request is still resolving $request->ip(), $request->host(), $request->isSecure(), URL generation, or forwarded-prefix handling.

Hypervel now keeps this state on the current Request instance instead. The existing Request::setTrustedProxies(), Request::setTrustedHosts(), TrustProxies, and TrustHosts APIs continue to work as before, but their state is scoped to the request being handled.

What Changed

  • Added per-request trusted proxy / host state to Hypervel\Http\Request.
  • Reimplemented the relevant Symfony request methods using the request-scoped state:
    • getClientIps()
    • getBaseUrl()
    • getPort()
    • isSecure()
    • getHost()
    • isFromTrustedProxy()
  • Preserved Symfony parity for forwarded header parsing, proxy IP filtering, host validation, and trusted header conflict handling.
  • Reset trusted request caches and one-shot validation flags during request lifecycle transitions.
  • Copied trusted request configuration through createFrom() and createFromBase() when converting Hypervel requests.
  • Reset trusted request caches during __clone(), which also covers Symfony's duplicate() flow.
  • Kept duplicate() focused on Hypervel's existing file filtering adaptation.

API Compatibility

This doesn't change the public API or the expected application behavior. Applications can continue configuring trusted proxies and trusted hosts the same way:

  • trustProxies(...)
  • trustHosts(...)
  • TrustProxies::at(...)
  • TrustHosts::at(...)
  • Request::setTrustedProxies(...)
  • Request::setTrustedHosts(...)

The difference is purely internal.

Tests

Added coverage for:

  • Trusted proxy IP resolution.
  • Trusted host validation.
  • X-Forwarded-* handling for IP, host, proto, port, and prefix.
  • Forwarded vs X-Forwarded-* conflict behavior.
  • REMOTE_ADDR and PRIVATE_SUBNETS sentinel handling.
  • createFrom() preserving trusted request state.
  • createFromBase() preserving trusted request state for Hypervel requests.
  • initialize() resetting trusted request state.
  • clone and duplicate() preserving configuration while resetting caches / one-shot flags.
  • setTrustedProxies() and setTrustedHosts() clearing stale caches.
  • Coroutine isolation for trusted proxies, trusted hosts, client IPs, and one-shot host validation flags.
  • End-to-end TrustProxies and TrustHosts middleware behavior.
  • FormRequest resolution behind TrustProxies.
  • Concurrent Testbench HTTP requests through TrustProxies.
  • Single-request parity with Symfony for representative trusted proxy behavior.

… test

Sets RequestContext::set() before calling Request::setTrustedProxies() and
passes the same Request instance into the UrlGenerator. Works with both the
existing static-storage implementation and the upcoming per-instance trust
state, so the tree stays green across the migration.
… prefix test

Reorders setup to install the Request in RequestContext before calling
Request::setTrustedProxies(), so the test exercises the trust state through
the per-coroutine context the new implementation requires.
Symfony stores trusted proxy/host configuration on Request as class-level
statics. Under Swoole's coroutine concurrency that becomes shared mutable
state across all in-flight requests in a worker — a request that yields
during async I/O can resume after another coroutine has overwritten the
trust config, then read the wrong client IP / host / scheme back from the
static.

This moves the four trust statics ($trustedProxies, $trustedHostPatterns,
$trustedHosts, $trustedHeaderSet) plus the three per-request privates
($trustedValuesCache, $isHostValid, $isForwardedValid) onto Hypervel's
Request subclass as protected instance properties. The Symfony-compatible
static setters (setTrustedProxies / setTrustedHosts) keep their signatures
and route writes to the current Request through RequestContext::getOrNull().
Read sites stay on plain property access — no Context lookups on the hot
path.

Reimplements Symfony's private getTrustedValues, normalizeAndFilterClientIps,
getBaseUrlReal, and isHostValid as protected so the overridden public
methods reach them. Redeclares the private FORWARDED_PARAMS and
TRUSTED_HEADERS constants as protected for the same reason.

Wires the request lifecycle so trust state flows correctly: initialize()
resets to defaults, createFrom() / createFromBase() copy from the source
request (so FormRequest::newInstance() preserves trust through the
container's SelfBuilding path), __clone() clears the parsed-values cache
and one-shot exception flags while preserving config (covers both direct
clone and parent::duplicate()'s internal clone). setTrustedProxies and
setTrustedHosts clear the trusted-values cache + one-shot flags so a
mid-request config change doesn't return stale parsed values.
Covers the proxy/header/host read paths (getClientIps, getHost, isSecure,
getPort, getBaseUrl), the REMOTE_ADDR and PRIVATE_SUBNETS sentinel
resolution in setTrustedProxies, the regex-compilation in setTrustedHosts,
and the lifecycle paths (createFrom, createFromBase, initialize, __clone,
duplicate). The duplicate test reflection-asserts the trustedValuesCache
clears on clone so the assertion can't pass via header-key auto-invalidation.

A final parity test runs the same headers through Symfony's own Request and
Hypervel's Request and asserts identical outputs across getClientIps,
getHost, getPort, isSecure — guarding against subtle copy-paste drift from
the Symfony private helpers we reimplemented as protected.
Runs two coroutines via parallel() with usleep() between mutation and read
to force interleaving. Each coroutine configures its own trust state and
verifies its reads (getTrustedProxies / isFromTrustedProxy / ip / getHost)
return its own values, not the other coroutine's. The host-validity test
also confirms the SuspiciousOperationException one-shot flag stays
per-coroutine — one coroutine's invalid host doesn't suppress the other's
exception.

These would fail on the previous static-storage implementation, where the
second coroutine's setTrustedProxies() would overwrite the first
coroutine's reads on resume.
Exercises the middleware end-to-end through the Testbench HTTP dispatcher:
explicit IP list, untrusted-proxy fallthrough to REMOTE_ADDR, the '*'
wildcard mode, and a FormRequest path that asserts \$this->ip() inside
authorize() reflects the forwarded IP — the regression that motivated
moving trust state off Symfony statics, since FormRequest goes through
createFrom() in the container's SelfBuilding path.

The concurrent-through-middleware test fires two overlapping requests via
parallel() against a slow route that usleep()s in the handler. Under the
old static-storage implementation the second request's middleware would
overwrite the first's trust config while the first was yielded; both
responses now correctly report their own forwarded IPs.
Verifies trusted-host pattern matching end-to-end: a matching host returns
200, an untrusted host surfaces SuspiciousOperationException through the
exception handler, and the closure form of trustHosts(at: fn () => [...])
runs per request and can read request-specific state (HOST header) when
deciding what to trust. Subclasses TrustHosts to bypass the
local/testing-environment opt-out so the middleware actually runs under
Testbench.
@binaryfire binaryfire merged commit 40ba173 into 0.4 May 12, 2026
34 checks passed
Comment thread src/http/src/Request.php
* Determine whether the request is secure.
*/
#[Override]
public function isSecure(): bool
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@binaryfire is this function exactly the same as its parent function?

Copy link
Copy Markdown
Collaborator Author

@binaryfire binaryfire May 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @albertcht. Yes this is funtionally identical, and we have tests to enforce that as well. Please see: #384 (comment)

Comment thread src/http/src/Request.php
* Return the port on which the request is made.
*/
#[Override]
public function getPort(): int|string|null
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@binaryfire is this function exactly the same as its parent function?

Copy link
Copy Markdown
Collaborator Author

@binaryfire binaryfire May 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @albertcht. Yes this is funtionally identical, and we have tests to enforce that as well. Please see: #384 (comment)

Comment thread src/http/src/Request.php
* Return the root URL from which this request is executed.
*/
#[Override]
public function getBaseUrl(): string
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@binaryfire is this function exactly the same as its parent function?

Copy link
Copy Markdown
Collaborator Author

@binaryfire binaryfire May 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @albertcht. Yes this is funtionally identical, and we have tests to enforce that as well. Please see: #384 (comment)

Comment thread src/http/src/Request.php
* Get the client IP addresses.
*/
#[Override]
public function getClientIps(): array
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@binaryfire is this function exactly the same as its parent function?

Copy link
Copy Markdown
Collaborator Author

@binaryfire binaryfire May 12, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hi @albertcht. Yes this is funtionally identical, and we have tests to enforce that as well. Please see: #384 (comment)

Comment thread src/http/src/Request.php
@binaryfire
Copy link
Copy Markdown
Collaborator Author

binaryfire commented May 12, 2026

Hi @albertcht

Yes, functionally these are all identical and there are parity tests for all of them to enforce that. The internal differences are intentional and necessary. The reason we can’t just call parent::getClientIps() / parent::getBaseUrl() / etc. is that Symfony’s implementations call private helpers like getTrustedValues() and use static trusted state like self::$trustedProxies / self::$trustedHeaderSet. Because those helpers are private, Hypervel can't override just the trust-state storage underneath them. If we delegated to the parent, we'd fall back into Symfony’s process-global trusted proxy state, which is the Swoole coroutine safety bug this PR fixes.

These override methods mirror Symfony’s implementation functionally, but they intentionally substitute Symfony’s static trusted request state with Hypervel’s context-backed request-scoped state.

Here is the test that ensure parity. The test creates the same server/header input as both a Symfony Request and a Hypervel Request, configures the same trusted proxy/header bitmask, then asserts the returned values are identical:

tests/Http/HttpRequestTrustedStateTest.php::testSingleRequestMatchesSymfonyTrustedProxyBehavior

It covers all the overrides:
Request::getClientIps()
Request::getHost()
Request::getPort()
Request::isSecure()
Request::getBaseUrl()
Request::isFromTrustedProxy()

@albertcht
Copy link
Copy Markdown
Member

getTrustedValues

oh, I didn't notice that getTrustedValues() function is private in the parent request. thanks for the explaination!

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.

2 participants