Skip to content

feat(http): trust the proxy's rewritten path and let a route take OPTIONS - #275

Closed
ChiragAgg5k wants to merge 2 commits into
mainfrom
feat/http-trusted-rewrite-path
Closed

ChiragAgg5k wants to merge 2 commits into
mainfrom
feat/http-trusted-rewrite-path

Conversation

@ChiragAgg5k

@ChiragAgg5k ChiragAgg5k commented Sep 15, 2026 •

Copy link
Copy Markdown
Member

Summary

Two additive changes to utopia-php/http so an application behind a rewriting gateway no longer has to reconstruct the client's path and method by hand.

Trusted rewrite path. TrustedHeaders gains a path list beside ip and proto, trusting x-replaced-path by default the way proto trusts x-forwarded-proto. Request::getOriginalURI() returns the path the client asked for before the proxy rewrote it, or the request URI when no trusted header carries one. Traefik's replacePath appends the header rather than setting it, so a client-supplied copy survives to the left of the real value and getHeaderLine joins the two. The resolver takes the last value, which is the one the hop in front of this server wrote. TrustedHeaders::REPLACED_PATH and TrustedHeaders::FORWARDED_PROTO name the headers.

Routes may take OPTIONS. Route::options() opts a route in, and Http::execute then runs its action for a matching OPTIONS instead of stopping at the options hooks. Only the wildcard can match OPTIONS in practice, so this is how a catch-all forwards a preflight without rewriting the method first and carrying the real one in a forgeable header.

Usage

Declare trust once where the server is built. The defaults already trust x-forwarded-proto and x-replaced-path; a deployment names something else, or withdraws trust, here:

use Utopia\Http\Adapter\SwooleCoroutine\Server;
use Utopia\Http\TrustedHeaders;

$server = new Server('0.0.0.0', '80', $settings, $container, new TrustedHeaders(
    proto: ['x-cdn-proto'],
    path: [TrustedHeaders::REPLACED_PATH],
));

Read the pre-rewrite path from the request. Behind Traefik, a request rewritten to /v1/router/gateway with the wire headers X-Replaced-Path: /forged and X-Replaced-Path: /submit resolves to the proxy's value:

$request->getURI();         // /v1/router/gateway
$request->getOriginalURI(); // /submit

Not behind a rewriting proxy? Withdraw trust and the fallback is the request URI:

new TrustedHeaders(path: []);

Let a catch-all receive preflights. Without options() the wildcard still stops at the options hooks as before:

Http::wildcard()
    ->options()
    ->inject('request')
    ->action(function (Request $request) {
        $request->getMethod(); // OPTIONS, no rewrite needed
    });

Why

Splits the fix in appwrite-labs/edge#1420 into the library. Edge trusts X-Replaced-Path through getHeaderLine, so a forged value controls prefix checks, and invents X-Replaced-Method because the framework would not dispatch OPTIONS to the wildcard. With this change Edge reads getOriginalURI() and getMethod(), deletes its method rewrite, and the method header ceases to exist.

Verification

bin/monorepo check http passes Pint, PHPStan and Rector. The unit suite is 145 tests green; the single deprecation is pre-existing in RouteTest. New tests cover default trust, opt-out, a header of another name, a client copy in front of the proxy value for both FPM and Swoole (two raw header lines), and the wildcard running an OPTIONS action while a non-opted wildcard still stops at the hooks. The e2e tier was not run locally because Docker is down.

A gateway that rewrites every request to one internal route tells the origin which path the client asked for, and Traefik's replacePath does so by appending X-Replaced-Path rather than setting it. A client copy survives to the left of the real value, and reading the header through getHeaderLine hands the application both joined together, with the forged half deciding any prefix check.

TrustedHeaders gains a path list beside ip and proto, trusting x-replaced-path by default as proto trusts x-forwarded-proto. getOriginalURI() reads the first trusted header that carries a value and falls back to the request URI. The rewriting hop appends, so the last value is its own and anything before it came from the client.
…hooks

execute() stops every OPTIONS at the options hooks, so a catch-all that proxies to another service could never see a preflight. The only way around it was to rewrite the method before the framework saw it and carry the real one in a header, which any client could then forge.

Route::options() opts a route in; a matching OPTIONS then runs its action with the method intact. Nothing changes for routes that do not ask.
@github-actions

Copy link
Copy Markdown

Benchmark results

http — Swoole modes (4 cores, 200 VUs, 20s/run)

workload mode req/s p95
ok a 13928.23145/s 30.6ms
ok b 14671.755881/s 29.89ms
io a 466.650192/s 804.91ms
io b 3338.747297/s 51.82ms
cpu a 3278.624473/s 165.2ms
cpu b 3086.969194/s 83.51ms

a = HYPERLOOP_A (process), b = HYPERLOOP_B (coroutine)

Shared CI runners — treat absolute numbers as rough, compare modes within a run. Commit d05f81f.

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