Skip to content

Commit a22a4ee

Browse files
ShukriShukri
authored andcommitted
fix: IP spoofing — only trust XFF when REMOTE_ADDR is a known trusted proxy
Previously X-Forwarded-For was blindly trusted (position [0]) regardless of where the request came from, allowing any client to spoof their IP. Now XFF is only read when REMOTE_ADDR matches a trusted proxy list (default: RFC-1918 ranges). The rightmost non-trusted IP in the XFF chain is returned, which cannot be spoofed by the client. New filters: devpulse_trusted_proxies — override the trusted proxy list devpulse_trust_proxy_headers — disable entirely for direct-internet servers Also adds sdk_version field to ingest payload.
1 parent 848c83e commit a22a4ee

1 file changed

Lines changed: 81 additions & 18 deletions

File tree

src/Handler.php

Lines changed: 81 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)