'Private configuration or repository path', 'input.sensitive-traversal' => 'Traversal toward a sensitive system file', 'input.php-wrapper' => 'PHP stream wrapper targeting code or configuration', 'input.command-pipe' => 'Remote download piped into a command shell', 'input.sql-extract' => 'SQL UNION extraction with a table reference', 'input.php-execution' => 'PHP request input sent to an execution function', 'patch.litespeed-role-simulation' => 'Vulnerable LiteSpeed role-simulation cookies', 'reputation.compromised-source' => 'Connection source in the enabled compromised-IP feed', 'bot.endpoint-rate' => 'Excessive requests to sign-in or XML-RPC endpoints', ); public static function register(): void { add_action( 'init', array( self::class, 'guard' ), -100 ); } public static function configuration(): array { $raw = get_option( 'antivirus_ai_firewall', array() ); $raw = is_array( $raw ) ? $raw : array(); return array( 'mode' => in_array( $raw['mode'] ?? '', array( 'observe', 'block' ), true ) ? $raw['mode'] : 'off', 'exceptions' => is_array( $raw['exceptions'] ?? null ) ? array_slice( $raw['exceptions'], 0, 32 ) : array(), 'updated' => (int) ( $raw['updated'] ?? 0 ) ); } public static function route( string $uri ): string { $path = explode( '?', substr( $uri, 0, 4096 ), 2 )[0]; return str_replace( '\\', '/', rawurldecode( $path ) ); } /** An altered or unsupported method cannot acquire an administrator's exact exception. */ public static function requestMethod(): string { $method = self::metadata( $_SERVER, 'REQUEST_METHOD', 32 ); return preg_match( '/^[A-Z]{1,16}$/D', $method ) ? $method : ''; } /** Only bounded, unchanged sanitized metadata can be used as a routing value. */ private static function metadata( array $server, string $key, int $maximum, string $invalid = '' ): string { if ( ! isset( $server[ $key ] ) ) { return ''; } if ( ! is_string( $server[ $key ] ) || strlen( $server[ $key ] ) > $maximum ) { return $invalid; } $value = wp_unslash( $server[ $key ] ); $clean = sanitize_text_field( $value ); return $clean === $value && ! preg_match( '/[\x00-\x1f\x7f]/', $value ) ? $clean : $invalid; } /** Inspection-only bytes: no display, redirect or filesystem use; logs retain a fingerprint. */ public static function requestUri(): string { if ( ! isset( $_SERVER['REQUEST_URI'] ) || ! is_string( $_SERVER['REQUEST_URI'] ) ) { return ''; } // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- A URI is hostile evidence for path rules, not trusted text. Text sanitization would erase percent-encoded attacks. Bound its raw view and retain only a fingerprint in activity. return substr( wp_unslash( $_SERVER['REQUEST_URI'] ), 0, 4096 ); } /** Malformed metadata can only add bounded JSON inspection; it cannot suppress it. */ private static function requestContentType(): string { return self::metadata( $_SERVER, 'CONTENT_TYPE', 512, 'application/json' ); } /** Pure inspection: never executes, logs, stores, or mutates supplied content. */ public static function inspect( string $uri, array $query, array $post, string $body = '', string $contentType = '' ): array { $matches = array(); $limited = false; $route = self::route( $uri ); if ( preg_match( '#(?:^|/)(?:\.git(?:/|$)|\.env(?:\.[a-z0-9_-]+)?$|wp-config\.php(?:[.~_-].*)?$)#i', $route ) ) { $matches[] = array( 'rule' => 'path.private-file', 'parameter' => '@path' ); } $values = array(); $budget = 131072; $walk = static function ( array $items, string $prefix = '', int $depth = 0, bool $ambiguous = false ) use ( &$walk, &$values, &$budget, &$limited ): void { if ( $depth > 6 ) { $limited = true; return; } foreach ( $items as $key => $value ) { if ( count( $values ) >= 256 || $budget <= 0 ) { $limited = true; return; } $name = '' === $prefix ? (string) $key : $prefix . '.' . (string) $key; $unsafeName = $ambiguous || ! preg_match( '/^[a-zA-Z0-9_-]{1,64}$/D', (string) $key ) || strlen( $name ) > 128; if ( is_array( $value ) ) { $walk( $value, $name, $depth + 1, $unsafeName ); continue; } if ( ! is_scalar( $value ) && null !== $value ) { continue; } $value = (string) $value; if ( strlen( $value ) > 16384 || strlen( $value ) > $budget ) { $limited = true; } // Unsupported names remain inspected, but cannot inherit an exception by prefix or dotted-key collision. $values[] = array( $unsafeName ? '@unscoped:' . hash( 'sha256', $name ) : $name, substr( $value, 0, min( 16384, $budget ) ) ); $budget -= min( strlen( $value ), 16384 ); } }; $walk( $query, 'query' ); $walk( $post, 'form' ); if ( '' !== $body && str_contains( strtolower( $contentType ), 'application/json' ) ) { if ( strlen( $body ) > 131072 ) { $limited = true; } else { try { $json = json_decode( $body, true, 8, JSON_THROW_ON_ERROR ); if ( is_array( $json ) ) { $walk( $json, 'json' ); } } catch ( \Throwable $error ) { $limited = true; } } } foreach ( $values as [$name, $value] ) { // WordPress already decoded query/form data. A second view handles common single-layer evasion. foreach ( array_unique( array( $value, rawurldecode( $value ) ) ) as $text ) { $flat = str_replace( '\\', '/', $text ); $checks = array( 'input.sensitive-traversal' => '~(?:\.\./){1,}(?:[a-z0-9_.-]+/)*(?:etc/(?:passwd|shadow)|wp-config\.php|\.env)(?:$|[\x00?#\s])~i', 'input.php-wrapper' => '~(?:php://filter/[^\r\n]{0,200}resource=(?:[^\r\n]{0,120}/)?(?:wp-config\.php|\.env)|data://text/(?:plain|html)(?:;base64)?,\s*(?:<\?php|PD9waHA))~i', 'input.command-pipe' => '~(?:^|[;&|`\r\n])\s*(?:curl|wget)\s+[^\r\n|]{1,300}\|\s*(?:/bin/)?(?:ba)?sh\b~i', 'input.sql-extract' => '~(?:\x27|\x22|\)|\b\d)\s*(?:/\*[^*]{0,40}\*/\s*)?union\s+(?:all\s+)?select\s+[^\r\n;]{1,160}\s+from\s+[`a-z0-9_]+~i', 'input.php-execution' => '~<\?(?:php|=)\s*[^\r\n]{0,100}\b(?:eval|system|exec|passthru|shell_exec)\s*\(\s*\$_(?:GET|POST|REQUEST|COOKIE)\s*\[~i', ); foreach ( $checks as $id => $pattern ) { if ( 1 === preg_match( $pattern, $flat ) ) { $matches[ $id . ':' . $name ] = array( 'rule' => $id, 'parameter' => $name ); } } } } return array( 'matches' => array_values( $matches ), 'limited' => $limited ); } public static function exception( array $input ): array { $rule = $input['rule'] ?? ''; $path = $input['path'] ?? ''; $parameter = $input['parameter'] ?? ''; $method = $input['method'] ?? ''; if ( ! is_string( $rule ) || ! isset( self::RULES[ $rule ] ) || ! is_string( $path ) || ! preg_match( '~^/[a-zA-Z0-9/_.\x7e-]{0,199}$~D', $path ) || str_contains( $path, '..' ) || ! is_string( $parameter ) || ! preg_match( '/^(?:@path|@source|[a-zA-Z0-9_.-]{1,128})$/D', $parameter ) || ! in_array( $method, array( 'GET', 'POST' ), true ) ) { throw new \InvalidArgumentException( 'Choose one rule, exact path, request method and parameter. Wildcards and query strings are not allowed.' ); } $reason = is_string( $input['reason'] ?? null ) ? sanitize_text_field( $input['reason'] ) : ''; if ( strlen( $reason ) < 10 || strlen( $reason ) > 300 ) { throw new \InvalidArgumentException( 'Explain the exception in 10–300 characters.' ); } return array( 'id' => bin2hex( random_bytes( 12 ) ), 'rule' => $rule, 'path' => $path, 'parameter' => $parameter, 'method' => $method, 'reason' => $reason, 'expires' => time() + 604800 ); } public static function excepted( array $match, string $path, string $method, array $exceptions, ?int $now = null ): bool { foreach ( array_slice( $exceptions, 0, 32 ) as $exception ) { if ( is_array( $exception ) && (int) ( $exception['expires'] ?? 0 ) > ( $now ?? time() ) && (int) $exception['expires'] <= ( $now ?? time() ) + 604800 && ( $exception['rule'] ?? '' ) === $match['rule'] && ( $exception['parameter'] ?? '' ) === $match['parameter'] && ( $exception['path'] ?? '' ) === $path && ( $exception['method'] ?? '' ) === $method ) { return true; } } return false; } public static function guard(): void { if ( ( defined( 'WP_CLI' ) && WP_CLI ) || ( defined( 'DOING_CRON' ) && DOING_CRON ) || ( defined( 'ANTIVIRUS_AI_DISABLE_FIREWALL' ) && ANTIVIRUS_AI_DISABLE_FIREWALL ) ) { return; } $config = self::configuration(); if ( 'off' === $config['mode'] ) { return; } try { $contentType = self::requestContentType(); $body = ''; if ( str_contains( strtolower( $contentType ), 'application/json' ) ) { $stream = fopen( 'php://input', 'rb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Close the bounded php://input stream after request inspection. if ( $stream ) { $body = (string) stream_get_contents( $stream, 131073 ); fclose( $stream ); } } $uri = self::requestUri(); $method = self::requestMethod(); // phpcs:ignore WordPress.Security.NonceVerification.Recommended,WordPress.Security.NonceVerification.Missing -- This public firewall inspects attacks before authentication; requiring a form nonce would bypass protection. inspect() bounds depth, size and count; request values are never persisted, displayed or executed. $result = self::inspect( $uri, wp_unslash( $_GET ), wp_unslash( $_POST ), $body, $contentType ); $block = false; foreach ( $result['matches'] as $match ) { $excepted = self::excepted( $match, self::route( $uri ), $method, $config['exceptions'] ); $decision = $excepted ? 'excepted' : $config['mode']; self::record( $match['rule'], $decision, $method, self::route( $uri ), $match['parameter'] ); $block = $block || ( ! $excepted && 'block' === $config['mode'] ); } if ( $result['limited'] ) { self::record( 'inspection.limit', 'partial', $method, self::route( $uri ), '' ); } if ( $block ) { nocache_headers(); wp_die( esc_html__( 'This request matched a site protection rule. If you were completing a legitimate action, contact the site owner and describe the action. No request content is included in the security log.', 'shieldlume-security' ), esc_html__( 'Request blocked', 'shieldlume-security' ), array( 'response' => 403 ) ); } } catch ( \Throwable $error ) { // A firewall failure must not take the whole site offline or falsely report full coverage. update_option( 'antivirus_ai_firewall_warning', array( 'at' => time(), 'message' => 'Request inspection failed. This request continued without a firewall verdict; review local diagnostics.' ), false ); } } public static function record( string $rule, string $decision, string $method, string $path, string $parameter ): void { $key = 'avai_fw_' . substr( hash( 'sha256', $rule . $decision ), 0, 24 ); if ( get_transient( $key ) ) { return; } set_transient( $key, 1, 60 ); try { ( new Store() )->activity( 'firewall_' . $decision, array( 'rule' => $rule, 'rule_version' => self::VERSION, 'method' => in_array( $method, array( 'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS' ), true ) ? $method : 'other', 'route_fingerprint' => hash_hmac( 'sha256', $path, wp_salt( 'auth' ) ), 'parameter_fingerprint' => hash_hmac( 'sha256', $parameter, wp_salt( 'auth' ) ), 'meaning' => 'Sampled at most once per rule/decision/minute; no IP, URL query, field name or request value retained.' ) ); } catch ( \Throwable $error ) { update_option( 'antivirus_ai_firewall_warning', array( 'at' => time(), 'message' => 'A firewall event could not be stored. Request filtering continued; activity history is incomplete.' ), false ); } } }