0, 'blocking_score' => 0, 'rules' => array(), 'blocking_rules' => array(), 'evidence' => array(), 'inspection_incomplete' => false, 'blocking_incomplete' => false, 'reasons' => array() ); if ( ! $path_exception && self::is_graphql_probe( $path ) && ! self::graphql_endpoint_available() ) { $blocking = 'monitor' !== $mode && ! $admin_monitor_only; NLSSL_Core::log( $blocking ? 'graphql_probe_blocked' : 'graphql_probe_observed', $blocking ? 'warning' : 'info', $blocking ? 'A probe for an unavailable GraphQL endpoint was concealed.' : 'A probe for an unavailable GraphQL endpoint was observed.', array( 'path' => $path, 'mode' => $mode ) ); if ( $blocking ) { self::block( 404 ); } } if ( ! $path_exception ) { $result = self::inspect_inputs(); if ( $result['inspection_incomplete'] ) { NLSSL_Core::log( 'firewall_inspection_incomplete', 'warning', 'Request inspection reached a safety limit; this is not a clean verdict.', array( 'reasons' => $result['reasons'], 'blocking_incomplete' => $result['blocking_incomplete'], 'mode' => $mode ) ); if ( $result['blocking_incomplete'] && ! $admin_monitor_only && self::blocks_incomplete( $mode ) ) { self::block( 413 ); } } } if ( NLSSL_Core::setting( 'block_bad_user_agents', 1 ) ) { $ua = strtolower( substr( sanitize_text_field( wp_unslash( $_SERVER['HTTP_USER_AGENT'] ?? '' ) ), 0, 512 ) ); if ( preg_match( '/(?:sqlmap|nikto|masscan|zgrab|acunetix|wpscan|nuclei|dirbuster|gobuster)/i', $ua ) ) { $result['score'] += 90; $result['blocking_score'] += 90; $result['rules'][] = 'AUTOMATED_ATTACK_TOOL'; $result['blocking_rules'][] = 'AUTOMATED_ATTACK_TOOL'; } } $limit = (int) NLSSL_Core::setting( 'public_rate_limit_per_minute', 0 ); $anonymous = ! is_user_logged_in(); $verified_bot = $limit > 0 && $anonymous && NLSSL_Bots::rate_limit_exempt(); $compatibility_exempt = $limit > 0 && $anonymous && self::transactional_rate_limit_exempt(); if ( $limit > 0 && $anonymous && ! $verified_bot && ! $compatibility_exempt && NLSSL_Core::rate_hit( 'public_request', MINUTE_IN_SECONDS, $limit ) ) { $result['score'] += 80; $result['blocking_score'] += 80; $result['rules'][] = 'PUBLIC_RATE_LIMIT'; $result['blocking_rules'][] = 'PUBLIC_RATE_LIMIT'; } $result['rules'] = array_values( array_unique( $result['rules'] ) ); $result['blocking_rules'] = array_values( array_unique( $result['blocking_rules'] ) ); if ( empty( $result['rules'] ) ) { return; } $threshold = 'strict' === $mode ? 55 : 80; $blocking = 'monitor' !== $mode && ! $admin_monitor_only && $result['blocking_score'] >= $threshold; NLSSL_Core::log( $blocking ? 'firewall_blocked' : 'firewall_observed', $blocking ? 'critical' : 'warning', $blocking ? 'A hostile request shape was blocked.' : 'A suspicious request shape was observed.', array( 'rules' => $result['rules'], 'blocking_rules' => $result['blocking_rules'], 'score' => $result['score'], 'blocking_score' => $result['blocking_score'], 'mode' => $mode, 'path_exception' => $path_exception, 'admin_monitor_only' => $admin_monitor_only, 'evidence' => array_slice( $result['evidence'], 0, 12 ), ) ); if ( $blocking ) { self::block(); } } private static function skip_request(): bool { if ( defined( 'WP_CLI' ) && WP_CLI ) { return true; } if ( function_exists( 'wp_doing_cron' ) && wp_doing_cron() ) { return true; } if ( is_admin() && ( ! function_exists( 'wp_doing_ajax' ) || ! wp_doing_ajax() ) ) { return is_user_logged_in() && in_array( strtoupper( (string) ( $_SERVER['REQUEST_METHOD'] ?? 'GET' ) ), array( 'GET', 'HEAD' ), true ); } return false; } /** * WAF is an additional heuristic layer, not the authorization/CSRF boundary for * WordPress administration. A logged-in site administrator must not be locked * out of wp-admin by a heuristic payload match; WordPress still enforces each * handler's capability and nonce checks. Suspicious shapes remain fully logged. */ private static function admin_monitor_only(): bool { if ( ! is_user_logged_in() || ! function_exists( 'current_user_can' ) || ! current_user_can( 'manage_options' ) ) { return false; } if ( is_admin() ) { return true; } // Authenticated REST mutations used by Site Editor/settings integrations are // monitor-only only when WordPress' REST nonce is valid for this administrator. $route = self::rest_route(); if ( '' === $route || ! function_exists( 'wp_verify_nonce' ) ) { return false; } if ( isset( $_SERVER['HTTP_X_WP_NONCE'] ) ) { $nonce = sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_WP_NONCE'] ) ); } else { $nonce = isset( $_REQUEST['_wpnonce'] ) && is_string( $_REQUEST['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_REQUEST['_wpnonce'] ) ) : ''; } if ( '' === $nonce || ! wp_verify_nonce( $nonce, 'wp_rest' ) ) { return false; } return true; } public static function blocks_incomplete( string $mode ): bool { return 'strict' === $mode || ( 'balanced' === $mode && ( ! is_user_logged_in() || ! in_array( strtoupper( (string) ( $_SERVER['REQUEST_METHOD'] ?? 'GET' ) ), array( 'GET', 'HEAD' ), true ) ) ); } private static function authorized_content_fields(): array { if ( ! function_exists( 'current_user_can' ) || ! function_exists( 'wp_verify_nonce' ) || ! current_user_can( 'unfiltered_html' ) ) { return array(); } $path = NLSSL_Core::request_path(); $method = strtoupper( sanitize_key( wp_unslash( $_SERVER['REQUEST_METHOD'] ?? 'GET' ) ) ); if ( 'POST' === $method && $path === wp_parse_url( admin_url( 'post.php' ), PHP_URL_PATH ) && isset( $_POST['action'] ) && 'editpost' === sanitize_key( wp_unslash( $_POST['action'] ) ) ) { $id = isset( $_POST['post_ID'] ) && is_scalar( $_POST['post_ID'] ) ? absint( $_POST['post_ID'] ) : 0; $nonce = isset( $_POST['_wpnonce'] ) && is_string( $_POST['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_POST['_wpnonce'] ) ) : ''; if ( $id && current_user_can( 'edit_post', $id ) && '' !== $nonce && wp_verify_nonce( $nonce, 'update-post_' . $id ) ) { return array( 'post.content' ); } } // REST controllers perform the object-specific permission checks. For a logged-in // user who already has unfiltered_html, a valid wp_rest nonce is sufficient to // exempt only the three XSS signatures in the content field. Every other WAF rule // and the REST endpoint's own permission_callback remain active. This covers core // posts, reusable blocks, Site Editor templates/template parts/navigation and CPTs. $rest = self::rest_route(); if ( isset( $_SERVER['HTTP_X_WP_NONCE'] ) ) { $nonce = sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_WP_NONCE'] ) ); } else { $nonce = isset( $_GET['_wpnonce'] ) && is_string( $_GET['_wpnonce'] ) ? sanitize_text_field( wp_unslash( $_GET['_wpnonce'] ) ) : ''; } if ( in_array( $method, array( 'POST', 'PUT', 'PATCH' ), true ) && str_starts_with( $rest, '/wp/v2/' ) && '' !== $nonce && wp_verify_nonce( $nonce, 'wp_rest' ) ) { return array( 'post.content', 'json.content', 'json.content.raw' ); } return array(); } /** * Do not apply the optional generic page-view rate bucket to known WooCommerce * transactional AJAX calls. Their payload still passes through all WAF rules above; * this only prevents unrelated browsing/fragment traffic from consuming the same * generic counter and breaking cart/checkout state changes. */ private static function transactional_rate_limit_exempt(): bool { if ( ! class_exists( 'WooCommerce' ) && ! function_exists( 'WC' ) ) { return false; } $method = strtoupper( sanitize_key( wp_unslash( $_SERVER['REQUEST_METHOD'] ?? 'GET' ) ) ); // Classic wc-ajax operations. Payload inspection is not bypassed; only the // optional generic anonymous request bucket is skipped. if ( 'POST' === $method ) { // Routing inspection of an incoming request; it runs before any form handler, so no nonce exists here. // phpcs:disable WordPress.Security.NonceVerification.Recommended $action = isset( $_GET['wc-ajax'] ) && is_scalar( $_GET['wc-ajax'] ) ? sanitize_key( wp_unslash( (string) $_GET['wc-ajax'] ) ) : ''; // phpcs:enable WordPress.Security.NonceVerification.Recommended if ( in_array( $action, array( 'add_to_cart', 'remove_from_cart', 'get_refreshed_fragments', 'get_variation', 'apply_coupon', 'remove_coupon', 'update_order_review', 'update_shipping_method', 'get_cart_totals', 'get_customer_location', 'checkout', ), true ) && class_exists( 'WC_AJAX' ) && is_callable( array( 'WC_AJAX', 'get_endpoint' ) ) ) { try { $endpoint = (string) WC_AJAX::get_endpoint( $action ); } catch ( Throwable $error ) { $endpoint = ''; } $endpoint_path = wp_parse_url( $endpoint, PHP_URL_PATH ); $endpoint_path = is_string( $endpoint_path ) && '' !== $endpoint_path ? '/' . ltrim( $endpoint_path, '/' ) : '/'; $current_path = '/' . ltrim( NLSSL_Core::request_path(), '/' ); if ( hash_equals( untrailingslashit( $endpoint_path ) ?: '/', untrailingslashit( $current_path ) ?: '/' ) ) { return true; } } } // WooCommerce Blocks and current Cart/Checkout blocks use the public Store API. // These routes have their own cart/session plus nonce or Cart-Token semantics for // mutations, so do not let the unrelated page-view bucket break a transaction. if ( ! in_array( $method, array( 'GET', 'POST', 'PUT', 'PATCH', 'DELETE' ), true ) ) { return false; } $route = self::rest_route(); return 1 === preg_match( '#^/wc/store/v1/(?:cart(?:/|$)|checkout(?:/|$))#D', $route ); } private static function rest_route(): string { if ( ! function_exists( 'rest_url' ) ) { return ''; } $path = '/' . ltrim( NLSSL_Core::request_path(), '/' ); // Routing inspection of an incoming request, before any nonce exists. The route stays unsanitized on purpose: // it is only compared with the URL WordPress itself generates below, and sanitizing could make a crafted // route match an exemption that the raw route does not. // phpcs:disable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized if ( isset( $_GET['rest_route'] ) && is_scalar( $_GET['rest_route'] ) ) { $route = '/' . ltrim( (string) wp_parse_url( wp_unslash( (string) $_GET['rest_route'] ), PHP_URL_PATH ), '/' ); // phpcs:enable WordPress.Security.NonceVerification.Recommended, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized // Query-style REST routing is used on plain permalinks. Compare with the URL // WordPress itself generates so an arbitrary path plus ?rest_route= cannot turn // into a public-rate-limit exemption. $expected = rest_url( $route ); $expected_path = wp_parse_url( $expected, PHP_URL_PATH ); $expected_query = wp_parse_url( $expected, PHP_URL_QUERY ); parse_str( is_string( $expected_query ) ? $expected_query : '', $query ); if ( is_string( $expected_path ) && hash_equals( untrailingslashit( '/' . ltrim( $expected_path, '/' ) ) ?: '/', untrailingslashit( $path ) ?: '/' ) && isset( $query['rest_route'] ) ) { return $route; } return ''; } $prefix = wp_parse_url( rest_url(), PHP_URL_PATH ); if ( ! is_string( $prefix ) || '' === $prefix ) { return ''; } $prefix = trailingslashit( '/' . ltrim( $prefix, '/' ) ); if ( ! str_starts_with( $path, $prefix ) ) { return ''; } return '/' . ltrim( substr( $path, strlen( $prefix ) ), '/' ); } private static function path_allowed( string $path ): bool { $raw = (string) NLSSL_Core::setting( 'allowed_paths', '' ); foreach ( preg_split( '/\R/', $raw ) ?: array() as $allowed ) { $allowed = trim( $allowed ); if ( '' === $allowed ) { continue; } $allowed = untrailingslashit( $allowed ) ?: '/'; if ( $path === $allowed || ( '/' !== $allowed && str_starts_with( $path, $allowed . '/' ) ) ) { return true; } } return false; } public static function is_graphql_probe( string $path ): bool { $path = '/' . ltrim( strtolower( $path ), '/' ); $path = untrailingslashit( $path ) ?: '/'; return 1 === preg_match( '#^/(?:api/)?(?:v\d+/)?(?:graphql|graphiql|gql)$#', $path ); } private static function graphql_endpoint_available(): bool { return class_exists( 'WPGraphQL' ) || class_exists( 'GraphQL_API' ) || function_exists( 'graphql' ) || defined( 'WPGRAPHQL_VERSION' ); } private static function inspect_inputs( ?string $raw_body = null ): array { $hits = array(); $blocking_hits = array(); $evidence = array(); $reasons = array(); $blocking_reasons = array(); $content_fields = self::authorized_content_fields(); // Cookies, Referer and forwarded-host metadata are attacker-controllable or // frequently contain encoded application state. They remain observable but a // match there alone must never turn every page view into a 403. Blocking input // is restricted to the request target/payload and a small set of routing headers. // A request firewall has to see exactly what the client sent. WordPress adds slashes to every superglobal // before init, so each value is unslashed back to the original bytes, but deliberately not sanitized: // sanitizing would strip the very sequences the rules look for. The values are only pattern-matched and // are never output, stored verbatim or used in SQL. There is no nonce at this layer, because it inspects // every request before any form handler runs. // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing $sources = array( 'uri' => array( 'value' => (string) wp_unslash( $_SERVER['REQUEST_URI'] ?? '' ), 'blocking' => true ), 'get' => array( 'value' => wp_unslash( $_GET ), 'blocking' => true ), 'post' => array( 'value' => wp_unslash( $_POST ), 'blocking' => true ), 'cookie' => array( 'value' => wp_unslash( $_COOKIE ), 'blocking' => false ), 'headers' => array( 'value' => array( 'host' => wp_unslash( $_SERVER['HTTP_HOST'] ?? '' ), 'original_url' => wp_unslash( $_SERVER['HTTP_X_ORIGINAL_URL'] ?? '' ), 'rewrite_url' => wp_unslash( $_SERVER['HTTP_X_REWRITE_URL'] ?? '' ), 'method_override' => wp_unslash( $_SERVER['HTTP_X_HTTP_METHOD_OVERRIDE'] ?? '' ), // The User-Agent is the canonical delivery path for Log4Shell and Shellshock, // and unlike a cookie or a Referer it never carries application state, so it // is inspected as blocking input rather than observed. 'user_agent' => wp_unslash( $_SERVER['HTTP_USER_AGENT'] ?? '' ), ), 'blocking' => true, ), 'context_headers' => array( 'value' => array( 'referer' => wp_unslash( $_SERVER['HTTP_REFERER'] ?? '' ), 'forwarded_host' => wp_unslash( $_SERVER['HTTP_X_FORWARDED_HOST'] ?? '' ), ), 'blocking' => false, ), ); // phpcs:enable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Recommended, WordPress.Security.NonceVerification.Missing // The raw body is read for every request, whatever its method or declared type. WordPress's REST API decodes a // JSON body on GET as well and honours ?_method=, it reads labels such as "application/json," and // "application/json+oembed" as JSON, and webhook and plugin handlers often read php://input without looking at // the label at all, so neither the method nor the label is a reason to skip the body. A multipart POST leaves // php://input empty, because PHP has already parsed it into $_POST and $_FILES, which are inspected above. $content_type = sanitize_text_field( wp_unslash( $_SERVER['CONTENT_TYPE'] ?? '' ) ); if ( null === $raw_body ) { $raw_body = file_get_contents( 'php://input', false, null, 0, self::MAX_INPUT_BYTES + 1 ); } if ( false === $raw_body ) { $reasons[] = 'body_read'; $blocking_reasons[] = 'body_read'; $raw_body = ''; } if ( '' !== $raw_body && ! self::body_is_binary_file( $content_type, $raw_body ) ) { if ( strlen( $raw_body ) > self::MAX_INPUT_BYTES ) { $reasons[] = 'body_bytes'; $blocking_reasons[] = 'body_bytes'; } $raw_body = substr( $raw_body, 0, self::MAX_INPUT_BYTES ); $sources['body'] = array( 'value' => $raw_body, 'blocking' => true ); // JSON is recognised by its content as well as by its label: an escaped sequence such as \u003c only matches // once decoded, and a handler that decodes the body does not check the label first. $declared_json = self::declares_json( $content_type ); if ( $declared_json || self::looks_like_json( $raw_body ) ) { $decoded = json_decode( $raw_body, true, 16 ); $json_error = json_last_error(); if ( JSON_ERROR_NONE === $json_error ) { $sources['json'] = array( 'value' => $decoded, 'blocking' => true ); // Inspect trusted code separately, retaining every other field in both raw and decoded passes. if ( in_array( 'json.content', $content_fields, true ) && is_array( $decoded ) && isset( $decoded['content'] ) ) { $raw_content = $decoded['content']; if ( is_string( $raw_content ) || ( is_array( $raw_content ) && array_keys( $raw_content ) === array( 'raw' ) && is_string( $raw_content['raw'] ) ) ) { $decoded['content'] = ''; $sources['body']['value'] = wp_json_encode( $decoded ); } } } // A body declared as JSON must decode. A body that only looks like JSON is still inspected raw above, so it // is incomplete only when it is valid JSON nested too deeply to decode here: a parser without that limit // would read what this inspection could not. elseif ( $declared_json || JSON_ERROR_DEPTH === $json_error ) { $reasons[] = 'json_parse'; $blocking_reasons[] = 'json_parse'; } } } // Each source and each scalar is inspected separately; earlier fields cannot consume another source's budget. foreach ( $sources as $source => $source_meta ) { $bytes = 0; $nodes = 0; self::inspect_value( $source_meta['value'], $source, 0, $bytes, $nodes, $hits, $blocking_hits, $evidence, $reasons, $blocking_reasons, $source, $content_fields, (bool) $source_meta['blocking'] ); } $score = 0; $blocking_score = 0; foreach ( self::rules() as $rule ) { if ( isset( $hits[ $rule['id'] ] ) ) { $score += $rule['score']; } if ( isset( $blocking_hits[ $rule['id'] ] ) ) { $blocking_score += $rule['score']; } } return array( 'score' => min( 200, $score ), 'blocking_score' => min( 200, $blocking_score ), 'rules' => array_keys( $hits ), 'blocking_rules' => array_keys( $blocking_hits ), 'evidence' => array_slice( $evidence, 0, 12 ), 'inspection_incomplete' => ! empty( $reasons ), 'blocking_incomplete' => ! empty( $blocking_reasons ), 'reasons' => array_values( array_unique( $reasons ) ), ); } private static function inspect_value( $value, string $source, int $depth, int &$bytes, int &$nodes, array &$hits, array &$blocking_hits, array &$evidence, array &$reasons, array &$blocking_reasons, string $field, array $content_fields, bool $blocking_source ): void { if ( ++$nodes > self::MAX_INPUT_NODES || $depth > 16 || $bytes >= self::MAX_SOURCE_BYTES ) { $reasons[] = $source . '_limit'; if ( $blocking_source ) { $blocking_reasons[] = $source . '_limit'; } return; } if ( is_array( $value ) ) { foreach ( $value as $key => $item ) { if ( $nodes >= self::MAX_INPUT_NODES || $bytes >= self::MAX_SOURCE_BYTES ) { $reasons[] = $source . '_limit'; if ( $blocking_source ) { $blocking_reasons[] = $source . '_limit'; } break; } self::inspect_value( (string) $key, $source, $depth + 1, $bytes, $nodes, $hits, $blocking_hits, $evidence, $reasons, $blocking_reasons, '', $content_fields, $blocking_source ); $child = '' !== $field && ! str_contains( (string) $key, '.' ) ? $field . '.' . $key : ''; self::inspect_value( $item, $source, $depth + 1, $bytes, $nodes, $hits, $blocking_hits, $evidence, $reasons, $blocking_reasons, $child, $content_fields, $blocking_source ); } return; } if ( ! is_scalar( $value ) ) { return; } $value = (string) $value; $length = min( strlen( $value ), self::MAX_SOURCE_BYTES - $bytes ); if ( $length < strlen( $value ) ) { $reasons[] = $source . '_bytes'; if ( $blocking_source ) { $blocking_reasons[] = $source . '_bytes'; } } $bytes += $length; for ( $offset = 0; $offset < $length; $offset += self::MAX_INPUT_BYTES - 1024 ) { $text = self::strip_own_origin( self::normalize( substr( $value, $offset, min( self::MAX_INPUT_BYTES, $length - $offset ) ) ) ); foreach ( self::classify_text( $text )['rules'] as $id ) { if ( in_array( $field, $content_fields, true ) && in_array( $id, array( 'XSS_SCRIPT_TAG', 'XSS_EVENT_HANDLER', 'XSS_SCRIPT_SCHEME' ), true ) ) { continue; } $hits[ $id ] = true; if ( $blocking_source ) { $blocking_hits[ $id ] = true; } if ( count( $evidence ) < 12 ) { $entry = array( 'rule' => $id, 'source' => $source, 'blocking_source' => $blocking_source ); if ( '' !== $field ) { $entry['field'] = substr( $field, 0, 160 ); } $evidence[] = $entry; } } } } /** * Whether a Content-Type declares JSON the way WordPress's REST API reads it: the part before any ";", trimmed and * lowercased, matched by wp_is_json_media_type(). That accepts more than the canonical form, for example * "application/json," or "application/json+oembed". */ public static function declares_json( string $content_type ): bool { // WP_REST_Request::get_content_type() trims with PHP's default characters; the same call keeps the two in step // on every PHP version. $media_type = strtolower( trim( explode( ';', $content_type, 2 )[0] ) ); if ( function_exists( 'wp_is_json_media_type' ) ) { return wp_is_json_media_type( $media_type ); } return 1 === preg_match( '/(^|\s|,)application\/([\w!#\$&-\^\.\+]+\+)?json(\+oembed)?($|\s|;|,)/i', $media_type ); } /** * Whether a request body is a binary file, such as an image a REST client uploads as raw bytes. Such a body is left * to the upload guard, which inspects every file WordPress accepts as an upload: read as text, ordinary image and * font files match the request rules at a blocking score, and most exceed the inspection budget. * * Only a body that begins with the signature of an image, audio, video, font, document or archive format qualifies, * and never one declared as text, a form, JSON, XML or GraphQL: those are always inspected, so neither PHP nor * WordPress can be handed a form or JSON body that this skipped. A handler that splits a body declared as some other * type on "&" by itself would still read text placed after a file signature. */ public static function body_is_binary_file( string $content_type, string $body ): bool { if ( self::declares_text( $content_type ) ) { return false; } $head = substr( $body, 0, 16 ); $signatures = array( "\x89PNG\r\n\x1A\n", "\xFF\xD8\xFF", 'GIF87a', 'GIF89a', "II*\x00", "MM\x00*", "\x00\x00\x01\x00", '8BPS', '%PDF-', "\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1", "PK\x03\x04", "PK\x05\x06", "\x1F\x8B", "7z\xBC\xAF\x27\x1C", "Rar!\x1A\x07", 'ID3', 'OggS', 'fLaC', "\x1A\x45\xDF\xA3", 'wOFF', 'wOF2', 'OTTO', "\x00\x01\x00\x00", ); foreach ( $signatures as $signature ) { if ( str_starts_with( $head, $signature ) ) { return true; } } if ( str_starts_with( $head, 'RIFF' ) && in_array( substr( $head, 8, 4 ), array( 'WEBP', 'WAVE', 'AVI ' ), true ) ) { return true; } if ( str_starts_with( $head, 'BM' ) && "\x00\x00\x00\x00" === substr( $head, 6, 4 ) ) { return true; } // ISO media (MP4, MOV, M4A, 3GP, HEIC, AVIF) names its brand after a 4-byte box size; MPEG audio may start // directly with a frame sync. if ( 'ftyp' === substr( $head, 4, 4 ) ) { return true; } return strlen( $head ) >= 2 && "\xFF" === $head[0] && 0xE0 === ( ord( $head[1] ) & 0xE0 ); } /** * Whether the Content-Type declares a type whose body is always inspected as text. The media type is read as PHP * reads it to decide whether to parse a form, cut at the first ";", "," or whitespace, so * "application/x-www-form-urlencoded, x" is still a form here, as it is to PHP. */ private static function declares_text( string $content_type ): bool { $media_type = strtolower( (string) preg_replace( '/[;,\s].*$/s', '', ltrim( $content_type ) ) ); return str_starts_with( $media_type, 'text/' ) || 'application/x-www-form-urlencoded' === $media_type || 'application/graphql' === $media_type || 1 === preg_match( '#^application/(?:[a-z0-9.+-]+\+)?(?:json|xml)$#', $media_type ) || self::declares_json( $content_type ); } /** * Whether a body starts like a JSON object, array or string, after JSON whitespace. */ private static function looks_like_json( string $body ): bool { $offset = strspn( $body, " \t\n\r" ); return $offset < strlen( $body ) && in_array( $body[ $offset ], array( '{', '[', '"' ), true ); } private static function normalize( string $value ): string { $value = str_replace( "\0", '', $value ); for ( $i = 0; $i < 3; $i++ ) { $value = (string) preg_replace( '/%(?:25)*(?:0d|0a)/i', "\x01", $value ); $decoded = rawurldecode( $value ); if ( $decoded === $value ) { break; } $value = $decoded; } $value = html_entity_decode( $value, ENT_QUOTES | ENT_HTML5, 'UTF-8' ); return substr( $value, 0, self::MAX_INPUT_BYTES ); } private static function strip_own_origin( string $value ): string { if ( ! function_exists( 'home_url' ) || ! function_exists( 'wp_parse_url' ) ) { return $value; } $urls = array( home_url() ); if ( function_exists( 'site_url' ) ) { $urls[] = site_url(); } foreach ( array_unique( $urls ) as $url ) { $parts = wp_parse_url( $url ); if ( ! is_array( $parts ) || empty( $parts['scheme'] ) || empty( $parts['host'] ) ) { continue; } $authority = strtolower( (string) $parts['host'] ); if ( isset( $parts['port'] ) ) { $authority .= ':' . (int) $parts['port']; } foreach ( array( strtolower( (string) $parts['scheme'] ) . '://' . $authority, '//' . $authority ) as $origin ) { $value = (string) preg_replace( '~' . preg_quote( $origin, '~' ) . '(?=/|[?#\\s"\'<>]|$)~i', '/', $value ); } } return $value; } public static function classify_text( string $text ): array { $score = 0; $hits = array(); foreach ( self::rules() as $rule ) { if ( 1 === preg_match( $rule['pattern'], $text ) ) { $score += $rule['score']; $hits[] = $rule['id']; } } return array( 'score' => min( 200, $score ), 'rules' => $hits ); } private static function rules(): array { return array( array( 'id' => 'SQL_UNION', 'score' => 90, 'pattern' => '/\bunion(?:\s|\/\*[^*]{0,64}\*\/)+(?:all(?:\s|\/\*[^*]{0,64}\*\/)+)?select\b/i' ), array( 'id' => 'SQL_TIME_DELAY', 'score' => 85, 'pattern' => '/\b(?:sleep|benchmark)\s*\(/i' ), array( 'id' => 'SQL_BOOLEAN_PROBE', 'score' => 70, 'pattern' => '/(?:^|[^\w])(?:or|and)\s+[\'\"]?\d+[\'\"]?\s*=\s*[\'\"]?\d+/i' ), array( 'id' => 'XSS_SCRIPT_TAG', 'score' => 90, 'pattern' => '/<\s*script\b/i' ), array( 'id' => 'XSS_EVENT_HANDLER', 'score' => 80, 'pattern' => '/<[^>]{0,500}\son[a-z]{3,24}\s*=/i' ), array( 'id' => 'XSS_SCRIPT_SCHEME', 'score' => 75, 'pattern' => '/\bjavascript\s*:/i' ), array( 'id' => 'PATH_TRAVERSAL', 'score' => 90, 'pattern' => '#(?:\.\.[/\\]){2,}|/(?:etc/passwd|proc/self/environ)(?:$|[?/])#i' ), array( 'id' => 'UNSAFE_STREAM', 'score' => 85, 'pattern' => '#\b(?:php|expect|phar|file)://#i' ), array( 'id' => 'SSRF_LOCAL_TARGET', 'score' => 90, 'pattern' => '~\bhttps?://(?:localhost|127(?:\.\d{1,3}){3}|0\.0\.0\.0|\[?::1\]?|169\.254\.169\.254)(?::\d{1,5})?(?:[/?#]|$)~i' ), array( 'id' => 'HTTP_RESPONSE_SPLITTING', 'score' => 90, 'pattern' => '/(?:\x01\s*){1,4}(?:set-cookie|location|content-length|transfer-encoding)\s*:/i' ), array( 'id' => 'SENSITIVE_FILE_PROBE', 'score' => 90, 'pattern' => '#(?:^|/)(?:(?:wp-config|xmlrpc)\.php(?:\.(?:bak|backup|old|orig|save|swp|txt)|~)?|\.env(?:\.[a-z0-9_-]{1,32})?|\.git/config|debug\.log|(?:database|db|dump|backup)[-_a-z0-9]*\.(?:sql(?:\.gz)?|zip|tar(?:\.gz)?|tgz)|wp-content/plugins/revslider/readme\.txt)(?:$|[?/\r\n])#i' ), array( 'id' => 'SHELL_COMMAND_CHAIN', 'score' => 95, 'pattern' => '/(?:;|\|\||&&|`)[\s\x00-\x20]*(?:curl|wget|bash|sh|cmd|powershell|whoami|id|uname|cat|chmod|nc)\b/i' ), array( 'id' => 'SHELL_FUNCTION_PREFIX', 'score' => 95, 'pattern' => '/\(\s*\)\s*\{[^}\r\n]{0,180}\}\s*;/' ), // Shell breakouts the chain rule above does not see: $( ) substitution, and a single pipe or a newline followed by a // shell, downloader, recon or connect-back command. A bare "curl ... | sh" is not matched on purpose: install one-liners // have that shape in ordinary comments, and without a breakout operator it cannot start a command. After a pipe or a // newline, id and hostname are ordinary words, so they only count at the end of the value. array( 'id' => 'SHELL_COMMAND_SUBSTITUTION', 'score' => 95, 'pattern' => '/\$\((?:\s|\$\{?IFS\}?){0,16}(?:\/(?:usr\/)?(?:local\/)?s?bin\/)?(?:(?:curl|wget|bash|sh|zsh|dash|ksh|cmd|powershell|pwsh|whoami|id|uname|cat|chmod|nc|ncat|netcat|socat|sleep|ping|nslookup|dig|python[23]?|perl|ruby|php|base64|echo|printf|hostname|ifconfig|busybox|telnet|tftp)(?:\s|\$\{?IFS\}?){1,16}[^\s)]|(?:id|whoami|hostname|uname|ifconfig)\s{0,16}\)(?!\s*[.;,)\]]))/i' ), array( 'id' => 'SHELL_PIPE_COMMAND', 'score' => 95, 'pattern' => '/\|(?:[ \t]|\$\{?IFS\}?){0,16}(?:sudo(?:[ \t]|\$\{?IFS\}?){1,16})?(?:\/(?:usr\/)?(?:local\/)?s?bin\/)?(?:(?:whoami\b(?!-)|(?:id|hostname|uname(?:(?:[ \t]|\$\{?IFS\}?){1,16}-[a-z]{1,8})?)[ \t\r\n\x01]{0,16}$)|(?:ncat|netcat|socat)(?:[ \t]|\$\{?IFS\}?){1,16}[^\s|]|(?:curl|wget)(?:(?:[ \t]|\$\{?IFS\}?){1,16}-{1,2}[a-z][\w-]{0,32}(?:(?:[ \t]|\$\{?IFS\}?){1,16}[^\s-]\S{0,128})?){0,6}(?:[ \t]|\$\{?IFS\}?){1,16}(?:[a-z][a-z0-9+.-]{0,15}:\/\/|\d{1,3}(?:\.\d{1,3}){3}\b)|(?:(?:ba|da|k|z)?sh(?:[ \t]|\$\{?IFS\}?){1,16}-[a-z]{0,8}[ci]\b|cmd(?:\.exe)?(?:[ \t]|\$\{?IFS\}?){1,16}\/[ck]\b|(?:powershell|pwsh)(?:\.exe)?(?:[ \t]|\$\{?IFS\}?){1,16}-[a-z]{1,16}\b)|(?:nc|ncat|netcat)(?:[ \t]|\$\{?IFS\}?){1,16}(?:-[a-z]{1,8}(?:[ \t]|\$\{?IFS\}?){1,16}){0,4}(?:[\w.:-]{1,253}(?:[ \t]|\$\{?IFS\}?){1,16}\d{1,5}\b|\d{1,5}(?:[ \t]|\$\{?IFS\}?){1,16}-[ec]\b))/iD' ), array( 'id' => 'SHELL_NEWLINE_COMMAND', 'score' => 95, 'pattern' => '/[\x01\r\n][ \t]{0,16}(?:\/(?:usr\/)?(?:local\/)?s?bin\/)?(?:(?:whoami\b(?!-)|(?:id|hostname|uname(?:(?:[ \t]|\$\{?IFS\}?){1,16}-[a-z]{1,8})?)[ \t\r\n\x01]{0,16}$)|(?:(?:ba|da|k|z)?sh(?:[ \t]|\$\{?IFS\}?){1,16}-[a-z]{0,8}[ci]\b|cmd(?:\.exe)?(?:[ \t]|\$\{?IFS\}?){1,16}\/[ck]\b|(?:powershell|pwsh)(?:\.exe)?(?:[ \t]|\$\{?IFS\}?){1,16}-[a-z]{1,16}\b)|(?:nc|ncat|netcat)(?:[ \t]|\$\{?IFS\}?){1,16}(?:-[a-z]{1,8}(?:[ \t]|\$\{?IFS\}?){1,16}){0,4}(?:[\w.:-]{1,253}(?:[ \t]|\$\{?IFS\}?){1,16}\d{1,5}\b|\d{1,5}(?:[ \t]|\$\{?IFS\}?){1,16}-[ec]\b)|(?:curl|wget)(?:(?:[ \t]|\$\{?IFS\}?){1,16}-{1,2}[a-z][\w-]{0,32}(?:(?:[ \t]|\$\{?IFS\}?){1,16}[^\s-]\S{0,128})?){0,6}(?:[ \t]|\$\{?IFS\}?){1,16}(?:[a-z][a-z0-9+.-]{0,15}:\/\/)?\d{1,3}(?:\.\d{1,3}){3}\b)/iD' ), array( 'id' => 'SHELL_REVERSE_CONNECT', 'score' => 95, 'pattern' => '/\/dev\/(?:tcp|udp)\/[\w.-]{1,253}\/\d{1,5}\b/i' ), array( 'id' => 'JNDI_LOOKUP', 'score' => 95, 'pattern' => '/\$\{\s*jndi\s*:\s*(?:ldap|rmi|dns|iiop|https?)\s*:/i' ), array( 'id' => 'TEMPLATE_EXPRESSION', 'score' => 90, 'pattern' => '/(?:\{\{|\$\{)\s*(?:\d+\s*[*\/%+\-]\s*\d+|(?:self|config|request|cycler|joiner|namespace|lipsum)\b)[^}\r\n]{0,120}\}\}?/i' ), ); } public static function inspect_upload( array $file ): array { if ( ! NLSSL_Core::setting( 'upload_guard_enabled', 1 ) || ! empty( $file['error'] ) ) { return $file; } $raw_name = strtolower( wp_basename( str_replace( '\\', '/', (string) ( $file['name'] ?? '' ) ) ) ); $name = strtolower( sanitize_file_name( $raw_name ) ); $tmp = (string) ( $file['tmp_name'] ?? '' ); $reason = ''; if ( preg_match( '/\.(?:php\d*|phtml|phar|phps|inc)(?:\.|$)/i', $name ) ) { $reason = 'Executable server-side extension.'; } elseif ( in_array( $raw_name, array( '.htaccess', '.user.ini', 'web.config' ), true ) || in_array( $name, array( 'htaccess', 'user.ini', 'web.config' ), true ) ) { // Check the raw basename before sanitize_file_name(); WordPress trims leading // periods, so checking only the sanitized value would miss dotfiles. $reason = 'Server-control file.'; } elseif ( $tmp && preg_match( '/\.(?:jpe?g|png|gif|webp|svg|ico)$/i', $name ) ) { $inspection = self::inspect_media_file( $tmp, microtime( true ) + 2.0, str_ends_with( $name, '.svg' ), strtolower( pathinfo( $name, PATHINFO_EXTENSION ) ) ); if ( $inspection['code'] ) { $reason = 'Executable code was found inside an image-shaped upload.'; } elseif ( ! $inspection['complete'] ) { $file['error'] = __( 'NeverLast Security Lite could not complete media inspection. Ask the administrator to review the file size, readability and server limits.', 'neverlast-security-lite' ); NLSSL_Core::log( 'upload_inspection_incomplete', 'warning', 'A media upload was held because inspection did not complete.', array( 'filename' => $name, 'reason' => $inspection['reason'] ) ); return $file; } } if ( '' !== $reason ) { $file['error'] = __( 'NeverLast Security Lite rejected this upload because it contains an executable file shape.', 'neverlast-security-lite' ); NLSSL_Core::log( 'upload_blocked', 'critical', 'A dangerous upload was rejected.', array( 'filename' => $name, 'reason' => $reason ) ); } return $file; } public static function inspect_media_file( string $path, float $deadline, bool $svg = false, string $extension_hint = '' ): array { $result = array( 'code' => false, 'complete' => false, 'reason' => 'read_failure', 'bytes' => 0, 'file_size' => 0, 'coverage' => '', 'signal_id' => '', 'offset' => null, 'snippet' => '', 'format' => '', 'structure_valid' => false, 'trailing_bytes' => 0, ); if ( self::is_link_path( $path ) ) { $result['reason'] = 'symlink'; return $result; } $size = @filesize( $path ); if ( false === $size ) { return $result; } $result['file_size'] = (int) $size; $ext = strtolower( preg_replace( '/[^a-z0-9]+/', '', $extension_hint ) ); if ( '' === $ext ) { $ext = strtolower( pathinfo( $path, PATHINFO_EXTENSION ) ); } $php_patterns = array( 'PHP_OPEN_TAG' => '/<\\?(?:php\\b|=)/i', 'PHP_SHORT_CODE_TAG' => '/<\\?\\s*(?:\\$[A-Za-z_]|(?:echo|eval|assert|system|shell_exec|passthru|exec|include|require)\\b)/i', ); $svg_patterns = $php_patterns + array( 'SVG_SCRIPT_TAG' => '/<\\s*script\\b/i', 'SVG_FOREIGN_OBJECT' => '/<\\s*foreignObject\\b/i', 'SVG_EMBEDDED_DOCUMENT' => '/<\\s*(?:iframe|object|embed)\\b/i', 'SVG_EVENT_HANDLER' => '/<[^>]{0,800}\\son[a-z][a-z0-9_-]{2,31}\\s*=/i', 'SVG_JAVASCRIPT_SCHEME' => '/\\bjavascript\\s*:/i', 'SVG_HTML_DATA_URI' => '/\\bdata\\s*:\\s*text\\/html(?:\\s*;[^,]{0,128})?\\s*,/i', 'SVG_XML_STYLESHEET' => '/<\\?xml-stylesheet\\b/i', ); if ( $svg || 'svg' === $ext ) { $stream = @fopen( $path, 'rb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Reads uploaded media in bounded chunks within a time budget; WP_Filesystem has no partial reads. if ( false === $stream ) { return $result; } try { $tail = ''; $processed = 0; while ( ! feof( $stream ) ) { if ( microtime( true ) >= $deadline ) { $result['reason'] = 'time'; return $result; } if ( $processed >= self::MAX_MEDIA_BYTES ) { $result['reason'] = 'media_bytes'; return $result; } $chunk = fread( $stream, min( 65536, self::MAX_MEDIA_BYTES - $processed ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Reads uploaded media in bounded chunks within a time budget; WP_Filesystem has no partial reads. if ( false === $chunk || ( '' === $chunk && ! feof( $stream ) ) ) { return $result; } $sample = $tail . $chunk; $base_offset = max( 0, $processed - strlen( $tail ) ); $signal = self::detect_media_signal( $sample, $svg_patterns, $base_offset ); $processed += strlen( $chunk ); $result['bytes'] = $processed; if ( $signal ) { $result = array_merge( $result, $signal, array( 'code' => true, 'complete' => true, 'reason' => 'active_svg_content', 'coverage' => 'full_text', 'format' => 'svg', 'structure_valid' => true ) ); return $result; } $tail = substr( $sample, -1024 ); } $result['complete'] = true; $result['reason'] = ''; $result['coverage'] = 'full_text'; $result['format'] = 'svg'; $result['structure_valid'] = true; return $result; } finally { fclose( $stream ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Reads uploaded media in bounded chunks within a time budget; WP_Filesystem has no partial reads. } $boundary = self::media_structural_boundary( $path, (int) $size, $deadline, $ext ); $result['format'] = (string) ( $boundary['format'] ?? $ext ); $result['coverage'] = (string) ( $boundary['coverage'] ?? 'structure_boundary' ); $result['bytes'] = (int) ( $boundary['bytes'] ?? 0 ); if ( empty( $boundary['ok'] ) ) { $result['reason'] = (string) ( $boundary['reason'] ?? 'invalid_media_structure' ); return $result; } $result['structure_valid'] = true; $end = (int) ( $boundary['boundary'] ?? $size ); if ( $end < 0 || $end > $size ) { $result['reason'] = 'invalid_media_boundary'; return $result; } $trailing = max( 0, (int) $size - $end ); $result['trailing_bytes'] = $trailing; if ( 0 === $trailing ) { $result['complete'] = true; $result['reason'] = ''; return $result; } if ( $trailing > self::MAX_MEDIA_BYTES ) { $result['reason'] = 'large_trailing_data'; return $result; } if ( microtime( true ) >= $deadline ) { $result['reason'] = 'time'; return $result; } $stream = @fopen( $path, 'rb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Reads uploaded media in bounded chunks within a time budget; WP_Filesystem has no partial reads. if ( false === $stream || 0 !== fseek( $stream, $end, SEEK_SET ) ) { if ( is_resource( $stream ) ) { fclose( $stream ); } $result['reason'] = 'seek_failure'; return $result; } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Reads uploaded media in bounded chunks within a time budget; WP_Filesystem has no partial reads. $payload = stream_get_contents( $stream, $trailing ); fclose( $stream ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Reads uploaded media in bounded chunks within a time budget; WP_Filesystem has no partial reads. if ( false === $payload || strlen( $payload ) !== $trailing ) { $result['reason'] = 'read_failure'; return $result; } $result['bytes'] += strlen( $payload ); $signal = self::detect_media_signal( $payload, $php_patterns, $end ); if ( $signal ) { $result = array_merge( $result, $signal, array( 'code' => true, 'complete' => true, 'reason' => 'executable_after_media_boundary', 'coverage' => 'validated_structure_plus_trailing_data' ) ); return $result; } // Trailing bytes without executable syntax are not malware by themselves. The // structure was validated and the complete trailing area was inspected. $result['complete'] = true; $result['reason'] = ''; $result['coverage'] = 'validated_structure_plus_trailing_data'; return $result; } private static function media_structural_boundary( string $path, int $size, float $deadline, string $ext ): array { if ( $size <= 0 || microtime( true ) >= $deadline ) { return array( 'ok' => false, 'reason' => 'time', 'boundary' => 0, 'bytes' => 0, 'format' => $ext, 'coverage' => 'structure_boundary' ); } $stream = @fopen( $path, 'rb' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fopen -- Reads uploaded media in bounded chunks within a time budget; WP_Filesystem has no partial reads. if ( false === $stream ) { return array( 'ok' => false, 'reason' => 'read_failure', 'boundary' => 0, 'bytes' => 0, 'format' => $ext, 'coverage' => 'structure_boundary' ); } $bytes = 0; try { $head = fread( $stream, min( 64, $size ) ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Reads uploaded media in bounded chunks within a time budget; WP_Filesystem has no partial reads. if ( false === $head ) { return array( 'ok' => false, 'reason' => 'read_failure', 'boundary' => 0, 'bytes' => $bytes, 'format' => $ext, 'coverage' => 'structure_boundary' ); } $bytes += strlen( $head ); if ( in_array( $ext, array( 'jpg', 'jpeg' ), true ) ) { if ( ! str_starts_with( $head, "\xFF\xD8" ) ) { return array( 'ok' => false, 'reason' => 'media_magic_mismatch', 'boundary' => 0, 'bytes' => $bytes, 'format' => 'jpeg', 'coverage' => 'structure_boundary' ); } // Parse JPEG markers so an FF D9 sequence appended by an attacker cannot // move the trusted boundary past an earlier, real EOI marker. Entropy-coded // scan bytes are skipped semantically (FF 00 stuffing and restart markers). if ( 0 !== fseek( $stream, 2, SEEK_SET ) ) { return array( 'ok' => false, 'reason' => 'seek_failure', 'boundary' => 0, 'bytes' => $bytes, 'format' => 'jpeg', 'coverage' => 'structure_boundary' ); } $offset = 2; $in_scan = false; $ticks = 0; while ( $offset < $size ) { if ( 0 === ( ++$ticks % 4096 ) && microtime( true ) >= $deadline ) { return array( 'ok' => false, 'reason' => 'time', 'boundary' => 0, 'bytes' => $bytes, 'format' => 'jpeg', 'coverage' => 'structure_boundary' ); } $b = fgetc( $stream ); if ( false === $b ) { break; } $offset++; $bytes++; if ( "\xFF" !== $b ) { if ( $in_scan ) { continue; } return array( 'ok' => false, 'reason' => 'invalid_jpeg_marker', 'boundary' => 0, 'bytes' => $bytes, 'format' => 'jpeg', 'coverage' => 'structure_boundary' ); } do { $m = fgetc( $stream ); if ( false === $m ) { return array( 'ok' => false, 'reason' => 'jpeg_eoi_not_found', 'boundary' => 0, 'bytes' => $bytes, 'format' => 'jpeg', 'coverage' => 'structure_boundary' ); } $offset++; $bytes++; } while ( "\xFF" === $m ); $marker = ord( $m ); if ( $in_scan && 0x00 === $marker ) { continue; } if ( $in_scan && $marker >= 0xD0 && $marker <= 0xD7 ) { continue; } if ( 0xD9 === $marker ) { return array( 'ok' => true, 'boundary' => $offset, 'bytes' => $bytes, 'format' => 'jpeg', 'coverage' => 'parsed_jpeg_structure' ); } $in_scan = false; if ( in_array( $marker, array( 0x01, 0xD8 ), true ) || ( $marker >= 0xD0 && $marker <= 0xD7 ) ) { continue; } $length_bytes = fread( $stream, 2 ); if ( false === $length_bytes || 2 !== strlen( $length_bytes ) ) { return array( 'ok' => false, 'reason' => 'invalid_jpeg_segment', 'boundary' => 0, 'bytes' => $bytes, 'format' => 'jpeg', 'coverage' => 'structure_boundary' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Reads uploaded media in bounded chunks within a time budget; WP_Filesystem has no partial reads. $offset += 2; $bytes += 2; $length = unpack( 'nlen', $length_bytes )['len']; if ( $length < 2 || $offset + $length - 2 > $size ) { return array( 'ok' => false, 'reason' => 'invalid_jpeg_segment', 'boundary' => 0, 'bytes' => $bytes, 'format' => 'jpeg', 'coverage' => 'structure_boundary' ); } if ( 0xDA === $marker ) { if ( 0 !== fseek( $stream, $length - 2, SEEK_CUR ) ) { return array( 'ok' => false, 'reason' => 'seek_failure', 'boundary' => 0, 'bytes' => $bytes, 'format' => 'jpeg', 'coverage' => 'structure_boundary' ); } $offset += $length - 2; $in_scan = true; continue; } if ( 0 !== fseek( $stream, $length - 2, SEEK_CUR ) ) { return array( 'ok' => false, 'reason' => 'seek_failure', 'boundary' => 0, 'bytes' => $bytes, 'format' => 'jpeg', 'coverage' => 'structure_boundary' ); } $offset += $length - 2; } return array( 'ok' => false, 'reason' => 'jpeg_eoi_not_found', 'boundary' => 0, 'bytes' => $bytes, 'format' => 'jpeg', 'coverage' => 'parsed_jpeg_structure' ); } if ( 'png' === $ext ) { if ( substr( $head, 0, 8 ) !== "\x89PNG\r\n\x1A\n" ) { return array( 'ok' => false, 'reason' => 'media_magic_mismatch', 'boundary' => 0, 'bytes' => $bytes, 'format' => 'png', 'coverage' => 'structure_boundary' ); } if ( 0 !== fseek( $stream, 8, SEEK_SET ) ) { return array( 'ok' => false, 'reason' => 'seek_failure', 'boundary' => 0, 'bytes' => $bytes, 'format' => 'png', 'coverage' => 'structure_boundary' ); } $offset = 8; while ( $offset + 12 <= $size ) { if ( microtime( true ) >= $deadline ) { return array( 'ok' => false, 'reason' => 'time', 'boundary' => 0, 'bytes' => $bytes, 'format' => 'png', 'coverage' => 'structure_boundary' ); } $hdr = fread( $stream, 8 ); if ( false === $hdr || 8 !== strlen( $hdr ) ) { return array( 'ok' => false, 'reason' => 'read_failure', 'boundary' => 0, 'bytes' => $bytes, 'format' => 'png', 'coverage' => 'structure_boundary' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Reads uploaded media in bounded chunks within a time budget; WP_Filesystem has no partial reads. $bytes += 8; $len = unpack( 'Nlen', substr( $hdr, 0, 4 ) )['len']; $type = substr( $hdr, 4, 4 ); $next = $offset + 12 + $len; if ( $len > $size || $next > $size ) { return array( 'ok' => false, 'reason' => 'invalid_png_chunk', 'boundary' => 0, 'bytes' => $bytes, 'format' => 'png', 'coverage' => 'structure_boundary' ); } if ( 0 !== fseek( $stream, $len + 4, SEEK_CUR ) ) { return array( 'ok' => false, 'reason' => 'seek_failure', 'boundary' => 0, 'bytes' => $bytes, 'format' => 'png', 'coverage' => 'structure_boundary' ); } $offset = $next; if ( 'IEND' === $type ) { return array( 'ok' => true, 'boundary' => $offset, 'bytes' => $bytes, 'format' => 'png', 'coverage' => 'structure_boundary' ); } } return array( 'ok' => false, 'reason' => 'png_iend_not_found', 'boundary' => 0, 'bytes' => $bytes, 'format' => 'png', 'coverage' => 'structure_boundary' ); } if ( 'webp' === $ext ) { if ( strlen( $head ) < 12 || 'RIFF' !== substr( $head, 0, 4 ) || 'WEBP' !== substr( $head, 8, 4 ) ) { return array( 'ok' => false, 'reason' => 'media_magic_mismatch', 'boundary' => 0, 'bytes' => $bytes, 'format' => 'webp', 'coverage' => 'parsed_webp_structure' ); } $riff = unpack( 'Vlen', substr( $head, 4, 4 ) )['len']; $boundary = 8 + (int) $riff; if ( $boundary < 20 || $boundary > $size || 0 !== fseek( $stream, 12, SEEK_SET ) ) { return array( 'ok' => false, 'reason' => 'invalid_webp_riff_size', 'boundary' => 0, 'bytes' => $bytes, 'format' => 'webp', 'coverage' => 'parsed_webp_structure' ); } $offset = 12; $has_image_chunk = false; while ( $offset + 8 <= $boundary ) { if ( microtime( true ) >= $deadline ) { return array( 'ok' => false, 'reason' => 'time', 'boundary' => 0, 'bytes' => $bytes, 'format' => 'webp', 'coverage' => 'parsed_webp_structure' ); } $chunk = fread( $stream, 8 ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Reads uploaded media in bounded chunks within a time budget; WP_Filesystem has no partial reads. if ( false === $chunk || 8 !== strlen( $chunk ) ) { return array( 'ok' => false, 'reason' => 'invalid_webp_chunk', 'boundary' => 0, 'bytes' => $bytes, 'format' => 'webp', 'coverage' => 'parsed_webp_structure' ); } $bytes += 8; $fourcc = substr( $chunk, 0, 4 ); $length = (int) unpack( 'Vlen', substr( $chunk, 4, 4 ) )['len']; $padded = $length + ( $length % 2 ); $next = $offset + 8 + $padded; if ( $length < 0 || $next > $boundary ) { return array( 'ok' => false, 'reason' => 'invalid_webp_chunk', 'boundary' => 0, 'bytes' => $bytes, 'format' => 'webp', 'coverage' => 'parsed_webp_structure' ); } if ( in_array( $fourcc, array( 'VP8 ', 'VP8L', 'VP8X' ), true ) ) { $has_image_chunk = true; } if ( 0 !== fseek( $stream, $padded, SEEK_CUR ) ) { return array( 'ok' => false, 'reason' => 'seek_failure', 'boundary' => 0, 'bytes' => $bytes, 'format' => 'webp', 'coverage' => 'parsed_webp_structure' ); } $offset = $next; } if ( $offset !== $boundary || ! $has_image_chunk ) { return array( 'ok' => false, 'reason' => 'invalid_webp_structure', 'boundary' => 0, 'bytes' => $bytes, 'format' => 'webp', 'coverage' => 'parsed_webp_structure' ); } return array( 'ok' => true, 'boundary' => $boundary, 'bytes' => $bytes, 'format' => 'webp', 'coverage' => 'parsed_webp_structure' ); } if ( 'gif' === $ext ) { if ( ! str_starts_with( $head, 'GIF87a' ) && ! str_starts_with( $head, 'GIF89a' ) ) { return array( 'ok' => false, 'reason' => 'media_magic_mismatch', 'boundary' => 0, 'bytes' => $bytes, 'format' => 'gif', 'coverage' => 'structure_boundary' ); } if ( $size < 13 || 0 !== fseek( $stream, 6, SEEK_SET ) ) { return array( 'ok' => false, 'reason' => 'invalid_gif_structure', 'boundary' => 0, 'bytes' => $bytes, 'format' => 'gif', 'coverage' => 'structure_boundary' ); } $lsd = fread( $stream, 7 ); if ( false === $lsd || 7 !== strlen( $lsd ) ) { return array( 'ok' => false, 'reason' => 'invalid_gif_structure', 'boundary' => 0, 'bytes' => $bytes, 'format' => 'gif', 'coverage' => 'structure_boundary' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Reads uploaded media in bounded chunks within a time budget; WP_Filesystem has no partial reads. $offset = 13; $bytes += 7; $packed = ord( $lsd[4] ); if ( $packed & 0x80 ) { $gct = 3 * ( 1 << ( ( $packed & 0x07 ) + 1 ) ); if ( $offset + $gct > $size || 0 !== fseek( $stream, $gct, SEEK_CUR ) ) { return array( 'ok' => false, 'reason' => 'invalid_gif_structure', 'boundary' => 0, 'bytes' => $bytes, 'format' => 'gif', 'coverage' => 'structure_boundary' ); } $offset += $gct; } while ( $offset < $size ) { if ( microtime( true ) >= $deadline ) { return array( 'ok' => false, 'reason' => 'time', 'boundary' => 0, 'bytes' => $bytes, 'format' => 'gif', 'coverage' => 'parsed_gif_structure' ); } $lead = fgetc( $stream ); if ( false === $lead ) { break; } $offset++; $bytes++; $code = ord( $lead ); if ( 0x3B === $code ) { return array( 'ok' => true, 'boundary' => $offset, 'bytes' => $bytes, 'format' => 'gif', 'coverage' => 'parsed_gif_structure' ); } if ( 0x2C === $code ) { $desc = fread( $stream, 9 ); if ( false === $desc || 9 !== strlen( $desc ) ) { return array( 'ok' => false, 'reason' => 'invalid_gif_image', 'boundary' => 0, 'bytes' => $bytes, 'format' => 'gif', 'coverage' => 'parsed_gif_structure' ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Reads uploaded media in bounded chunks within a time budget; WP_Filesystem has no partial reads. $offset += 9; $bytes += 9; $ip = ord( $desc[8] ); if ( $ip & 0x80 ) { $lct = 3 * ( 1 << ( ( $ip & 0x07 ) + 1 ) ); if ( $offset + $lct > $size || 0 !== fseek( $stream, $lct, SEEK_CUR ) ) { return array( 'ok' => false, 'reason' => 'invalid_gif_image', 'boundary' => 0, 'bytes' => $bytes, 'format' => 'gif', 'coverage' => 'parsed_gif_structure' ); } $offset += $lct; } $lzw = fgetc( $stream ); if ( false === $lzw ) { return array( 'ok' => false, 'reason' => 'invalid_gif_image', 'boundary' => 0, 'bytes' => $bytes, 'format' => 'gif', 'coverage' => 'parsed_gif_structure' ); } $offset++; $bytes++; } elseif ( 0x21 === $code ) { $label = fgetc( $stream ); if ( false === $label ) { return array( 'ok' => false, 'reason' => 'invalid_gif_extension', 'boundary' => 0, 'bytes' => $bytes, 'format' => 'gif', 'coverage' => 'parsed_gif_structure' ); } $offset++; $bytes++; } else { return array( 'ok' => false, 'reason' => 'invalid_gif_block', 'boundary' => 0, 'bytes' => $bytes, 'format' => 'gif', 'coverage' => 'parsed_gif_structure' ); } // Image data and extension payloads use GIF sub-blocks. while ( true ) { $n = fgetc( $stream ); if ( false === $n ) { return array( 'ok' => false, 'reason' => 'invalid_gif_subblock', 'boundary' => 0, 'bytes' => $bytes, 'format' => 'gif', 'coverage' => 'parsed_gif_structure' ); } $offset++; $bytes++; $len = ord( $n ); if ( 0 === $len ) { break; } if ( $offset + $len > $size || 0 !== fseek( $stream, $len, SEEK_CUR ) ) { return array( 'ok' => false, 'reason' => 'invalid_gif_subblock', 'boundary' => 0, 'bytes' => $bytes, 'format' => 'gif', 'coverage' => 'parsed_gif_structure' ); } $offset += $len; } } return array( 'ok' => false, 'reason' => 'gif_trailer_not_found', 'boundary' => 0, 'bytes' => $bytes, 'format' => 'gif', 'coverage' => 'parsed_gif_structure' ); } if ( 'ico' === $ext ) { if ( strlen( $head ) < 6 || "\x00\x00\x01\x00" !== substr( $head, 0, 4 ) ) { return array( 'ok' => false, 'reason' => 'media_magic_mismatch', 'boundary' => 0, 'bytes' => $bytes, 'format' => 'ico', 'coverage' => 'structure_boundary' ); } $count = unpack( 'vcount', substr( $head, 4, 2 ) )['count']; if ( $count < 1 || $count > 1024 ) { return array( 'ok' => false, 'reason' => 'invalid_ico_directory', 'boundary' => 0, 'bytes' => $bytes, 'format' => 'ico', 'coverage' => 'structure_boundary' ); } $dir_size = 6 + 16 * $count; if ( $dir_size > $size || 0 !== fseek( $stream, 6, SEEK_SET ) ) { return array( 'ok' => false, 'reason' => 'invalid_ico_directory', 'boundary' => 0, 'bytes' => $bytes, 'format' => 'ico', 'coverage' => 'structure_boundary' ); } $max = $dir_size; for ( $i = 0; $i < $count; $i++ ) { $entry = fread( $stream, 16 ); if ( false === $entry || 16 !== strlen( $entry ) ) { return array( 'ok' => false, 'reason' => 'read_failure', 'boundary' => 0, 'bytes' => $bytes, 'format' => 'ico', 'coverage' => 'structure_boundary' ); } $bytes += 16; $len = unpack( 'Vlen', substr( $entry, 8, 4 ) )['len']; $off = unpack( 'Voff', substr( $entry, 12, 4 ) )['off']; if ( $off < $dir_size || $off + $len > $size ) { return array( 'ok' => false, 'reason' => 'invalid_ico_entry', 'boundary' => 0, 'bytes' => $bytes, 'format' => 'ico', 'coverage' => 'structure_boundary' ); } $max = max( $max, $off + $len ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fread -- Reads uploaded media in bounded chunks within a time budget; WP_Filesystem has no partial reads. return array( 'ok' => true, 'boundary' => $max, 'bytes' => $bytes, 'format' => 'ico', 'coverage' => 'structure_boundary' ); } return array( 'ok' => false, 'reason' => 'unsupported_media_format', 'boundary' => 0, 'bytes' => $bytes, 'format' => $ext, 'coverage' => 'structure_boundary' ); } finally { fclose( $stream ); } // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Reads uploaded media in bounded chunks within a time budget; WP_Filesystem has no partial reads. } private static function detect_media_signal( string $sample, array $patterns, int $base_offset ): ?array { $best = null; foreach ( $patterns as $id => $pattern ) { $match = array(); if ( 1 !== preg_match( $pattern, $sample, $match, PREG_OFFSET_CAPTURE ) ) { continue; } $offset = (int) $match[0][1]; if ( null === $best || $offset < $best['local_offset'] ) { $start = max( 0, $offset - 96 ); $snippet = substr( $sample, $start, 256 ); $snippet = preg_replace_callback( '/[^\\x20-\\x7E]/', static fn( array $m ): string => sprintf( '\\\\x%02X', ord( $m[0] ) ), $snippet ); $best = array( 'local_offset' => $offset, 'signal_id' => (string) $id, 'offset' => $base_offset + $offset, 'snippet' => (string) $snippet ); } } if ( null === $best ) { return null; } unset( $best['local_offset'] ); return $best; } public static function is_link_path( string $path ): bool { if ( is_link( $path ) ) { return true; } if ( '\\' !== DIRECTORY_SEPARATOR ) { return false; } // Windows PHP does not label junctions as symlinks; compare the resolved leaf to its resolved parent. $resolved = realpath( $path ); $parent = realpath( dirname( $path ) ); if ( false === $resolved || false === $parent ) { return false; } $expected = $parent . DIRECTORY_SEPARATOR . basename( $path ); return 0 !== strcasecmp( str_replace( '\\', '/', $resolved ), str_replace( '\\', '/', $expected ) ); } private static function block( int $status = 403 ): void { status_header( $status ); nocache_headers(); header( 'Content-Type: text/plain; charset=utf-8' ); echo 404 === $status ? esc_html__( 'Not found.', 'neverlast-security-lite' ) : esc_html__( 'Request blocked by the website security policy.', 'neverlast-security-lite' ); exit; } }