├── phpcs.xml ├── src ├── Mezzio │ ├── ConfigProvider.php │ ├── config │ │ └── ip_address.global.php.dist │ └── IpAddressFactory.php └── IpAddress.php ├── phpunit.xml ├── LICENSE ├── composer.json └── README.md /phpcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | src 10 | tests 11 | 12 | 13 | -------------------------------------------------------------------------------- /src/Mezzio/ConfigProvider.php: -------------------------------------------------------------------------------- 1 | $this->getDependencies(), 15 | ]; 16 | } 17 | 18 | private function getDependencies(): array 19 | { 20 | return [ 21 | 'factories' => [ 22 | IpAddress::class => IpAddressFactory::class, 23 | ] 24 | ]; 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/Mezzio/config/ip_address.global.php.dist: -------------------------------------------------------------------------------- 1 | [ 13 | 'ip_address' => [ 14 | 'check_proxy_headers' => (bool) ($_ENV['IP_ADDRESS_CHECK_PROXY_HEADERS'] ?? false), 15 | 'trusted_proxies' => $_ENV['IP_ADDRESS_TRUSTED_PROXIES'] ?? null, 16 | 'attribute_name' => $_ENV['IP_ADDRESS_ATTRIBUTE_NAME'] ?? null, 17 | 'headers_to_inspect' => explode(',', $_ENV['IP_ADDRESS_HEADERS_TO_INSPECT'] ?? ''), 18 | ], 19 | ], 20 | ]; 21 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 18 | 19 | 20 | ./tests/ 21 | 22 | 23 | 24 | 25 | ./src 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /src/Mezzio/IpAddressFactory.php: -------------------------------------------------------------------------------- 1 | has('config')) { 23 | $config = $container->get('config'); 24 | } 25 | 26 | $checkProxyHeaders = $config['rka']['ip_address']['check_proxy_headers'] ?? false; 27 | $trustedProxies = $config['rka']['ip_address']['trusted_proxies'] ?? null; 28 | $attributeName = $config['rka']['ip_address']['attribute_name'] ?? null; 29 | $headersToInspect = $config['rka']['ip_address']['headers_to_inspect'] ?? []; 30 | $hopCount = $config['rka']['ip_address']['hop_count'] ?? 0; 31 | 32 | return new IpAddress( 33 | $checkProxyHeaders, 34 | $trustedProxies, 35 | $attributeName, 36 | $headersToInspect, 37 | $hopCount 38 | ); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015-2025 Rob Allen (rob@akrabat.com) and other contributors 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, 8 | this list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * The name of Rob Allen may not be used to endorse or promote products derived 15 | from this software without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 18 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 19 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 20 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 21 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 22 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 23 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 24 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 25 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 26 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "akrabat/ip-address-middleware", 3 | "description": "PSR-15 middleware that determines the client IP address and stores it as a ServerRequest attribute", 4 | "keywords": [ 5 | "psr7", "middleware", "ip" 6 | ], 7 | "support": { 8 | "issues": "https://github.com/akrabat/ip-address-middleware/issues", 9 | "source": "https://github.com/akrabat/ip-address-middleware" 10 | }, 11 | "type": "library", 12 | "license": "BSD-3-Clause", 13 | "authors": [ 14 | { 15 | "name": "Rob Allen", 16 | "email": "rob@akrabat.com", 17 | "homepage": "http://akrabat.com" 18 | } 19 | ], 20 | "replace": { 21 | "akrabat/rka-ip-address-middleware": "*" 22 | }, 23 | "require": { 24 | "php": "^7.2 || ^8.0", 25 | "psr/http-message": "^1.0 || ^2.0", 26 | "psr/http-server-middleware": "^1.0", 27 | "psr/container": "^1.0 || ^2.0" 28 | }, 29 | "require-dev": { 30 | "laminas/laminas-diactoros": "^2.4 || ^3.0", 31 | "phpunit/phpunit": "^8.5.8 || ^9.4", 32 | "squizlabs/php_codesniffer": "^3.2" 33 | }, 34 | "autoload": { 35 | "psr-4": { 36 | "RKA\\Middleware\\": "src" 37 | } 38 | }, 39 | "extra": { 40 | "laminas": { 41 | "config-provider": "RKA\\Middleware\\Mezzio\\ConfigProvider" 42 | } 43 | }, 44 | "scripts": { 45 | "test": "phpunit", 46 | "cs": "phpcs", 47 | "cs-fix": "phpcbf", 48 | "code-coverage": "phpunit --coverage-html=coverage ./build", 49 | "check": [ 50 | "@cs", 51 | "@test" 52 | ] 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Client IP address middleware 2 | 3 | PSR-15 Middleware that determines the client IP address and stores it as an `ServerRequest` attribute called `ip_address`. It optionally checks various common proxy headers and then falls back to `$_SERVER['REMOTE_ADDR']`. 4 | 5 | ## Installation 6 | 7 | Install via Composer: 8 | 9 | ```bash 10 | composer require akrabat/ip-address-middleware 11 | ``` 12 | 13 | ## Configuration 14 | 15 | The constructor takes 5 parameters which can be used to configure this middleware. 16 | 17 | **Check proxy headers** 18 | 19 | The proxy headers are only checked if the first parameter to the constructor is set to `true`. If it is set to `false`, then only `$_SERVER['REMOTE_ADDR']` is used. 20 | 21 | **Trusted Proxies** 22 | 23 | If you enable checking of the proxy headers (first parameter is `true`), you have to provide an array as the second parameter. This is the list of IP addresses (supporting wildcards) of your proxy servers. If the array is empty, the proxy headers will always be used and the selection is based on the hop count (parameter 5). 24 | 25 | If the array is not empty, it must contain strings with IP addresses (wildcard `*` is allowed in any given part) or networks in CIDR-notation. One of them must match the `$_SERVER['REMOTE_ADDR']` variable in order to allow evaluating the proxy headers - otherwise the `REMOTE_ADDR` itself is returned. This list is not ordered and there is no requirement that any given proxy header includes all the listed proxies. 26 | 27 | **Attribute name** 28 | 29 | By default, the name of the attribute is '`ip_address`'. This can be changed by the third constructor parameter. 30 | 31 | **Headers to inspect** 32 | 33 | By default, this middleware checks the 'Forwarded', 'X-Forwarded-For', 'X-Forwarded', 'X-Cluster-Client-Ip' and 'Client-Ip' headers. You can replace this list with your own using the fourth constructor parameter. 34 | 35 | If you use the _nginx_, [set_real_ip_from][nginx] directive, then you should probably set this to: 36 | 37 | $headersToInspect = [ 38 | 'X-Real-IP', 39 | 'Forwarded', 40 | 'X-Forwarded-For', 41 | 'X-Forwarded', 42 | 'X-Cluster-Client-Ip', 43 | 'Client-Ip', 44 | ]; 45 | 46 | If you use _CloudFlare_, then according to the [documentation][cloudflare] you should probably set this to: 47 | 48 | $headersToInspect = [ 49 | 'CF-Connecting-IP', 50 | 'True-Client-IP', 51 | 'Forwarded', 52 | 'X-Forwarded-For', 53 | 'X-Forwarded', 54 | 'X-Cluster-Client-Ip', 55 | 'Client-Ip', 56 | ]; 57 | 58 | [nginx]: http://nginx.org/en/docs/http/ngx_http_realip_module.html 59 | [cloudflare]: https://support.cloudflare.com/hc/en-us/articles/200170986-How-does-Cloudflare-handle-HTTP-Request-headers- 60 | 61 | **hop count** 62 | 63 | Set this to the number of known proxies between ingress and the application. This is used to determine the number of 64 | proxies to check in the `X-Forwarded-For` header, and is generally used when the IP addresses of the proxies cannot 65 | be reliably determined. The default is 0. 66 | 67 | ## Security considerations 68 | 69 | A malicious client may send any header to your proxy, including any proxy headers, containing any IP address. If your proxy simply adds another IP address to the header, an attacker can send a fake IP. Make sure to setup your proxy in a way that removes any sent (and possibly faked) headers from the original request and replaces them with correct values (i.e. the currently used `REMOTE_ADDR` on the proxy server). 70 | 71 | This library cannot by design ensure you get correct and trustworthy results if your network environment isn't setup properly. 72 | 73 | ## Installation 74 | 75 | `composer require akrabat/ip-address-middleware` 76 | 77 | In Mezzio, copy `Mezzio/config/ip_address.global.php.dist` into your Mezzio Application `config/autoload` directory as `ip_address.global.php` 78 | 79 | ## Usage 80 | 81 | In Slim: 82 | 83 | ```php 84 | $checkProxyHeaders = true; // Note: Never trust the IP address for security processes! 85 | $trustedProxies = ['10.0.0.1', '10.0.0.2']; // Note: Never trust the IP address for security processes! 86 | $app->add(new RKA\Middleware\IpAddress($checkProxyHeaders, $trustedProxies)); 87 | 88 | $app->get('/', function ($request, $response, $args) { 89 | $ipAddress = $request->getAttribute('ip_address'); 90 | 91 | return $response; 92 | }); 93 | ``` 94 | 95 | In Laminas or Mezzio, add to your `pipeline.php` config at the correct stage, usually just before the `DispatchMiddleware`: 96 | ```php 97 | # config/pipeline.php 98 | # using default config 99 | $app->add(RKA\Middleware\IpAddress::class); 100 | ``` 101 | If required, update your `.env` file with the environmental variables found in `/config/autoload/ip_address.global.php`. 102 | 103 | ## Testing 104 | 105 | * Code style: `$ vendor/bin/phpcs` 106 | * Fix style: `$ vendor/bin/phpcbf` 107 | * Unit tests: `$ vendor/bin/phpunit` 108 | * Code coverage: `$ vendor/bin/phpunit --coverage-html ./build` 109 | 110 | You can also use Composer scripts: 111 | 112 | * Check both: `$ composer check` 113 | * Code style: `$ composer cs` 114 | * Fix style: `$ composer cs-fix` 115 | * Unit tests: `$ composer test` 116 | 117 | 118 | [Master]: https://travis-ci.org/akrabat/ip-address-middleware 119 | [Master image]: https://secure.travis-ci.org/akrabat/ip-address-middleware.svg?branch=master 120 | -------------------------------------------------------------------------------- /src/IpAddress.php: -------------------------------------------------------------------------------- 1 | checkProxyHeaders = $checkProxyHeaders; 94 | 95 | if (is_array($trustedProxies)) { 96 | foreach ($trustedProxies as $proxy) { 97 | if (strpos($proxy, '*') !== false) { 98 | // Wildcard IP address 99 | $this->trustedWildcards[] = $this->parseWildcard($proxy); 100 | } elseif (strpos($proxy, '/') > 6) { 101 | // CIDR notation 102 | $this->trustedCidrs[] = $this->parseCidr($proxy); 103 | } else { 104 | // String-match IP address 105 | $this->trustedProxies[] = $proxy; 106 | } 107 | } 108 | } 109 | 110 | if ($attributeName) { 111 | $this->attributeName = $attributeName; 112 | } 113 | 114 | if (!empty($headersToInspect)) { 115 | $this->headersToInspect = $headersToInspect; 116 | } 117 | 118 | $this->hopCount = $hopCount; 119 | } 120 | 121 | private function parseWildcard(string $ipAddress): array 122 | { 123 | // IPv4 has 4 parts separated by '.' 124 | // IPv6 has 8 parts separated by ':' 125 | if (strpos($ipAddress, '.') > 0) { 126 | $delim = '.'; 127 | $parts = 4; 128 | } else { 129 | $delim = ':'; 130 | $parts = 8; 131 | } 132 | 133 | return explode($delim, $ipAddress, $parts); 134 | } 135 | 136 | private function parseCidr(string $ipAddress): array 137 | { 138 | list($subnet, $bits) = explode('/', $ipAddress, 2); 139 | $subnet = ip2long($subnet); 140 | $mask = -1 << (32 - $bits); 141 | $min = $subnet & $mask; 142 | $max = $subnet | ~$mask; 143 | 144 | return [$min, $max]; 145 | } 146 | 147 | /** 148 | * {@inheritDoc} 149 | * 150 | * Set the "$attributeName" attribute to the client's IP address as determined from 151 | * the proxy header (X-Forwarded-For or from $_SERVER['REMOTE_ADDR'] 152 | */ 153 | public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface 154 | { 155 | $ipAddress = $this->determineClientIpAddress($request); 156 | $request = $request->withAttribute($this->attributeName, $ipAddress); 157 | 158 | return $handler->handle($request); 159 | } 160 | 161 | /** 162 | * Set the "$attributeName" attribute to the client's IP address as determined from 163 | * the proxy header (X-Forwarded-For or from $_SERVER['REMOTE_ADDR'] 164 | * 165 | * @param ServerRequestInterface $request PSR7 request 166 | * @param ResponseInterface $response PSR7 response 167 | * @param callable $next Next middleware 168 | * 169 | * @return ResponseInterface 170 | */ 171 | public function __invoke(ServerRequestInterface $request, ResponseInterface $response, $next) 172 | { 173 | if (!$next) { 174 | return $response; 175 | } 176 | 177 | $ipAddress = $this->determineClientIpAddress($request); 178 | $request = $request->withAttribute($this->attributeName, $ipAddress); 179 | 180 | return $next($request, $response); 181 | } 182 | 183 | /** 184 | * Find out the client's IP address from the headers available to us 185 | * 186 | * @param ServerRequestInterface $request PSR-7 Request 187 | * @return string 188 | */ 189 | protected function determineClientIpAddress($request): ?string 190 | { 191 | $ipAddress = null; 192 | 193 | $serverParams = $request->getServerParams(); 194 | if (isset($serverParams['REMOTE_ADDR'])) { 195 | $remoteAddr = $this->extractIpAddress($serverParams['REMOTE_ADDR']); 196 | if (filter_var($remoteAddr, FILTER_VALIDATE_IP)) { 197 | $ipAddress = $remoteAddr; 198 | } 199 | } 200 | if ($ipAddress === null) { 201 | // do not continue if there isn't a valid remote address 202 | return $ipAddress; 203 | } 204 | 205 | if (!$this->checkProxyHeaders) { 206 | // do not check if configured to not check 207 | return $ipAddress; 208 | } 209 | 210 | // If trustedProxies is empty, then the remote address is the trusted proxy 211 | $trustedProxies = $this->trustedProxies; 212 | if (empty($trustedProxies) && empty($this->trustedWildcards) && empty($this->trustedCidrs)) { 213 | $trustedProxies[] = $ipAddress; 214 | } 215 | 216 | // find the first non-empty header from the headersToInspect list and use just that one 217 | foreach ($this->headersToInspect as $header) { 218 | if ($request->hasHeader($header)) { 219 | $headerValue = $request->getHeaderLine($header); 220 | if (!empty($headerValue)) { 221 | $ipAddress = $this->getIpAddressFromHeader( 222 | $header, 223 | $headerValue, 224 | $ipAddress, 225 | $trustedProxies, 226 | $this->hopCount 227 | ); 228 | break; 229 | } 230 | } 231 | } 232 | 233 | return empty($ipAddress) ? null : $ipAddress; 234 | } 235 | 236 | public function getIpAddressFromHeader( 237 | string $headerName, 238 | string $headerValue, 239 | string $thisIpAddress, 240 | array $trustedProxies, 241 | int $hopCount 242 | ) { 243 | if (strtolower($headerName) == 'forwarded') { 244 | // The Forwarded header is different, so we need to extract the for= values. Note that we perform a 245 | // simple extraction here, and do not support the full RFC 7239 specification. 246 | preg_match_all('/for=([^,;]+)/i', $headerValue, $matches); 247 | $ipList = $matches[1]; 248 | 249 | // If any of the items in the list are not an IP address, then we ignore the entire list for now 250 | foreach ($ipList as $ip) { 251 | $ip = $this->extractIpAddress($ip); 252 | if (!filter_var($ip, FILTER_VALIDATE_IP)) { 253 | return $thisIpAddress; 254 | } 255 | } 256 | } else { 257 | $ipList = explode(',', $headerValue); 258 | } 259 | $ipList[] = $thisIpAddress; 260 | 261 | // Remove port from each item in the list 262 | $ipList = array_map(function ($ip) { 263 | return $this->extractIpAddress(trim($ip)); 264 | }, $ipList); 265 | 266 | // Ensure all IPs are valid and return $ipAddress if not 267 | foreach ($ipList as $ip) { 268 | if (!filter_var($ip, FILTER_VALIDATE_IP)) { 269 | return $thisIpAddress; 270 | } 271 | } 272 | 273 | // walk list from right to left removing known proxy IP addresses. 274 | $ipList = array_reverse($ipList); 275 | $count = 0; 276 | foreach ($ipList as $ip) { 277 | $count++; 278 | if (!$this->isTrustedProxy($ip, $trustedProxies)) { 279 | if ($count <= $hopCount) { 280 | continue; 281 | } 282 | return $ip; 283 | // } else { 284 | // if ($count <= $hopCount) { 285 | // continue; 286 | // } 287 | } 288 | } 289 | 290 | return $thisIpAddress; 291 | } 292 | 293 | protected function isTrustedProxy(string $ipAddress, array $trustedProxies): bool 294 | { 295 | if (in_array($ipAddress, $trustedProxies)) { 296 | return true; 297 | } 298 | 299 | // Do we match a wildcard? 300 | if ($this->trustedWildcards) { 301 | // IPv4 has 4 parts separated by '.' 302 | // IPv6 has 8 parts separated by ':' 303 | if (strpos($ipAddress, '.') > 0) { 304 | $delim = '.'; 305 | $parts = 4; 306 | } else { 307 | $delim = ':'; 308 | $parts = 8; 309 | } 310 | 311 | $ipAddrParts = explode($delim, $ipAddress, $parts); 312 | foreach ($this->trustedWildcards as $proxy) { 313 | if (count($proxy) !== $parts) { 314 | continue; // IP version does not match 315 | } 316 | $match = true; 317 | foreach ($proxy as $i => $part) { 318 | if ($part !== '*' && $part !== $ipAddrParts[$i]) { 319 | $match = false; 320 | break; // IP does not match, move to next proxy 321 | } 322 | } 323 | if ($match) { 324 | return true; 325 | } 326 | } 327 | } 328 | 329 | // Do we match a CIDR address? 330 | if ($this->trustedCidrs) { 331 | // Only IPv4 is supported for CIDR matching 332 | $ipAsLong = ip2long($ipAddress); 333 | if ($ipAsLong) { 334 | foreach ($this->trustedCidrs as $proxy) { 335 | if ($proxy[0] <= $ipAsLong && $ipAsLong <= $proxy[1]) { 336 | return true; 337 | } 338 | } 339 | } 340 | } 341 | 342 | return false; 343 | } 344 | 345 | /** 346 | * Remove port from IPV4 address if it exists 347 | * 348 | * Note: leaves IPV6 addresses alone 349 | * 350 | * @param string $ipAddress 351 | * @return string 352 | */ 353 | protected function extractIpAddress($ipAddress) 354 | { 355 | $parts = explode(':', $ipAddress); 356 | if (count($parts) == 1) { 357 | return $ipAddress; 358 | } 359 | if (count($parts) == 2) { 360 | if (filter_var($parts[0], FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) !== false) { 361 | return $parts[0]; 362 | } 363 | } 364 | 365 | // If the $ipAddress starts with a [ and ends with ] or ]:port, then it is an IPv6 address and 366 | // we can extract the IP address 367 | $ipAddress = trim($ipAddress, '"\''); 368 | if (substr($ipAddress, 0, 1) === '[' 369 | && (substr($ipAddress, -1) === ']' || preg_match('/\]:\d+$/', $ipAddress))) { 370 | // Extract IPv6 address between brackets 371 | preg_match('/\[(.*?)\]/', $ipAddress, $matches); 372 | $ipAddress = $matches[1]; 373 | } 374 | 375 | return $ipAddress; 376 | } 377 | } 378 | --------------------------------------------------------------------------------