Skip to content

feat(http): client IP address behind trusted proxies - #2244

Open
osbre wants to merge 2 commits into
tempestphp:3.xfrom
osbre:feat/request-client-address
Open

feat(http): client IP address behind trusted proxies#2244
osbre wants to merge 2 commits into
tempestphp:3.xfrom
osbre:feat/request-client-address

Conversation

@osbre

@osbre osbre commented Aug 1, 2026

Copy link
Copy Markdown
Contributor

Right now, getting the IP address a request came from means digging into $_SERVER yourself. The PSR request has it in REMOTE_ADDR, but the mapper to GenericRequest throws the server params away.

This PR adds Request::$ip:

#[Get(uri: '/aircraft')]
public function index(Request $request): View
{
    $ip = $request->ip;
}

It's null when the server doesn't report an address. In tests, fromIp sets it:

$this->http
    ->fromIp('203.0.113.9')
    ->get('/aircraft')
    ->assertOk();

Trusted proxies

Behind a reverse proxy you get the proxy's address, and the client's real one sits in a header like X-Forwarded-For. Anyone can set that header, so it's only read if the request came through a proxy you've declared. Nothing is trusted by default:

use Tempest\Http\Ip\TrustedProxiesConfig;

return new TrustedProxiesConfig(
    proxies: ['10.0.0.0/8'], // or PRIVATE_RANGES, or ANY
);

Addresses and CIDR ranges both work, IPv4 and IPv6. In a chain of hops, the closest one that isn't a trusted proxy is the client. Header names are configurable too, for the likes of Cloudflare. Docs included, under routing.

Notes

  • ip is now a reserved request parameter name, since it's a property on the Request interface. If a payload has a field called ip and gets mapped onto a custom request object, it'll throw RequestParametersIncludedReservedNames, same as it already does for path, query and so on. Probably worth a line in the release notes.
  • Went with ip rather than clientIp to keep it short, like Laravel's $request->ip().
  • The range matching lives in Tempest\Support\Ip rather than the HTTP package, since it seemed generally useful: matches(), matches_any(), is_private().

Included changes

  • Request::$ip on the interface and in IsRequest
  • TrustedProxiesConfig and ClientIpResolver in Tempest\Http\Ip, Tempest\Support\Ip functions and PRIVATE_RANGES
  • PsrRequestToGenericRequestMapper resolves the address, RequestToPsrRequestMapper writes it back for the tester's dispatch, and RequestToObjectMapper carries it onto custom request objects
  • HttpRouterTester::fromIp(), across all verb methods and makePsrRequest
  • Docs and tests

@github-actions

github-actions Bot commented Aug 1, 2026

Copy link
Copy Markdown

Benchmark Results

Comparison of feat/request-client-address against 3.x (067c28d846cb591de13d874c3d1ecd1fb101d588).

Open to see the benchmark results
Benchmark Set Mem. Peak Time Variability
ViewRenderBench(benchExpressions) - 24.573mb +0.07% 525.335μs -6.07% ±2.62% -7.12%

Generated by phpbench against commit b8031ab

@xHeaven

xHeaven commented Aug 1, 2026

Copy link
Copy Markdown
Member

Perhaps we could make this a bit more exhaustive if we're adding it. SERVER_ADDRREMOTE_ADDR doesn't always contain the client's real IP address, especially behind Cloudflare, Docker and other services. What do you think?

@osbre
osbre requested a review from innocenzi as a code owner August 1, 2026 17:34
@osbre osbre changed the title feat(http): expose client IP address on the request feat(http): resolve the client IP address behind trusted proxies Aug 1, 2026
@osbre

osbre commented Aug 1, 2026

Copy link
Copy Markdown
Contributor Author

@xHeaven You mean REMOTE_ADDR, yes. Updated to include trusted proxies and relevant IP helpers under support package, hopefully that's not too many changes for one PR.

@osbre osbre changed the title feat(http): resolve the client IP address behind trusted proxies feat(http): client IP address behind trusted proxies Aug 1, 2026
use Tempest\Http\Ip\TrustedProxiesConfig;

return new TrustedProxiesConfig(
proxies: ['10.0.0.0/8'],

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.

Ok so, one thought I have it whether we could introduce an IpAddress value object that's used for the request's property and (optionally) in this config as well.

I believe comparing IPs is more involved than a simple === operation on strings, so maybe it could have an $request->ip->equals($ip) method?

* matches('10.0.1.24', '::/0'); // false
* ```
*/
function matches(string $ip, string $range): 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.

With this newly added value object, this function's implementation would be moved to the value object.

The function can be kept, but should be called ip_matches. Same for the other functions

* is_private('203.0.113.9'); // false
* ```
*/
function is_private(string $ip): 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.

Rename ip_is_private

*
* @internal
*/
function to_bytes(string $ip): ?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.

Rename ip_to_bytes

*
* @param string[] $ranges
*/
function matches_any(string $ip, array $ranges): 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.

rename ip_matches_any

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.

3 participants