PluginProbe
Current Year, Symbols and IP Shortcode / trunk
Current Year, Symbols and IP Shortcode vtrunk
trunk 2.5 2.6
current-year-shortcode / shortcodes / ip.php

ip.php in Current Year, Symbols and IP Shortcode trunk, at shortcodes/ip.php

329 lines 11.0 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Includes shortcodes of user IP
4 * Plugin: Current Year and Symbols Shortcode
5 * Since: 2.3.2
6 * Author: KGM Servizi
7 * License: GPLv2 or later
8 * License URI: https://www.gnu.org/licenses/gpl-2.0.html
9 *
10 * @package Current_Year_Shortcode
11 */
12
13 if ( ! defined( 'ABSPATH' ) ) {
14 exit; // Exit if accessed directly.
15 }
16
17 /**
18 * The networks Cloudflare serves requests from.
19 *
20 * Published at https://www.cloudflare.com/ips/ and read on 2026-08-12. Kept apart from
21 * the other trusted networks because the CF-Connecting-IP header means nothing unless
22 * the request really arrived from Cloudflare. If this list ever falls behind, a site on
23 * a new range shows the address of the Cloudflare edge instead of the visitor: a wrong
24 * answer, never a forgeable one.
25 *
26 * @return array List of CIDR ranges.
27 */
28 function cys_cloudflare_ranges() {
29 return array(
30 // IPv4.
31 '173.245.48.0/20',
32 '103.21.244.0/22',
33 '103.22.200.0/22',
34 '103.31.4.0/22',
35 '141.101.64.0/18',
36 '108.162.192.0/18',
37 '190.93.240.0/20',
38 '188.114.96.0/20',
39 '197.234.240.0/22',
40 '198.41.128.0/17',
41 '162.158.0.0/15',
42 '104.16.0.0/13',
43 '104.24.0.0/14',
44 '172.64.0.0/13',
45 '131.0.72.0/22',
46 // IPv6.
47 '2400:cb00::/32',
48 '2606:4700::/32',
49 '2803:f800::/32',
50 '2405:b500::/32',
51 '2405:8100::/32',
52 '2a06:98c0::/29',
53 '2c0f:f248::/32',
54 );
55 }
56
57 /**
58 * Public networks whose forwarded headers can be believed.
59 *
60 * A forwarded header is only as trustworthy as whoever set it, and the one party a site
61 * can identify with certainty is the one that opened the TCP connection: REMOTE_ADDR. So
62 * the question is never "which header do I read" but "did this request reach me through
63 * a proxy of mine". It is the rule Apache's mod_remoteip applies with RemoteIPTrustedProxy.
64 *
65 * Private and reserved networks are recognized separately, in cys_is_trusted_proxy().
66 * Any other CDN or load balancer with a public address is declared from the site:
67 *
68 * add_filter( 'cys_trusted_proxies', function ( $proxies ) {
69 * $proxies[] = '203.0.113.0/24';
70 * return $proxies;
71 * } );
72 *
73 * @return array List of CIDR ranges and single addresses.
74 */
75 function cys_trusted_proxies() {
76 $proxies = apply_filters( 'cys_trusted_proxies', cys_cloudflare_ranges() );
77
78 /*
79 * A callback that forgets its return statement hands back null, which would silently
80 * drop the shipped ranges along with whatever it meant to add.
81 */
82 return is_array( $proxies ) ? $proxies : cys_cloudflare_ranges();
83 }
84
85 /**
86 * Reduce an IPv4-mapped IPv6 address to its plain IPv4 form.
87 *
88 * A web server listening on a dual-stack socket reports an IPv4 peer as ::ffff:127.0.0.1,
89 * which matches no IPv4 range and would quietly switch the whole mechanism off.
90 *
91 * @param string $ip The address to normalize.
92 * @return string The IPv4 address when the input was a mapped one, the input otherwise.
93 */
94 function cys_normalize_ip( $ip ) {
95 $packed = inet_pton( $ip );
96
97 /*
98 * Recognising the mapping on the text would mean chasing every legal way of writing it:
99 * ::ffff:8.8.8.8 and ::ffff:0808:0808 are the same address, and a check that only knows
100 * the dotted form leaves the other one looking like a plain IPv6 address. All of them
101 * collapse to the same sixteen bytes, so the binary form is the one place to look.
102 */
103 if ( false !== $packed && 16 === strlen( $packed ) && "\0\0\0\0\0\0\0\0\0\0\xff\xff" === substr( $packed, 0, 12 ) ) {
104 $unmapped = inet_ntop( substr( $packed, 12, 4 ) );
105
106 if ( false !== $unmapped ) {
107 return $unmapped;
108 }
109 }
110
111 return $ip;
112 }
113
114 /**
115 * Tell whether an address falls inside a CIDR range.
116 *
117 * Works on both IPv4 and IPv6 by masking the packed binary forms, so an IPv4 address is
118 * never matched against an IPv6 range or the other way round.
119 *
120 * @param string $ip The address to test.
121 * @param string $range A CIDR range, or a single address when it carries no slash.
122 * @return bool True when the address is inside the range.
123 */
124 function cys_ip_in_range( $ip, $range ) {
125 if ( false === strpos( $range, '/' ) ) {
126 return $ip === $range;
127 }
128
129 list( $subnet, $prefix ) = explode( '/', $range, 2 );
130
131 $ip_packed = inet_pton( $ip );
132 $subnet_packed = inet_pton( $subnet );
133
134 if ( false === $ip_packed || false === $subnet_packed || strlen( $ip_packed ) !== strlen( $subnet_packed ) ) {
135 return false;
136 }
137
138 /*
139 * This list is extended from the site, so a typo has to fail closed: '10.0.0.0/' and
140 * '10.0.0.0/abc' both cast to a prefix of 0, and a prefix of 0 matches every address
141 * there is. Turning one mistyped range into "trust the whole internet" is exactly
142 * what this function exists to prevent.
143 */
144 if ( ! ctype_digit( $prefix ) || (int) $prefix > strlen( $ip_packed ) * 8 ) {
145 return false;
146 }
147
148 $prefix = (int) $prefix;
149 $full_bytes = (int) ( $prefix / 8 );
150 $spare_bits = $prefix % 8;
151
152 $mask = str_repeat( "\xFF", $full_bytes );
153 if ( $spare_bits > 0 ) {
154 $mask .= chr( ( 0xFF << ( 8 - $spare_bits ) ) & 0xFF );
155 }
156 $mask = str_pad( $mask, strlen( $ip_packed ), "\x00" );
157
158 return ( $ip_packed & $mask ) === ( $subnet_packed & $mask );
159 }
160
161 /**
162 * Tell whether an address falls inside any of the given ranges.
163 *
164 * @param string $ip The address to test.
165 * @param array $ranges List of CIDR ranges.
166 * @return bool True when the address is inside one of them.
167 */
168 function cys_ip_in_any_range( $ip, $ranges ) {
169 foreach ( $ranges as $range ) {
170 if ( is_string( $range ) && cys_ip_in_range( $ip, $range ) ) {
171 return true;
172 }
173 }
174
175 return false;
176 }
177
178 /**
179 * Tell whether an address belongs to one of the site's own proxies.
180 *
181 * @param string $ip The address to test.
182 * @return bool True when the address is a trusted proxy.
183 */
184 function cys_is_trusted_proxy( $ip ) {
185 // Normalized here too, so the answer does not depend on which form the caller passed in.
186 $ip = cys_normalize_ip( $ip );
187
188 if ( ! filter_var( $ip, FILTER_VALIDATE_IP ) ) {
189 return false;
190 }
191
192 /*
193 * A peer on a private or reserved network did not reach the site across the public
194 * internet: on the overwhelming majority of installations it is infrastructure of the
195 * site itself, a reverse proxy, a load balancer or a container gateway.
196 *
197 * The trade-off is deliberate. mod_remoteip ships no defaults at all and makes the
198 * administrator name every proxy, which is stricter but leaves the common
199 * nginx-to-PHP-FPM setup reporting 127.0.0.1 for every visitor until someone
200 * configures it. The cost of trusting these networks is that on a site also reachable
201 * from an internal LAN, someone already inside that network can dictate the address
202 * shown back to them by sending their own X-Forwarded-For.
203 */
204 if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
205 return true;
206 }
207
208 return cys_ip_in_any_range( $ip, cys_trusted_proxies() );
209 }
210
211 /**
212 * Read the address a trusted proxy forwarded.
213 *
214 * Cloudflare states a single address in CF-Connecting-IP, so that one is read as is, but
215 * only from a request that really arrived from Cloudflare: any other proxy would simply
216 * be handing on a header the visitor wrote.
217 *
218 * X-Forwarded-For is instead a chain, "client, proxy1, proxy2", to which every hop
219 * APPENDS the address it received the connection from. Nothing stops a visitor from
220 * sending a chain of their own, which the first proxy then appends to, so the leftmost
221 * entry is whatever the visitor decided to put there: reading it hands the answer back
222 * to the party being identified. The chain is walked from the right instead, stepping
223 * over the site's own proxies, and the first address that is not one of them is the
224 * furthest point a proxy of ours actually observed.
225 *
226 * @param string $remote_addr The address that opened the connection.
227 * @return string The forwarded address, or an empty string when there is none to trust.
228 */
229 function cys_forwarded_client_ip( $remote_addr ) {
230 if ( ! empty( $_SERVER['HTTP_CF_CONNECTING_IP'] ) && cys_ip_in_any_range( $remote_addr, cys_cloudflare_ranges() ) ) {
231 $forwarded = cys_normalize_ip( sanitize_text_field( wp_unslash( $_SERVER['HTTP_CF_CONNECTING_IP'] ) ) );
232
233 if ( filter_var( $forwarded, FILTER_VALIDATE_IP ) ) {
234 return $forwarded;
235 }
236 }
237
238 if ( empty( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) {
239 return '';
240 }
241
242 $chain = explode( ',', sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_FORWARDED_FOR'] ) ) );
243
244 foreach ( array_reverse( $chain ) as $hop ) {
245 $hop = cys_normalize_ip( trim( $hop ) );
246
247 if ( ! filter_var( $hop, FILTER_VALIDATE_IP ) ) {
248 return ''; // A malformed hop breaks the chain of custody: stop believing the rest of it.
249 }
250
251 if ( ! cys_is_trusted_proxy( $hop ) ) {
252 return $hop;
253 }
254 }
255
256 return '';
257 }
258
259 /**
260 * Resolve the address of whoever is asking for the page.
261 *
262 * @return string A validated IP address, or an empty string when none can be established.
263 */
264 function cys_get_client_ip() {
265 if ( empty( $_SERVER['REMOTE_ADDR'] ) ) {
266 return '';
267 }
268
269 // Unslash and sanitize the IP address from $_SERVER.
270 $remote_addr = cys_normalize_ip( sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) );
271
272 if ( ! filter_var( $remote_addr, FILTER_VALIDATE_IP ) ) {
273 return '';
274 }
275
276 /*
277 * Forwarded headers are read only when the request actually arrived through one of
278 * the site's proxies. On a direct connection they are just text the visitor typed.
279 */
280 if ( ! cys_is_trusted_proxy( $remote_addr ) ) {
281 return $remote_addr;
282 }
283
284 $forwarded = cys_forwarded_client_ip( $remote_addr );
285
286 return ( '' !== $forwarded ) ? $forwarded : $remote_addr;
287 }
288
289 /**
290 * Retrieve user IP address with security validation and sanitization.
291 *
292 * Returns '0.0.0.0' as a safe default if no valid IP is found.
293 *
294 * Note: Displaying user IP addresses may have GDPR implications.
295 * Site administrators should ensure proper privacy policy disclosure.
296 *
297 * @return string The user's IP address, escaped for safe output.
298 */
299 function cys_retrieve_ip() {
300 /*
301 * The output differs for every visitor, so the page must not be stored in a full page
302 * cache: one visitor's address would be served to everybody after them. DONOTCACHEPAGE
303 * is the constant the WordPress caching plugins look for and is what actually does the
304 * work here. The nocache_headers() call is a second line of defence for the rare case
305 * where output has not started yet, since it returns without doing anything once the
306 * headers have been sent, which is the normal state of affairs by the time a shortcode
307 * runs. Neither of the two reaches a cache sitting in front of the web server, so a
308 * site with "cache everything" enabled at the edge has to exclude the page there too.
309 */
310 if ( ! defined( 'DONOTCACHEPAGE' ) ) {
311 define( 'DONOTCACHEPAGE', true );
312 }
313 nocache_headers();
314
315 $ip = cys_get_client_ip();
316
317 // Default safe value if no valid IP found.
318 if ( '' === $ip ) {
319 $ip = '0.0.0.0';
320 }
321
322 // Backward-compatible deprecated hook — fires only if someone is using it.
323 $ip = apply_filters_deprecated( 'wpb_get_ip', array( $ip ), '2.5', 'cys_get_ip' );
324
325 // Escape output for security and apply filters.
326 return esc_html( apply_filters( 'cys_get_ip', $ip ) );
327 }
328 add_shortcode( 'show_user_ip', 'cys_retrieve_ip' );
329