@@ -500,15 +500,16 @@ private function build_from_exception( \Throwable $e ): array {
500500 $ stacktrace = $ this ->build_stacktrace ( $ e );
501501
502502 $ payload = [
503- 'level ' => 'error ' ,
504- 'exception ' => [
503+ 'level ' => 'error ' ,
504+ 'exception ' => [
505505 'type ' => get_class ( $ e ),
506506 'message ' => $ e ->getMessage (),
507507 'stacktrace ' => $ stacktrace ,
508508 ],
509- 'context ' => $ this ->build_context (),
510- 'request ' => $ this ->build_request (),
511- 'timestamp ' => gmdate ( 'c ' ),
509+ 'context ' => $ this ->build_context (),
510+ 'request ' => $ this ->build_request (),
511+ 'sdk_version ' => 'devpulse-wordpress/2.0.0 ' ,
512+ 'timestamp ' => gmdate ( 'c ' ),
512513 ];
513514
514515 // Attribute the error to the first plugin/theme-owned frame in the trace.
@@ -714,28 +715,68 @@ private function build_request(): ?array {
714715 /**
715716 * Resolve the real client IP, accounting for reverse proxies and CDNs.
716717 *
717- * Checks X-Forwarded-For → X-Real-IP → REMOTE_ADDR in order.
718- * Disable proxy header trust on direct-to-internet servers with:
718+ * Reads X-Forwarded-For only when REMOTE_ADDR is a known trusted proxy.
719+ * This prevents IP spoofing — an attacker cannot fake their IP by injecting
720+ * an X-Forwarded-For header unless the request actually arrives via a proxy
721+ * whose IP you trust.
722+ *
723+ * Configure trusted proxy IPs/CIDRs with:
724+ * add_filter( 'devpulse_trusted_proxies', fn() => [ '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16' ] );
725+ *
726+ * Disable proxy header trust entirely on direct-to-internet servers with:
719727 * add_filter( 'devpulse_trust_proxy_headers', '__return_false' );
720728 *
721- * @since 1 .0.0
729+ * @since 2 .0.0
722730 * @return string|null
723731 */
724732 private function resolve_client_ip (): ?string {
733+ $ remote_addr = isset ( $ _SERVER ['REMOTE_ADDR ' ] )
734+ ? sanitize_text_field ( wp_unslash ( $ _SERVER ['REMOTE_ADDR ' ] ) )
735+ : null ;
736+
725737 /**
726738 * Filter: Whether to read X-Forwarded-For / X-Real-IP headers.
727739 *
728- * Disable on servers where proxy headers are not sanitised upstream.
729- *
730740 * @since 1.0.0
731741 * @param bool $trust
732742 */
733- if ( apply_filters ( 'devpulse_trust_proxy_headers ' , true ) ) {
743+ if ( ! apply_filters ( 'devpulse_trust_proxy_headers ' , true ) ) {
744+ return $ remote_addr ;
745+ }
746+
747+ /**
748+ * Filter: List of trusted proxy IP addresses or CIDR ranges.
749+ *
750+ * X-Forwarded-For is only trusted when REMOTE_ADDR matches one of these.
751+ * Defaults to RFC-1918 private ranges (suitable for most WordPress hosting
752+ * behind a load balancer on a private network). Override with your actual
753+ * proxy IPs for stricter control.
754+ *
755+ * @since 2.0.0
756+ * @param string[] $proxies IP addresses or CIDR notation ranges.
757+ */
758+ $ trusted_proxies = apply_filters ( 'devpulse_trusted_proxies ' , [
759+ '10.0.0.0/8 ' ,
760+ '172.16.0.0/12 ' ,
761+ '192.168.0.0/16 ' ,
762+ '127.0.0.1 ' ,
763+ '::1 ' ,
764+ ] );
765+
766+ if ( $ remote_addr && $ this ->ip_in_list ( $ remote_addr , $ trusted_proxies ) ) {
734767 if ( ! empty ( $ _SERVER ['HTTP_X_FORWARDED_FOR ' ] ) ) {
735- // XFF may be a comma-separated list; the first entry is the original client.
736- $ ip = trim ( explode ( ', ' , sanitize_text_field ( wp_unslash ( $ _SERVER ['HTTP_X_FORWARDED_FOR ' ] ) ) )[0 ] ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
737- if ( filter_var ( $ ip , FILTER_VALIDATE_IP ) ) {
738- return sanitize_text_field ( $ ip );
768+ // XFF is a comma-separated list appended left-to-right.
769+ // Walk right-to-left and return the first IP that is NOT itself
770+ // a trusted proxy — that is the real client IP.
771+ $ ips = array_reverse ( array_map ( 'trim ' , explode ( ', ' , wp_unslash ( $ _SERVER ['HTTP_X_FORWARDED_FOR ' ] ) ) ) ); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized
772+ foreach ( $ ips as $ candidate ) {
773+ $ candidate = sanitize_text_field ( $ candidate );
774+ if (
775+ filter_var ( $ candidate , FILTER_VALIDATE_IP ) &&
776+ ! $ this ->ip_in_list ( $ candidate , $ trusted_proxies )
777+ ) {
778+ return $ candidate ;
779+ }
739780 }
740781 }
741782
@@ -747,9 +788,31 @@ private function resolve_client_ip(): ?string {
747788 }
748789 }
749790
750- return isset ( $ _SERVER ['REMOTE_ADDR ' ] )
751- ? sanitize_text_field ( wp_unslash ( $ _SERVER ['REMOTE_ADDR ' ] ) )
752- : null ;
791+ return $ remote_addr ;
792+ }
793+
794+ /**
795+ * Check whether an IP address falls within any of the given IPs or CIDR ranges.
796+ *
797+ * @param string $ip IP address to test.
798+ * @param string[] $list IPs or CIDR ranges (e.g. "10.0.0.0/8").
799+ * @return bool
800+ */
801+ private function ip_in_list ( string $ ip , array $ list ): bool {
802+ $ ip_long = ip2long ( $ ip );
803+ foreach ( $ list as $ entry ) {
804+ if ( strpos ( $ entry , '/ ' ) !== false ) {
805+ [ $ range , $ bits ] = explode ( '/ ' , $ entry , 2 );
806+ $ mask = $ bits >= 32 ? -1 : ~( ( 1 << ( 32 - (int ) $ bits ) ) - 1 );
807+ $ network = ip2long ( $ range ) & $ mask ;
808+ if ( $ ip_long !== false && ( $ ip_long & $ mask ) === $ network ) {
809+ return true ;
810+ }
811+ } elseif ( $ ip === $ entry ) {
812+ return true ;
813+ }
814+ }
815+ return false ;
753816 }
754817
755818 // ── HTTP Transport ────────────────────────────────────────────────────
0 commit comments