2, 'kind' => $kind, ); $safe_paths = RequestsClassifier::safe_paths(); if ( 'ip' === $kind ) { if ( 'full' !== $ip_mode || 'full' !== ( $evidence['ip_mode'] ?? '' ) || (int) ( $evidence['ip_expires_at'] ?? 0 ) <= time() || ! is_string( $evidence['client_mask'] ?? null ) || ! filter_var( $evidence['client_mask'], FILTER_VALIDATE_IP ) ) { return new \WP_Error( 'target', 'Exact-source response requires current, unexpired full safely derived address evidence and full IP mode.' ); } $target['ip'] = isset( $evidence['source_generation'] ) ? RequestsSource::address( $evidence['client_mask'] ) : inet_ntop( inet_pton( $evidence['client_mask'] ) ); $target['source_expires_at'] = (int) $evidence['ip_expires_at']; $target['source_captured_at'] = is_string( $evidence['captured_at'] ?? null ) ? (int) strtotime( $evidence['captured_at'] . ' UTC' ) : 0; $target['settings_revision'] = (int) ( $evidence['settings_revision'] ?? 0 ); if ( isset( $evidence['source_generation'] ) ) { if ( ! is_string( $evidence['source_generation'] ) || ! preg_match( '/\A[a-f0-9]{32}\z/D', $evidence['source_generation'] ) ) { return new \WP_Error( 'source', 'A valid source configuration generation is required.' ); } $target['source_generation'] = $evidence['source_generation']; } if ( $target['source_captured_at'] < 1 ) { return new \WP_Error( 'target', 'Exact-source evidence needs a known capture time.' ); } } elseif ( 'path' === $kind ) { if ( ! is_string( $path ) || ! in_array( $path, $safe_paths, true ) || ( $evidence['safe_path'] ?? '' ) !== $path ) { return new \WP_Error( 'target', 'Exact-path response requires a retained safe resource template. Root, business and management paths are unavailable.' ); } $target['path'] = $path; } elseif ( 'prefix' === $kind ) { if ( ! is_string( $path ) || ! in_array( $path, self::PREFIXES, true ) || ! is_string( $evidence['safe_path'] ?? null ) || ! self::segment_prefix( $evidence['safe_path'], $path ) ) { return new \WP_Error( 'target', 'Prefix response requires evidence in an available reserved source-control namespace.' ); } $target['path'] = $path; } elseif ( 'query' === $kind ) { $key = $input['key'] ?? ''; $operator = $input['operator'] ?? ''; $value = $input['value'] ?? ''; if ( ! is_string( $path ) || ( '/' !== $path && ! in_array( $path, $safe_paths, true ) ) || ! in_array( $key, self::QUERY_KEYS, true ) || ! in_array( $operator, array( 'key_exists', 'equals' ), true ) ) { return new \WP_Error( 'target', 'Choose a supported exact path, nonsecret numeric key and structured operator.' ); } if ( 'equals' === $operator && ( ! is_string( $value ) || ! preg_match( '/^[0-9]{1,12}$/D', $value ) ) ) { return new \WP_Error( 'target', 'Exact query values must be a reviewed nonsecret number of at most 12 digits.' ); } $target += array( 'path' => $path, 'key' => $key, 'operator' => $operator, ); if ( 'equals' === $operator ) { $target['value'] = $value; } } else { return new \WP_Error( 'target', 'Choose a supported policy type.' ); } $exceptions = $input['exceptions'] ?? array(); if ( ! is_array( $exceptions ) || count( $exceptions ) > 5 ) { return new \WP_Error( 'exceptions', 'At most five explicit exact-path exceptions are supported.' ); } $target['exceptions'] = array(); foreach ( $exceptions as $exception ) { if ( ! is_string( $exception ) || ! in_array( $exception, array_merge( self::EXCEPTION_PATHS, $safe_paths ), true ) ) { return new \WP_Error( 'exceptions', 'An exception must be one of the reviewed safe exact paths.' ); } $target['exceptions'][] = $exception; } $target['exceptions'] = array_values( array_unique( $target['exceptions'] ) ); sort( $target['exceptions'], SORT_STRING ); return $target; } /** * Validate stored manual conditions, canonical form and authentication hash. * * @param array $policy Stored or compiled policy fields being inspected. * * @return bool Whether the stored policy matches its supported canonical format. */ public static function valid( array $policy ): bool { if ( ( $policy['parser_version'] ?? '' ) !== self::VERSION || 'manual' !== ( $policy['origin'] ?? '' ) || 1 !== (int) ( $policy['revision'] ?? 0 ) || 'GET,HEAD' !== ( $policy['methods'] ?? '' ) || ! in_array( (int) ( $policy['ttl'] ?? 0 ), Requests::TTLS, true ) || ! empty( $policy['target_erased'] ) ) { return false; } $json = $policy['target_json'] ?? ''; if ( ! is_string( $json ) || strlen( $json ) > 2048 ) { return false; } $target = json_decode( $json, true, 8 ); if ( ! is_array( $target ) || 2 !== ( $target['version'] ?? null ) ) { return false; } if ( 'ip' === ( $target['kind'] ?? '' ) && ( ! is_string( $policy['source_request_uid'] ?? null ) || ! preg_match( '/^[a-f0-9-]{36}$/D', $policy['source_request_uid'] ) ) ) { return false; } $evidence = array( 'safe_path' => $target['path'] ?? '', 'ip_mode' => 'full', 'client_mask' => $target['ip'] ?? null, 'ip_expires_at' => $target['source_expires_at'] ?? 0, 'captured_at' => gmdate( 'Y-m-d H:i:s', (int) ( $target['source_captured_at'] ?? 0 ) ), 'settings_revision' => $target['settings_revision'] ?? 0, ); if ( isset( $target['source_generation'] ) ) { $evidence['source_generation'] = $target['source_generation']; } if ( 'prefix' === ( $target['kind'] ?? '' ) ) { $evidence['safe_path'] .= '/config'; } // Source expiration is an evaluation/privacy condition, not corrupt JSON. if ( 'ip' === ( $target['kind'] ?? '' ) ) { $evidence['ip_expires_at'] = max( time() + 1, (int) $evidence['ip_expires_at'] ); } $canonical = self::compile( $target, $evidence, 'full' ); if ( is_wp_error( $canonical ) ) { return false; } if ( 'ip' === ( $target['kind'] ?? '' ) ) { $canonical['source_expires_at'] = $target['source_expires_at']; } return $canonical === $target && ( $policy['target_kind'] ?? '' ) === $target['kind'] && (int) ( $policy['target_expires_at'] ?? 0 ) === (int) ( $target['source_expires_at'] ?? 0 ) && (int) ( $policy['target_captured_at'] ?? 0 ) === (int) ( $target['source_captured_at'] ?? 0 ) && (int) ( $policy['target_settings_revision'] ?? 0 ) === (int) ( $target['settings_revision'] ?? 0 ) && (string) ( $policy['target_source_generation'] ?? RequestsSource::INITIAL_GENERATION ) === (string) ( $target['source_generation'] ?? RequestsSource::INITIAL_GENERATION ) && hash_equals( self::hash( $policy ), (string) ( $policy['compiled_hash'] ?? '' ) ); } /** * Authenticate the manual policy fields bound to its compiled target. * * @param array $policy Stored or compiled policy fields being inspected. * * @return string SHA-256 HMAC using the local WordPress authentication salt. */ public static function hash( array $policy ): string { $fields = array(); foreach ( array( 'network_id', 'site_id', 'creator', 'parser_version', 'safe_path', 'methods', 'ttl', 'evidence_id', 'revision', 'target_kind', 'target_expires_at', 'target_captured_at', 'target_settings_revision', 'source_request_uid', 'target_json' ) as $key ) { $fields[ $key ] = (string) ( $policy[ $key ] ?? '' ); } return hash_hmac( 'sha256', wp_json_encode( $fields ), wp_salt( 'auth' ) ); } /** * Build a transient bounded view for exact manual-policy matching. * * Transient matching view only. Callers must never persist its values. * * @param string $target Request target inspected transiently without persisting raw contents. * @param string $method Native HTTP method used for the matching decision. * @param string|null $peer Safely derived source address, or null when unavailable. * * @return array Parsed matching fields and explicit validity flags; never a persistent payload. */ public static function view( string $target, string $method, ?string $peer ): array { $valid = strlen( $target ) <= 16384 && in_array( $method, array( 'GET', 'HEAD' ), true ); $target = substr( $target, 0, 16384 ); $parts = explode( '?', $target, 2 ); $path = $parts[0]; $query = $parts[1] ?? ''; $valid = $valid && strlen( $path ) <= 8192 && strlen( $query ) <= 8192 && '' !== $path && '/' === $path[0] && ! str_contains( $path, '%' ) && ! str_contains( $path, '//' ) && ! str_contains( $path, '\\' ) && ! preg_match( '/[\x00-\x20\x7f#]/', $path ) && ! preg_match( '~(?:^|/)\.{1,2}(?:/|$)~', $path ) && 1 === preg_match( '//u', $target ); $pairs = '' === $query ? array() : explode( '&', $query, 129 ); // A host can accept other separators or truncate $_GET before our bounded view. // Refuse those ambiguous requests rather than matching a value PHP did not receive. $separators = (string) ini_get( 'arg_separator.input' ); $alternate = str_replace( '&', '', $separators ); $input_limit = max( 0, (int) ini_get( 'max_input_vars' ) ); $query_valid = count( $pairs ) <= min( 128, $input_limit ) && ! preg_match( '/%(?![0-9A-Fa-f]{2})/', $query ) && ! str_contains( $query, '#' ) && ( str_contains( $separators, '&' ) || ! str_contains( $query, '&' ) ) && ( '' === $alternate || false === strpbrk( $query, $alternate ) ); $values = array(); $seen = array(); foreach ( array_slice( $pairs, 0, 128 ) as $pair ) { $item = explode( '=', $pair, 2 ); $key = urldecode( $item[0] ); $value = urldecode( $item[1] ?? '' ); if ( isset( $seen[ $key ] ) || preg_match( '/[\x00-\x1f\x7f]/', $key . $value ) || 1 !== preg_match( '//u', $key . $value ) ) { $query_valid = false; } $seen[ $key ] = true; // PHP trims leading ASCII spaces before aliasing dotted/spaced keys and bracket syntax. // Reject aliases even when they appear only once: raw matching must not disagree with $_GET. $php_key = str_replace( array( '.', ' ' ), '_', explode( '[', ltrim( $key, ' ' ), 2 )[0] ); if ( $php_key !== $key && in_array( $php_key, self::QUERY_KEYS, true ) ) { $query_valid = false; } if ( ! in_array( $key, self::QUERY_KEYS, true ) ) { continue; } if ( array_key_exists( $key, $values ) ) { $query_valid = false; } if ( strlen( $value ) > 8192 || preg_match( '/[\x00-\x1f\x7f]/', $value ) || 1 !== preg_match( '//u', $value ) ) { $query_valid = false; } $values[ $key ] = $value; } $peer = is_string( $peer ) ? RequestsSource::address( $peer ) : null; return array( 'valid' => (bool) ( $valid && $query_valid ), 'query_valid' => (bool) $query_valid, 'path' => $path, 'values' => $values, 'peer' => $peer, ); } /** * Match an eligible compiled target while applying current privacy and exceptions. * * @param array $policy Stored or compiled policy fields being inspected. * @param array $view Transient request view produced for bounded policy matching. * @param array $settings Current privacy settings used to evaluate personal targets. * @param bool $ignore_exceptions Whether to test the target solely for suppressed-response facts. * * @return bool Whether this policy matches the transient request view. */ public static function matches( array $policy, array $view, array $settings, bool $ignore_exceptions = false ): bool { if ( ! $view['valid'] || self::personal_unavailable( $policy, $settings ) ) { return false; } $target = json_decode( $policy['target_json'], true, 8 ); if ( ! is_array( $target ) || ( ! $ignore_exceptions && in_array( $view['path'], $target['exceptions'] ?? array(), true ) ) ) { return false; } return match ( $target['kind'] ) { 'ip' => 'full' === $settings['ip_mode'] && (int) $target['source_expires_at'] > time() && (int) $target['source_captured_at'] + max( 1, (int) $settings['exact_ip_days'] ) * DAY_IN_SECONDS > time() && null !== $view['peer'] && hash_equals( RequestsSource::address( $target['ip'] ) ?? '', $view['peer'] ), 'path' => $view['path'] === $target['path'], 'prefix' => self::segment_prefix( $view['path'], $target['path'] ), 'query' => $view['path'] === $target['path'] && $view['query_valid'] && array_key_exists( $target['key'], $view['values'] ) && ( 'key_exists' === $target['operator'] || $view['values'][ $target['key'] ] === $target['value'] ), default => false, }; } /** * Evaluate whether current privacy or source generation disables an IP target. * * Current privacy always disables expired/reduced personal targets before physical cleanup. * * @param array $policy Stored or compiled policy fields being inspected. * @param array $settings Current privacy settings used to evaluate personal targets. * * @return bool Whether this personal target is unavailable under current settings. */ public static function personal_unavailable( array $policy, array $settings ): bool { if ( 'ip' !== ( $policy['target_kind'] ?? '' ) ) { return false; } if ( array_key_exists( 'source_generation', $settings ) ) { $generation = $settings['source_generation']; } else { $source = RequestsSource::get(); $generation = is_wp_error( $source ) ? null : $source['generation']; } return ! is_string( $generation ) || ! hash_equals( $generation, (string) ( $policy['target_source_generation'] ?? RequestsSource::INITIAL_GENERATION ) ) || ( ! empty( $policy['target_erased'] ) || (int) ( $policy['target_settings_revision'] ?? 0 ) !== (int) ( $settings['revision'] ?? 0 ) || 'full' !== $settings['ip_mode'] || (int) ( $policy['target_expires_at'] ?? 0 ) <= time() || (int) ( $policy['target_captured_at'] ?? 0 ) + max( 1, (int) $settings['exact_ip_days'] ) * DAY_IN_SECONDS <= time() ); } /** * Match only the exact namespace or a child path at a slash boundary. * * @param string $path Selected request path or supported path condition. * @param string $prefix Supported namespace prefix without a trailing slash. * * @return bool Whether the path is within the selected namespace. */ private static function segment_prefix( string $path, string $prefix ): bool { return $path === $prefix || str_starts_with( $path, $prefix . '/' ); } }