admin_url( 'admin-ajax.php' ), 'nonce' => wp_create_nonce( self::ACTION ), ) ); wp_enqueue_style( 'achstpaa-audit', plugin_dir_url( __FILE__ ) . 'assets/audit.css', array(), self::VERSION ); } public static function page() { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'Access denied.', 'ach-stored-payload-audit' ) ); } ?>
possible stored XSS indicators in selected tables or across your site\'s WordPress database. This tool is not a WAF, does not block incoming requests, and cannot guarantee that your site is free of vulnerabilities.', 'ach-stored-payload-audit' ) ); ?>
Read-only: no database modifications or deletions, no server-side log or report files, and no requests to external services. Findings remain in this browser tab and disappear when you reload the page.', 'ach-stored-payload-audit' ) ); ?>
first %2$s characters of each text field are scanned. Binary columns and tables with other prefixes are excluded. Tables without a single numeric primary key are read using OFFSET: concurrent changes may cause rows to be skipped or repeated.', 'ach-stored-payload-audit' ), esc_html( (string) self::CHUNK ), esc_html( number_format_i18n( self::CELL_CHARS ) ) ) ); ?>
database cell that deserves review, not a confirmed vulnerability or proof of script execution. Valid, benign Schema.org JSON-LD and strictly validated WordPress or YouTube oEmbed cache markup are ignored. Ordinary scripts and educational security examples may still generate false positives. WordPress revisions are scanned as separate rows and labeled with their parent post ID.', 'ach-stored-payload-audit' ) ); ?>
View content for a safe, plain-text preview of the flagged cell. Open page is shown only for published content in public post types. Opening a live page may execute malicious code if that page is compromised; inspect the text first. A row number (rather than an ID) may change if the table changes during scanning.', 'ach-stored-payload-audit' ) ); ?>
]*)>\s*]*)>([^<]*)\s*\s*)?\s*\z~is'; if ( ! preg_match( $pattern, $value, $parts ) ) { return false; } $iframe = self::embed_attributes( $parts[4], array( 'class', 'sandbox', 'security', 'style', 'title', 'src', 'data-secret', 'width', 'height', 'frameborder', 'marginwidth', 'marginheight', 'scrolling', 'loading' ) ); if ( null === $iframe || ! isset( $iframe['class'], $iframe['sandbox'], $iframe['security'], $iframe['src'], $iframe['data-secret'] ) || 'wp-embedded-content' !== $iframe['class'] || 'allow-scripts' !== $iframe['sandbox'] || 'restricted' !== $iframe['security'] || ! preg_match( '/\A[a-zA-Z0-9]{6,50}\z/D', $iframe['data-secret'] ) ) { return false; } if ( isset( $iframe['style'] ) && ! preg_match( '/\A\s*position:\s*absolute;\s*visibility:\s*hidden;?\s*\z/iD', $iframe['style'] ) ) { return false; } foreach ( array( 'width', 'height', 'frameborder', 'marginwidth', 'marginheight' ) as $number ) { if ( isset( $iframe[ $number ] ) && ! preg_match( '/\A[0-9]{1,5}\z/D', $iframe[ $number ] ) ) { return false; } } if ( isset( $iframe['scrolling'] ) && ! in_array( strtolower( $iframe['scrolling'] ), array( 'yes', 'no', 'auto' ), true ) ) { return false; } if ( isset( $iframe['loading'] ) && ! in_array( strtolower( $iframe['loading'] ), array( 'lazy', 'eager' ), true ) ) { return false; } $site_host = wp_parse_url( home_url( '/' ), PHP_URL_HOST ); $src_host = wp_parse_url( $iframe['src'], PHP_URL_HOST ); $src_scheme = wp_parse_url( $iframe['src'], PHP_URL_SCHEME ); $src_path = wp_parse_url( $iframe['src'], PHP_URL_PATH ); if ( ! is_string( $site_host ) || ! is_string( $src_host ) || 0 !== strcasecmp( $site_host, $src_host ) || ! in_array( strtolower( (string) $src_scheme ), array( 'http', 'https' ), true ) || ! is_string( $src_path ) || ! preg_match( '~/embed/?\z~iD', $src_path ) || null !== wp_parse_url( $iframe['src'], PHP_URL_USER ) || null !== wp_parse_url( $iframe['src'], PHP_URL_PASS ) ) { return false; } // WordPress's classic oEmbed contains a matching same-site link. if ( isset( $parts[1] ) && '' !== $parts[1] ) { $blockquote = self::embed_attributes( $parts[1], array( 'class', 'data-secret' ) ); $anchor = self::embed_attributes( $parts[2], array( 'href' ) ); if ( null === $blockquote || null === $anchor || ! isset( $blockquote['class'], $blockquote['data-secret'], $anchor['href'] ) || 'wp-embedded-content' !== $blockquote['class'] || $iframe['data-secret'] !== $blockquote['data-secret'] ) { return false; } $href_host = wp_parse_url( $anchor['href'], PHP_URL_HOST ); $href_scheme = wp_parse_url( $anchor['href'], PHP_URL_SCHEME ); $href_path = wp_parse_url( $anchor['href'], PHP_URL_PATH ); if ( ! is_string( $href_host ) || 0 !== strcasecmp( $href_host, $site_host ) || ! in_array( strtolower( (string) $href_scheme ), array( 'http', 'https' ), true ) || ! is_string( $href_path ) || rtrim( $href_path, '/' ) !== preg_replace( '~/embed/?\z~iD', '', rtrim( $src_path, '/' ) ) || null !== wp_parse_url( $anchor['href'], PHP_URL_USER ) || null !== wp_parse_url( $anchor['href'], PHP_URL_PASS ) ) { return false; } } return true; } /** * Recognize a single ordinary YouTube iframe cached by WordPress oEmbed. * A trusted host alone is not enough: validate the entire cell, every * attribute, the video ID and the exact query parameter. Anything unusual * stays visible for manual investigation. This never applies to post body * content or arbitrary metadata keys. */ private static function benign_youtube_oembed( $value, $meta_key ) { if ( ! preg_match( '/\A_oembed_[a-f0-9]{32}\z/D', $meta_key ) ) { return false; } if ( ! preg_match( '~\A\s*\s*\z~is', $value, $parts ) ) { return false; } $allowed = array( 'title', 'width', 'height', 'src', 'frameborder', 'allow', 'referrerpolicy', 'loading' ); $attributes = array(); $offset = 0; $source = rtrim( $parts[1] ); $length = strlen( $source ); while ( $offset < $length ) { // WordPress renders allowfullscreen as a boolean (no equals sign). if ( preg_match( '/\G\s+allowfullscreen(?=\s|$)/i', $source, $match, 0, $offset ) ) { if ( isset( $attributes['allowfullscreen'] ) ) { return false; } $attributes['allowfullscreen'] = true; $offset += strlen( $match[0] ); continue; } if ( ! preg_match( '/\G\s+([a-z][a-z0-9-]*)\s*=\s*(["\x27])([^"\x27<>]*)\2/is', $source, $match, 0, $offset ) ) { return false; } $name = strtolower( $match[1] ); if ( ! in_array( $name, $allowed, true ) || isset( $attributes[ $name ] ) ) { return false; } $attributes[ $name ] = html_entity_decode( $match[3], ENT_QUOTES | ENT_HTML5, 'UTF-8' ); $offset += strlen( $match[0] ); } if ( ! isset( $attributes['title'], $attributes['width'], $attributes['height'], $attributes['src'], $attributes['allowfullscreen'] ) ) { return false; } if ( ! is_string( $attributes['title'] ) || '' === trim( $attributes['title'] ) || strlen( $attributes['title'] ) > 1024 || preg_match( '/[<>\x00-\x1F\x7F]/', $attributes['title'] ) ) { return false; } foreach ( array( 'width', 'height' ) as $dimension ) { if ( ! preg_match( '/\A[1-9][0-9]{0,4}\z/D', $attributes[ $dimension ] ) ) { return false; } } if ( ! preg_match( '~\Ahttps://(?:www\.)?youtube(?:-nocookie)?\.com/embed/[A-Za-z0-9_-]{11}\?feature=oembed\z~D', $attributes['src'] ) ) { return false; } if ( isset( $attributes['frameborder'] ) && '0' !== $attributes['frameborder'] ) { return false; } if ( isset( $attributes['loading'] ) && ! in_array( $attributes['loading'], array( 'lazy', 'eager' ), true ) ) { return false; } if ( isset( $attributes['referrerpolicy'] ) && ! in_array( $attributes['referrerpolicy'], array( 'strict-origin-when-cross-origin', 'no-referrer' ), true ) ) { return false; } if ( isset( $attributes['allow'] ) ) { $features = preg_split( '/\s*;\s*/', trim( $attributes['allow'] ) ); $approved = array( 'accelerometer', 'autoplay', 'clipboard-write', 'encrypted-media', 'gyroscope', 'picture-in-picture', 'web-share', 'fullscreen' ); if ( empty( $features ) || count( $features ) !== count( array_unique( $features ) ) ) { return false; } foreach ( $features as $feature ) { if ( ! in_array( $feature, $approved, true ) ) { return false; } } } return true; } /** * JSON-LD is data, not executable JavaScript. Ignore only explicitly typed * application/ld+json blocks with valid JSON and no dangerous signatures. * Other scripts and malformed blocks remain visible. */ private static function omit_benign_json_ld( $value, $rules ) { $pattern = '~~is'; $filtered = preg_replace_callback( $pattern, static function ( $match ) use ( $rules ) { $data = json_decode( trim( $match[1] ), true ); if ( JSON_ERROR_NONE !== json_last_error() || ! is_array( $data ) ) { return $match[0]; } // Do not hide suspicious values that other components could reuse. foreach ( $rules as $rule ) { if ( 1 === preg_match( $rule, $match[1] ) ) { return $match[0]; } } return ''; }, $value ); return is_string( $filtered ) ? $filtered : $value; } /** * Conservative heuristic detection. Does not execute HTML or JavaScript. * Scan responses contain rule names, never the flagged database value. */ private static function detect( $value, $meta_key = '' ) { $rules = array( 'Script tag' => '/<\s*\/?\s*script\b/i', 'HTML event handler' => '/<[^>]{0,2000}\bon[a-z]{3,32}\s*=/i', 'JavaScript URI' => '/\b(?:javascript|vbscript)\s*:/i', 'Active HTML markup' => '/<\s*(?:iframe|object|embed|svg|math|foreignobject)\b/i', 'Embedded HTML data URI' => '/\bdata\s*:\s*text\/html\b/i', 'CSS expression' => '/\bexpression\s*\(/i', 'Sensitive DOM access' => '/\b(?:document\.(?:cookie|write|domain)|window\.location|localStorage|sessionStorage)\b/i', ); $variants = array( 'direct' => $value ); $working = $value; for ( $i = 0; $i < 3; ++$i ) { $decoded = html_entity_decode( rawurldecode( $working ), ENT_QUOTES | ENT_HTML5, 'UTF-8' ); $decoded = preg_replace_callback( '/\\\\(?:u([0-9a-f]{4})|x([0-9a-f]{2}))/i', static function ( $m ) { return html_entity_decode( '' . ( '' !== $m[1] ? $m[1] : $m[2] ) . ';', ENT_QUOTES | ENT_HTML5, 'UTF-8' ); }, $decoded ); if ( ! is_string( $decoded ) || $decoded === $working || strlen( $decoded ) > 262144 ) { break; } $variants[ 'decoded ' . ( $i + 1 ) ] = $decoded; $working = $decoded; } $matched = array(); foreach ( $variants as $form => $variant ) { $variant = self::omit_benign_json_ld( $variant, $rules ); foreach ( $rules as $label => $pattern ) { if ( ! isset( $matched[ $label ] ) && 1 === preg_match( $pattern, $variant ) ) { $matched[ $label ] = $form; } } } // Suppress the expected iframe signature only after ALL decoding passes, // and only when the entire original cache value matches a safe embed. // Other indicators, including encoded XSS strings, must stay visible. if ( 1 === count( $matched ) && isset( $matched['Active HTML markup'] ) && ( self::benign_wp_oembed( $value, $meta_key ) || self::benign_youtube_oembed( $value, $meta_key ) ) ) { return array(); } return $matched; } } ACHSTPAA_Stored_Payload_Audit::bootstrap();