The Challenges of IP Address Detection in a Multi-Step Connection Scenario in Symfony Projects
--
NOTE: Any time I mention NGINX, it is interoperable by Apache, IIS or any other server you might have condifured in your environment. I am going to consider the NGINX settings in this article, but you can replicate the same behaviour for your Web Proxy server to achieve the same result.
The Internet Protocol (IP) address plays a pivotal role in the identity of an online user. It’s a unique string of numbers separated by periods that identifies each computer using the Internet Protocol to communicate over a network. IP addresses are paramount for servers to recognise and interact with clients. However, accurately identifying the IP address in a web app environment can be challenging, particularly in a multi-step connection scenario like the one we’re discussing.
Scenario Description
In this article, we are going to investigate a setting where a user connects to a Symfony web application via multiple steps: first through the Cloudflare DNS resolver, then an Nginx proxy on the hosting server, and finally, the primary Nginx server hosting the Symfony web app inside a Docker container.
Problem 1: Cloudflare DNS Resolver
The first problem arises with Cloudflare. The role of Cloudflare in our scenario is to offer a reverse proxy service that provides DDoS protection and SSL termination, among other benefits.
When users connect to your site, they connect to Cloudflare, which connects to your server. This can lead to a situation where the IP address you detect is that of Cloudflare’s server and not that of the user.
To solve this problem, Cloudflare provides a couple of HTTP headers:
- The
CF-Connecting-IP
header provides the actual IP address of the client making the request. - The
X-Forwarded-For
header lists the IP addresses that requested the route to your server.
Problem 2: Nginx Proxy
Next, we have an Nginx proxy on the hosting server. The role of a reverse proxy is to take requests from the Internet and forward them to servers in an internal network.
When Nginx acts as a reverse proxy, it becomes the source of the connection to your application server. This means that, by default, your application sees the Nginx server’s IP address as the client’s IP address, not the original client’s IP address.
To overcome this, you must adjust the Nginx configuration to include the original IP address in the request headers. This is typically done with the X-Real-IP
and X-Forwarded-For
headers. The former carries the client’s IP address, while the latter lists all proxies the request passed through along with the client’s IP address.
Problem 3: Docker Container
Lastly, your application runs in a Docker container on an Nginx server. This adds another layer of complexity because Docker also uses a proxy mechanism to route requests to the appropriate container. The effect is similar to the previous steps, resulting in the container seeing the Docker host’s IP address as the client’s IP address.
To address this, you can employ tactics like before, ensuring that the Docker networking configuration and Nginx server configuration within the Docker container pass through the appropriate headers. Depending on your specific setup, this may include the X-Real-IP
, X-Forwarded-For
, or CF-Connecting-IP
headers.
Configuration Examples
Let’s go over some configuration examples for each problem.
NOTE: I will use PHP Symfony code samples to showcase how a more accurate IP Address detection can be performed. You can achieve the same behaviour by using the $_SERVER superglobal variable in raw PHP or any object representing your Request in any other language or framework you use to develop your code.
Cloudflare Configuration
There’s no specific configuration to be done in Cloudflare. It will automatically add the CF-Connecting-IP
and X-Forwarded-For
headers to the requests. Your job will be to use these headers appropriately in the following steps.
Nginx Proxy Configuration
Modifying the Nginx configuration to forward the original client’s IP address would be best. Here’s an example of what that might look like:
location / {
proxy_pass http://your_upstream;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Host $http_host;
}
Docker Nginx Configuration
Given your application will use this Nginx / Apache / NodeJS / … engine only to handle the Requests and return the Responses, there is no need to add further settings.
Still, if you have the feeling you don’t get the expected IP Addresses, you can consider adding the same rules you added as part of the Host’s Nginx site configuration.
PHP Service for Symfony
Finally, let’s create a simple PHP service in Symfony that retrieves the IP address from the request. Here’s an example of how that might look:
namespace App\Service;
use Symfony\Component\HttpFoundation\RequestStack;
class RealIpResolver
{
public function __construct(private readonly RequestStack $requestStack)
{
}
public function getRealIp()
{
$request = $this->requestStack->getCurrentRequest();
if ($request->headers->has('CF-Connecting-IP')) {
return $request->headers->get('CF-Connecting-IP');
}
if ($request->headers->has('X-Real-IP')) {
return $request->headers->get('X-Real-IP');
}
return $request->getClientIp();
}
}
You can use this service in your Symfony controllers or other services to get the actual IP address of the client. If available, the getRealIp()
function will return the client’s IP address from the CF-Connecting-IP
header. Otherwise, it will return the IP from the X-Real-IP
header. If neither header is available, it will return the IP address Symfony sees.
The Multiple IP Address Issue
In some scenarios, the getClientIp()
method in Symfony (and in other frameworks) can also produce more than one IP address. This typically happens due to the X-Forwarded-For
header.
The X-Forwarded-For
header is a de facto standard for identifying the originating IP address of a client connecting to a web server through an HTTP proxy or load balancer. This header is a comma-separated list of IP addresses, where the leftmost IP address is the original client, and each subsequent IP address represents a proxy the request passed through.
The problem arises when this header is manipulated. A client can set this header to any value they want. Therefore, you may see multiple IP addresses, and the leftmost one (which should be the real client IP) might be fake.
Fixing the Issue
To prevent IP spoofing through the X-Forwarded-For
header, you should only trust the proxies that you control. This includes the Nginx proxy and the Cloudflare resolver in your case.
Symfony has a mechanism to define a list of trusted proxies. Once these proxies are defined, Symfony will trust the X-Forwarded-For
header only if the request comes from one of these trusted proxies.
In your public/index.php
file, you can add something like:
Request::setTrustedProxies(
// Trust Cloudflare and your Nginx proxy IPs
['cloudflare_ip', 'nginx_proxy_ip'],
// Only trust X-Forwarded-For header
Request::HEADER_X_FORWARDED_FOR
);
Please replace 'cloudflare_ip'
and 'nginx_proxy_ip'
with the actual IPs of your Cloudflare resolver and Nginx proxy.
Then, when you call getClientIp()
, Symfony will ignore any X-Forwarded-For
header unless the request comes from a trusted proxy. And it will only consider the rightmost IP address in the X-Forwarded-For
header that was not added by a trusted proxy.
Updating the PHP Service
Let’s update the RealIpResolver
service to split the X-Forwarded-For
header and get the first IP address:
namespace App\Service;
use Symfony\Component\HttpFoundation\RequestStack;
class RealIpResolver
{
public function __construct(private readonly RequestStack $requestStack)
{
}
public function getRealIp()
{
$request = $this->requestStack->getCurrentRequest();
if ($request->headers->has('CF-Connecting-IP')) {
return $request->headers->get('CF-Connecting-IP');
}
if ($request->headers->has('X-Real-IP')) {
return $request->headers->get('X-Real-IP');
}
if ($request->headers->has('X-Forwarded-For')) {
$ips = explode(',', $request->headers->get('X-Forwarded-For'));
return trim($ips[0]); // The left-most IP address is the original client
}
return $request->getClientIp();
}
}
In this updated service, if the X-Forwarded-For
header is present, we split the value by commas and return the left-most (first) IP address.
Overriding Symfony Request’s getClientIp()
Typically, Symfony’s Request
object is created early in the request lifecycle and is then immutable for the rest of the request. It’s not designed to have its attributes, such as the client IP, changed after creation.
The client IP is set when the Request object is instantiated based on the server variables. Since we cannot alter the IP after the object has been created, we can’t directly “update” the Request object’s IP address to use our service’s method.
However, some workarounds can help achieve a similar effect by using dependency injection and Symfony’s Event system.
Creating a Custom Request Object
A possible solution is to create a new Request object, a custom extension of the base Request that includes your service to determine the real IP.
Here is how you might create such a class:
namespace App\Request;
use Symfony\Component\HttpFoundation\Request as BaseRequest;
use App\Service\RealIpResolver;
class Request extends BaseRequest
{
public function __construct(private readonly RealIpResolver $resolver)
{
parent::__construct();
}
public function getClientIp()
{
return $this->resolver->getRealIp();
}
}
Replacing the Request with an Event Listener
We can’t directly change the Request object Symfony creates, but we can replace it early in the request lifecycle. We can create an event listener that listens for the kernel.request
event, and then replace the Request object in the event with our custom Request.
Here’s an example of what that listener might look like:
namespace App\EventListener;
use Symfony\Component\HttpKernel\Event\RequestEvent;
use App\Request\Request;
class RequestListener
{
public function __construct(private readonly RealIpResolver $resolver)
{
$this->realIpResolver = $realIpResolver;
}
public function onKernelRequest(RequestEvent $event)
{
$originalRequest = $event->getRequest();
// Create a new Request (the one we have created)
$newRequest = new Request($this->resolver);
// Copy all attributes from the original request to the new one
// Note: given we are calling the parent constructor, it might
// not be necessary, but it's better to play safe.
$newRequest->attributes = $originalRequest->attributes;
$newRequest->request = $originalRequest->request;
$newRequest->query = $originalRequest->query;
$newRequest->cookies = $originalRequest->cookies;
$newRequest->files = $originalRequest->files;
$newRequest->server = $originalRequest->server;
$newRequest->headers = $originalRequest->headers;
$event->setRequest($newRequest);
}
}
Don’t forget to register this listener in your kernel.request
:
services:
App\EventListener\RequestListener:
tags:
- name: kernel.event_listener
event: kernel.request
priority: 10000
This way, when you get the Request object in your controllers, your custom Request object will use your service to determine the real client IP.
Conclusion
While you can’t directly change the IP of Symfony’s Request object after it’s created, you can use a combination of a custom Request object and an event listener to replace it early in the request lifecycle. This approach allows your controllers to use a custom Request object that uses your service to determine the real IP.
It’s important to note that this is a bit of a hack and may not be suitable for all situations. Always be aware of the potential implications when deviating from the default behaviour of the framework.