├── 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 |
--------------------------------------------------------------------------------