0, 'generation' => self::INITIAL_GENERATION, 'header' => 'direct_peer', 'trusted' => array(), 'changed_at' => 0, ); } /** * Describe source-configuration changes and explicit observer tests. * * @return array Event definitions containing only allowed audit facts. */ public static function definitions(): array { return array( 'configuration.request_source_changed' => array( 'category' => 'configuration', 'action' => 'request_source_changed', 'severity' => 'notice', 'object_type' => 'site', 'fields' => array( 'proxy_count' => 'count', 'revision' => 'count', ), 'diffs' => array(), ), 'configuration.observer_test_started' => array( 'category' => 'configuration', 'action' => 'observer_test_started', 'severity' => 'info', 'object_type' => 'site', 'fields' => array(), 'diffs' => array(), ), ); } /** * Canonical IPv4/IPv6, including mapped IPv6 and explicit optional port syntax. * * @param string $input Address text to validate without trusting request headers. * @param bool $ports Whether an explicit valid port may accompany the address. * * @return string|null Canonical address, or null for invalid input. */ public static function address( string $input, bool $ports = false ): ?string { if ( strlen( $input ) > 128 || preg_match( '/[\x00-\x20\x7f]/', $input ) ) { return null; } $value = $input; if ( ! filter_var( $value, FILTER_VALIDATE_IP ) && $ports ) { if ( preg_match( '/\A\[([0-9a-fA-F:.]+)\](?::([0-9]{1,5}))?\z/D', $value, $parts ) ) { $value = $parts[1]; $port = $parts[2] ?? null; } elseif ( preg_match( '/\A([0-9.]+):([0-9]{1,5})\z/D', $value, $parts ) ) { $value = $parts[1]; $port = $parts[2]; } else { return null; } if ( null !== $port && ( (int) $port < 1 || (int) $port > 65535 ) ) { return null; } } if ( ! filter_var( $value, FILTER_VALIDATE_IP ) ) { return null; } $binary = inet_pton( $value ); if ( false === $binary ) { return null; } if ( 16 === strlen( $binary ) && substr( $binary, 0, 12 ) === str_repeat( "\x00", 10 ) . "\xff\xff" ) { $binary = substr( $binary, 12 ); } $address = inet_ntop( $binary ); return $address ? $address : null; } /** * Normalize an address or a CIDR with a nonzero prefix to its canonical network. * * @param string $input Address or CIDR, including mapped IPv6 notation. * * @return string|null Canonical network and prefix, or null when invalid. */ public static function cidr( string $input ): ?string { if ( strlen( $input ) > 80 ) { return null; } $parts = explode( '/', $input ); if ( count( $parts ) > 2 ) { return null; } $address = self::address( $parts[0] ); if ( null === $address ) { return null; } $binary = inet_pton( $address ); $bits = strlen( $binary ) * 8; $prefix = $parts[1] ?? (string) $bits; if ( ! preg_match( '/\A[0-9]{1,3}\z/D', $prefix ) ) { return null; } $prefix = (int) $prefix; // Mapped input prefixes refer to its original 128-bit representation. if ( str_contains( $parts[0], ':' ) && 32 === $bits && isset( $parts[1] ) ) { if ( $prefix < 96 ) { return null; } $prefix -= 96; } if ( $prefix < 1 || $prefix > $bits ) { return null; } $network = ''; $binary_length = strlen( $binary ); for ( $i = 0; $i < $binary_length; ++$i ) { $keep = max( 0, min( 8, $prefix - $i * 8 ) ); $network .= chr( ord( $binary[ $i ] ) & ( 0 === $keep ? 0 : 255 << 8 - $keep ) ); } return inet_ntop( $network ) . '/' . $prefix; } /** * Match a canonical address against the configured proxy networks. * * @param string $address Canonical IPv4 or IPv6 address. * @param array $networks Validated canonical CIDR strings. * * @return bool Whether a network contains the address. */ private static function trusted( string $address, array $networks ): bool { $binary = inet_pton( $address ); if ( false === $binary ) { return false; } foreach ( $networks as $network ) { [$base, $prefix] = explode( '/', $network ); $base = inet_pton( $base ); if ( false === $base || strlen( $base ) !== strlen( $binary ) ) { continue; } $bytes = intdiv( (int) $prefix, 8 ); $extra = (int) $prefix % 8; if ( substr( $binary, 0, $bytes ) === substr( $base, 0, $bytes ) && ( ! $extra || 0 === ( ( ord( $binary[ $bytes ] ) ^ ord( $base[ $bytes ] ) ) & 255 << 8 - $extra ) ) ) { return true; } } return false; } /** * Pure reducer. Left-side input beyond the first untrusted hop cannot override it. * * @param array $config Validated current proxy configuration. * @param string $peer Direct socket peer supplied by the request environment. * @param string|null $forwarded Forwarded chain, used only behind an explicitly trusted peer. * * @return array Transient address, derivation, quality and configuration generation. */ public static function derive( array $config, string $peer, ?string $forwarded ): array { $result = array( 'address' => null, 'derivation' => 'unavailable', 'quality' => 'invalid_peer', 'generation' => $config['generation'] ?? self::INITIAL_GENERATION, 'trusted_proxy' => false, ); $peer = self::address( $peer ); if ( null === $peer ) { return $result; } if ( 'direct_peer' === ( $config['header'] ?? '' ) ) { return array_merge( $result, array( 'address' => $peer, 'derivation' => 'direct_peer', 'quality' => 'exact', ) ); } if ( 'x_forwarded_for' !== ( $config['header'] ?? '' ) || ! is_array( $config['trusted'] ?? null ) ) { return array_merge( $result, array( 'quality' => 'config_unavailable' ) ); } if ( ! self::trusted( $peer, $config['trusted'] ) ) { return array_merge( $result, array( 'address' => $peer, 'derivation' => 'direct_peer', 'quality' => 'exact', ) ); } $result['trusted_proxy'] = true; $result['quality'] = 'header_missing'; if ( null === $forwarded || '' === $forwarded ) { return $result; } if ( strlen( $forwarded ) > 4096 || preg_match( '/[\x00-\x08\x0a-\x1f\x7f]/', $forwarded ) ) { return array_merge( $result, array( 'quality' => 'header_malformed' ) ); } $chain = explode( ',', $forwarded, self::MAX_HOPS + 1 ); if ( count( $chain ) > self::MAX_HOPS ) { return array_merge( $result, array( 'quality' => 'hop_limit' ) ); } for ( $i = count( $chain ) - 1; $i >= 0; $i-- ) { $hop = self::address( trim( $chain[ $i ], " \t" ), true ); if ( null === $hop ) { return array_merge( $result, array( 'quality' => 'header_malformed' ) ); } if ( ! self::trusted( $hop, $config['trusted'] ) ) { return array_merge( $result, array( 'address' => $hop, 'derivation' => 'trusted_proxy', 'quality' => 'exact', ) ); } } return array_merge( $result, array( 'quality' => 'all_hops_trusted' ) ); } /** * Read and validate the current site configuration without a cached projection. * * @return array|\WP_Error Valid configuration, direct-peer defaults, or a storage/validation error. */ public static function get(): array|\WP_Error { global $wpdb; $old = $wpdb->suppress_errors( true ); try { // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Reload actual proxy-trust configuration so generation changes and corrupt storage are not cached. $raw = $wpdb->get_var( $wpdb->prepare( "SELECT option_value FROM %i WHERE option_name=%s LIMIT 1", $wpdb->options, self::OPTION ) ); $error = $wpdb->last_error; } finally { $wpdb->suppress_errors( $old ); } if ( '' !== $error ) { return new \WP_Error( 'source_storage', 'Client-source configuration is unavailable.' ); } if ( null === $raw ) { return self::defaults(); } if ( ! is_string( $raw ) || strlen( $raw ) > 8192 ) { return new \WP_Error( 'source_config', 'Client-source configuration is invalid.' ); } $config = json_decode( $raw, true, 5 ); if ( ! is_array( $config ) || array_keys( $config ) !== array_keys( self::defaults() ) || ! is_int( $config['revision'] ) || $config['revision'] < 1 || $config['revision'] > 2147483647 || ! is_int( $config['changed_at'] ) || ! is_string( $config['generation'] ) || ! preg_match( '/\A[a-f0-9]{32}\z/D', $config['generation'] ) || ! in_array( $config['header'], array( 'direct_peer', 'x_forwarded_for' ), true ) || ! is_array( $config['trusted'] ) || count( $config['trusted'] ) > self::MAX_PROXIES ) { return new \WP_Error( 'source_config', 'Client-source configuration is invalid.' ); } foreach ( $config['trusted'] as $network ) { if ( ! is_string( $network ) || self::cidr( $network ) !== $network ) { return new \WP_Error( 'source_config', 'Client-source configuration is invalid.' ); } } if ( ( 'direct_peer' === $config['header'] && $config['trusted'] ) || ( 'x_forwarded_for' === $config['header'] && ! $config['trusted'] ) ) { return new \WP_Error( 'source_config', 'Client-source configuration is invalid.' ); } return $config; } /** * Authorized diagnostic projection; the internal derivation configuration stays unchanged. * * @param Scope $scope Current site authority for settings and optional sensitive context. * * @return array|\WP_Error Configuration with restricted networks omitted, or an access/storage error. */ public static function view( Scope $scope ): array|\WP_Error { if ( ! $scope->valid_for( 'logged_ai_manage_settings' ) ) { return new \WP_Error( 'forbidden', 'Request-source diagnostic access denied.' ); } $value = self::get(); if ( is_wp_error( $value ) ) { return $value; } if ( ! $scope->valid_for( 'logged_ai_manage_settings' ) ) { return new \WP_Error( 'forbidden', 'Request-source diagnostic authority changed.' ); } $value['trusted_count'] = count( $value['trusted'] ); if ( ! $scope->valid_for_all( array( 'logged_ai_manage_settings', 'logged_ai_view_sensitive_context' ) ) ) { if ( ! $scope->valid_for( 'logged_ai_manage_settings' ) ) { return new \WP_Error( 'forbidden', 'Request-source diagnostic authority changed.' ); } $value['trusted'] = array(); $value['context_reason'] = 'sensitive_permission_required'; } return $value; } /** * Derive transient request-source facts; CLI and cron have no client address. * * @return array Transient source facts, including explicit unavailable states. */ public static function current(): array { if ( ( defined( 'WP_CLI' ) && WP_CLI ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) ) { // @phpstan-ignore phpstanWP.wpConstant.fetch (Use the native cron entry flag so a filtered wp_doing_cron value cannot create human client-address attribution for scheduler work.) return array( 'address' => null, 'derivation' => 'unavailable', 'quality' => 'system_context', 'generation' => self::INITIAL_GENERATION, 'trusted_proxy' => false, ); } $config = self::get(); if ( is_wp_error( $config ) ) { return array( 'address' => null, 'derivation' => 'unavailable', 'quality' => 'config_unavailable', 'generation' => self::INITIAL_GENERATION, 'trusted_proxy' => false, ); } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Unslash the bounded peer string once before canonical address validation; do not repair malformed addresses. $peer = $_SERVER['REMOTE_ADDR'] ?? ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- Read only the explicitly configured header, then unslash its bounded string before strict proxy reduction. $forwarded = 'x_forwarded_for' === $config['header'] ? $_SERVER['HTTP_X_FORWARDED_FOR'] ?? null : null; return self::derive( $config, is_string( $peer ) ? wp_unslash( substr( $peer, 0, 258 ) ) : '', is_string( $forwarded ) ? wp_unslash( substr( $forwarded, 0, 8194 ) ) : null ); } /** * Explicit recovery of invalid stored configuration; never reset a healthy newer form. * * @param Scope $scope Current site settings authority. * @param bool $confirmed Explicit operator confirmation of recovery. * * @return array|\WP_Error Recovered configuration, or a recovery, cleanup or audit error. */ public static function reset_corrupt( Scope $scope, bool $confirmed ): array|\WP_Error { if ( ! $confirmed || ! $scope->valid_for( 'logged_ai_manage_settings' ) ) { return new \WP_Error( 'forbidden', 'Confirm authorized client-source recovery.' ); } $actor = Store::actor_context(); $result = Requests::locked( static function () use ( $scope ): array|\WP_Error { global $wpdb; $broken = self::get(); if ( ! $scope->valid_for( 'logged_ai_manage_settings' ) || ! is_wp_error( $broken ) || 'source_config' !== $broken->get_error_code() ) { return new \WP_Error( 'source_recovery', 'Recovery is available only for invalid stored configuration, after database access is restored.' ); } $value = array( 'revision' => 1, 'generation' => bin2hex( random_bytes( 16 ) ), 'header' => 'direct_peer', 'trusted' => array(), 'changed_at' => time(), ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Persist confirmed recovery of the actual corrupt source option under the request mutex. $saved = $wpdb->query( $wpdb->prepare( "UPDATE %i SET option_value=%s WHERE option_name=%s", $wpdb->options, wp_json_encode( $value ), self::OPTION ) ); if ( 1 !== $saved || ! $scope->valid_for( 'logged_ai_manage_settings' ) || self::get() !== $value ) { return new \WP_Error( 'source_storage', 'Source recovery could not be verified.' ); } return $value; } ); if ( is_wp_error( $result ) ) { return $result; } if ( ! $scope->valid_for( 'logged_ai_manage_settings' ) ) { return new \WP_Error( 'source_authority', 'Source recovery authority changed.' ); } Privacy::schedule(); $cleanup = Requests::erase_peer_context( $scope, 500 ); if ( ! $scope->valid_for( 'logged_ai_manage_settings' ) || ! Store::capture( array( 'code' => 'configuration.request_source_changed', 'actor_context' => $actor, 'outcome' => 'stored_change', 'object_id' => $scope->site_id, 'payload' => array( 'observed_via' => 'logged_ai_source_recovery', 'facts' => array( 'proxy_count' => 0, 'revision' => 1, ), ), ) ) ) { return new \WP_Error( 'audit_failed', 'Source configuration recovered, but its audit receipt is unavailable.' ); } if ( is_wp_error( $cleanup ) ) { return new \WP_Error( 'source_cleanup', 'Source configuration recovered; dependent target cleanup needs a retry.' ); } return $result; } /** * Save confirmed proxy settings against the current revision and optional generation. * * @param Scope $scope Current settings and sensitive-context authority. * @param string $header Supported direct-peer or forwarded-header mode. * @param array $networks Candidate proxy addresses or CIDRs, individually validated. * @param int $revision Revision observed by the operator before confirmation. * @param bool $confirmed Explicit confirmation of the submitted configuration. * @param string|null $generation Optional observed generation used to reject stale input. * * @return array|\WP_Error Verified configuration, or an error including partial cleanup/audit failures. */ public static function save( Scope $scope, string $header, array $networks, int $revision, bool $confirmed, ?string $generation = null ): array|\WP_Error { if ( ! $scope->valid_for_all( array( 'logged_ai_manage_settings', 'logged_ai_view_sensitive_context' ) ) ) { return new \WP_Error( 'forbidden', 'Client-source configuration denied.' ); } if ( ! $confirmed || ! in_array( $header, array( 'direct_peer', 'x_forwarded_for' ), true ) || count( $networks ) > self::MAX_PROXIES ) { return new \WP_Error( 'source_input', 'Confirm one supported header and at most 32 trusted proxy networks.' ); } $canonical = array(); foreach ( $networks as $input ) { $network = is_string( $input ) ? self::cidr( trim( $input ) ) : null; if ( null === $network ) { return new \WP_Error( 'source_input', 'Every trusted proxy must be a valid IPv4/IPv6 address or non-global CIDR.' ); } $canonical[] = $network; } $canonical = array_values( array_unique( $canonical ) ); sort( $canonical, SORT_STRING ); if ( ( 'direct_peer' === $header && $canonical ) || ( 'x_forwarded_for' === $header && ! $canonical ) ) { return new \WP_Error( 'source_input', 'Direct-peer mode has no trusted proxies; forwarded mode requires an explicit proxy set.' ); } $actor = Store::actor_context(); $result = Requests::locked( static function () use ( $scope, $header, $canonical, $revision, $generation ): array|\WP_Error { global $wpdb; if ( ! $scope->valid_for_all( array( 'logged_ai_manage_settings', 'logged_ai_view_sensitive_context' ) ) ) { return new \WP_Error( 'forbidden', 'Client-source authority changed.' ); } $before = self::get(); if ( is_wp_error( $before ) ) { return $before; } if ( $revision >= 2147483647 ) { return new \WP_Error( 'source_revision', 'Source configuration revision limit reached.' ); } if ( $before['revision'] !== $revision || ( null !== $generation && ! hash_equals( $before['generation'], $generation ) ) ) { return new \WP_Error( 'source_stale', 'Client-source configuration changed; reload before saving.' ); } if ( $before['header'] === $header && $before['trusted'] === $canonical ) { return $before; } $after = array( 'revision' => $revision + 1, 'generation' => bin2hex( random_bytes( 16 ) ), 'header' => $header, 'trusted' => $canonical, 'changed_at' => time(), ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- Persist the validated proxy configuration and new generation atomically with autoload disabled. $saved = $wpdb->query( $wpdb->prepare( "INSERT INTO %i (option_name,option_value,autoload) VALUES (%s,%s,'off') ON DUPLICATE KEY UPDATE option_value=VALUES(option_value),autoload='off'", $wpdb->options, self::OPTION, wp_json_encode( $after ) ) ); if ( false === $saved || ! $scope->valid_for_all( array( 'logged_ai_manage_settings', 'logged_ai_view_sensitive_context' ) ) || self::get() !== $after ) { return new \WP_Error( 'source_storage', 'Client-source configuration could not be verified after saving.' ); } return $after; } ); if ( is_wp_error( $result ) ) { return $result; } if ( ! $scope->valid_for_all( array( 'logged_ai_manage_settings', 'logged_ai_view_sensitive_context' ) ) ) { return new \WP_Error( 'source_authority', 'Source configuration may have changed; authority or scope changed before its receipt.' ); } if ( $result['revision'] !== $revision ) { Privacy::schedule(); $cleanup = Requests::erase_peer_context( $scope, 500 ); if ( ! $scope->valid_for_all( array( 'logged_ai_manage_settings', 'logged_ai_view_sensitive_context' ) ) || ! Store::capture( array( 'code' => 'configuration.request_source_changed', 'actor_context' => $actor, 'outcome' => 'stored_change', 'object_id' => $scope->site_id, 'payload' => array( 'observed_via' => 'logged_ai_request_source', 'facts' => array( 'proxy_count' => count( $canonical ), 'revision' => $result['revision'], ), ), ) ) ) { return new \WP_Error( 'audit_failed', 'Client-source configuration changed, but its audit summary could not be saved.' ); } if ( is_wp_error( $cleanup ) ) { return new \WP_Error( 'source_cleanup', 'Client-source configuration changed; dependent target cleanup needs a retry.' ); } $result['target_cleanup_pending'] = ! $cleanup['done']; } if ( ! $scope->valid_for_all( array( 'logged_ai_manage_settings', 'logged_ai_view_sensitive_context' ) ) ) { return new \WP_Error( 'forbidden', 'Source configuration receipt authority changed.' ); } return $result; } }