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 ) ) ) ); ?>

(0)

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' ) ); ?>


        
__( 'Access denied.', 'ach-stored-payload-audit' ) ), 403 ); } if ( false === check_ajax_referer( self::ACTION, 'nonce', false ) ) { wp_send_json_error( array( 'message' => __( 'Session expired. Reload the page.', 'ach-stored-payload-audit' ) ), 403 ); } } private static function requested_mode() { // Required again here so static analyzers can see protection next to input use. check_ajax_referer( self::ACTION, 'nonce' ); $mode = isset( $_POST['mode'] ) && is_string( $_POST['mode'] ) ? sanitize_key( wp_unslash( $_POST['mode'] ) ) : ''; return in_array( $mode, array( 'selected', 'all' ), true ) ? $mode : ''; } private static function tables() { global $wpdb; // Ignore views; include only tables belonging to this WordPress site. // This auditor must enumerate actual database tables: no WordPress API exposes all plugin-defined tables. // Caching is deliberately avoided so newly created or removed tables are reflected immediately. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Read current database metadata, not cached or model-based data. $list = $wpdb->get_results( $wpdb->prepare( 'SHOW FULL TABLES LIKE %s', $wpdb->esc_like( $wpdb->prefix ) . '%' ), ARRAY_N ); if ( ! is_array( $list ) ) { return array(); } $found = array(); foreach ( $list as $item ) { if ( ! isset( $item[0], $item[1] ) || 'BASE TABLE' !== strtoupper( (string) $item[1] ) ) { continue; } $name = (string) $item[0]; if ( 0 !== strpos( $name, $wpdb->prefix ) ) { continue; } // On the main site of a multisite install, the base prefix also // matches wp_2_*, wp_3_* and so on. Do not scan other blogs. $suffix = substr( $name, strlen( $wpdb->prefix ) ); if ( is_multisite() && preg_match( '/^[0-9]+_/', $suffix ) ) { continue; } $found[] = $name; } sort( $found, SORT_STRING ); return $found; } /** * Available tables are discovered server-side; client-provided identifiers * can never expand the scan to other database prefixes or arbitrary tables. */ private static function requested_tables( $available ) { check_ajax_referer( self::ACTION, 'nonce' ); $raw = isset( $_POST['tables'] ) && is_string( $_POST['tables'] ) ? sanitize_text_field( wp_unslash( $_POST['tables'] ) ) : ''; if ( '' === $raw || strlen( $raw ) > 65536 ) { return null; } $names = json_decode( $raw, true ); if ( ! is_array( $names ) || empty( $names ) || count( $names ) > 500 ) { return null; } $allowed = array_fill_keys( $available, true ); $chosen = array(); foreach ( $names as $name ) { if ( ! is_string( $name ) || ! isset( $allowed[ $name ] ) ) { return null; } $chosen[ $name ] = true; } return array_keys( $chosen ); } public static function ajax_tables() { self::authorize(); global $wpdb; $wpdb->suppress_errors( true ); $names = self::tables(); $core = $wpdb->tables( 'all', true ); $failed = ! empty( $wpdb->last_error ); $wpdb->suppress_errors( false ); if ( $failed ) { wp_send_json_error( array( 'message' => __( 'Unable to list the database tables.', 'ach-stored-payload-audit' ) ), 500 ); } wp_send_json_success( array( 'tables' => $names, 'sitePrefix' => $wpdb->prefix, 'core' => array_values( array_intersect( $names, $core ) ), ) ); } private static function columns( $table ) { global $wpdb; // Table names are allow-listed against this site's tables and passed through %i. // Metadata is intentionally fetched live to prevent an outdated table schema from being scanned. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- Read current schema of an allow-listed database table. $metadata = $wpdb->get_results( $wpdb->prepare( 'SHOW COLUMNS FROM %i', $table ), ARRAY_A ); if ( ! is_array( $metadata ) || empty( $metadata ) ) { return null; } $text = array(); $primary = array(); foreach ( $metadata as $col ) { if ( ! isset( $col['Field'], $col['Type'] ) ) { continue; } $name = (string) $col['Field']; $type = strtolower( (string) $col['Type'] ); if ( preg_match( '/^(?:varchar|char|(?:tiny|medium|long)?text|json|enum|set)\b/i', $type ) ) { $text[] = $name; } if ( isset( $col['Key'] ) && 'PRI' === $col['Key'] ) { $primary[] = $col; } } $numeric_key = null; if ( 1 === count( $primary ) && preg_match( '/^(?:tinyint|smallint|mediumint|int|integer|bigint)\b/i', (string) $primary[0]['Type'] ) ) { $numeric_key = (string) $primary[0]['Field']; } return array( 'text' => $text, 'key' => $numeric_key ); } public static function ajax_init() { self::authorize(); $mode = self::requested_mode(); if ( ! $mode ) { wp_send_json_error( array( 'message' => __( 'Invalid scan scope.', 'ach-stored-payload-audit' ) ), 400 ); } global $wpdb; $wpdb->suppress_errors( true ); $all = self::tables(); if ( 'selected' === $mode ) { $chosen = self::requested_tables( $all ); if ( null === $chosen ) { $wpdb->suppress_errors( false ); wp_send_json_error( array( 'message' => __( 'Choose at least one valid table.', 'ach-stored-payload-audit' ) ), 400 ); } $all = $chosen; } $tables = array(); $skipped = 0; foreach ( $all as $name ) { $schema = self::columns( $name ); if ( null === $schema ) { $wpdb->suppress_errors( false ); wp_send_json_error( array( 'message' => __( 'Unable to read table metadata. Check the database permissions.', 'ach-stored-payload-audit' ) ), 500 ); } if ( empty( $schema['text'] ) ) { ++$skipped; continue; } $tables[] = $name; } $wpdb->suppress_errors( false ); wp_send_json_success( array( 'tables' => $tables, 'skipped' => $skipped ) ); } /** * Offer a direct link only to an existing, published public post/page. * Never generate arbitrary URLs from attacker-controlled database cells. */ private static function public_post_url( $table, $schema, $row ) { global $wpdb; if ( $table !== $wpdb->posts || null === $schema['key'] || 'ID' !== $schema['key'] || ! isset( $row['achstpaa_record_key'] ) ) { return ''; } $post = get_post( (int) $row['achstpaa_record_key'] ); if ( ! $post || 'publish' !== $post->post_status || ! is_post_type_viewable( $post->post_type ) ) { return ''; } $url = get_permalink( $post ); if ( ! is_string( $url ) ) { return ''; } $scheme = wp_parse_url( $url, PHP_URL_SCHEME ); $host = wp_parse_url( $url, PHP_URL_HOST ); $site_host = wp_parse_url( home_url( '/' ), PHP_URL_HOST ); if ( ! in_array( $scheme, array( 'http', 'https' ), true ) || ! is_string( $host ) || ! is_string( $site_host ) || 0 !== strcasecmp( $host, $site_host ) ) { return ''; } return esc_url_raw( $url ); } /** * Explain duplicate-looking findings that belong to stored WordPress revisions. * Only inspect post metadata for records in the current site's posts table; * do not alter or exclude revisions from the scan. */ private static function record_context( $table, $schema, $row ) { global $wpdb; if ( $table !== $wpdb->posts || null === $schema['key'] || 'ID' !== $schema['key'] || ! isset( $row['achstpaa_record_key'] ) ) { return ''; } $post = get_post( (int) $row['achstpaa_record_key'] ); if ( ! $post || 'revision' !== $post->post_type ) { return ''; } $parent_id = (int) $post->post_parent; return $parent_id > 0 ? sprintf( /* translators: %d: Original post ID. */ __( 'Revision of post #%d', 'ach-stored-payload-audit' ), $parent_id ) : __( 'Revision (parent unavailable)', 'ach-stored-payload-audit' ); } /** * Retrieve one previously flagged cell on demand. No payload is ever inserted * into the findings table; the client must display this response as plain text. */ public static function ajax_inspect() { self::authorize(); check_ajax_referer( self::ACTION, 'nonce' ); $mode = self::requested_mode(); $table = isset( $_POST['table'] ) && is_string( $_POST['table'] ) ? sanitize_text_field( wp_unslash( $_POST['table'] ) ) : ''; $column = isset( $_POST['column'] ) && is_string( $_POST['column'] ) ? sanitize_text_field( wp_unslash( $_POST['column'] ) ) : ''; $selector = isset( $_POST['selector'] ) && is_string( $_POST['selector'] ) ? sanitize_text_field( wp_unslash( $_POST['selector'] ) ) : ''; if ( ! $mode || ! $table || ! in_array( $table, self::tables(), true ) ) { wp_send_json_error( array( 'message' => __( 'Invalid scan scope or table.', 'ach-stored-payload-audit' ) ), 400 ); } if ( ! preg_match( '/\A(?:0|[1-9][0-9]{0,15})\z/D', $selector ) ) { wp_send_json_error( array( 'message' => __( 'Invalid row selector.', 'ach-stored-payload-audit' ) ), 400 ); } $position = filter_var( $selector, FILTER_VALIDATE_INT ); if ( false === $position || $position < 0 ) { wp_send_json_error( array( 'message' => __( 'Invalid row selector.', 'ach-stored-payload-audit' ) ), 400 ); } global $wpdb; $wpdb->suppress_errors( true ); $schema = self::columns( $table ); if ( null === $schema || ! in_array( $column, $schema['text'], true ) ) { $wpdb->suppress_errors( false ); wp_send_json_error( array( 'message' => __( 'Column is not a scan-eligible text field.', 'ach-stored-payload-audit' ) ), 400 ); } // All SQL identifiers are validated against database metadata and bound with %i. if ( null !== $schema['key'] ) { $query = $wpdb->prepare( 'SELECT LEFT(%i, %d) AS `achstpaa_content`, CHAR_LENGTH(%i) AS `achstpaa_length` FROM %i WHERE %i = %d LIMIT 1', $column, self::CELL_CHARS, $column, $table, $schema['key'], $position ); } else { $query = $wpdb->prepare( 'SELECT LEFT(%i, %d) AS `achstpaa_content`, CHAR_LENGTH(%i) AS `achstpaa_length` FROM %i LIMIT 1 OFFSET %d', $column, self::CELL_CHARS, $column, $table, $position ); } // $query was fully prepared in one of the two branches above. Identifiers are bound via %i; // position/limit via %d. A live audit must not cache potentially sensitive payloads. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared -- Prepared in the preceding mutually exclusive branches. $result = $wpdb->get_row( $query, ARRAY_A ); $failed = ! empty( $wpdb->last_error ); $wpdb->suppress_errors( false ); if ( $failed ) { wp_send_json_error( array( 'message' => __( 'Unable to read the selected database cell.', 'ach-stored-payload-audit' ) ), 500 ); } $meta_key = ''; if ( $table === $wpdb->postmeta && 'meta_value' === $column && 'meta_id' === $schema['key'] ) { $metadata = get_metadata_by_mid( 'post', $position ); $meta_key = $metadata && isset( $metadata->meta_key ) ? (string) $metadata->meta_key : ''; } if ( ! is_array( $result ) || ! is_string( $result['achstpaa_content'] ) || empty( self::detect( $result['achstpaa_content'], $meta_key ) ) ) { wp_send_json_error( array( 'message' => __( 'The row was removed, changed, or is no longer flagged. Rescan the table.', 'ach-stored-payload-audit' ) ), 404 ); } wp_send_json_success( array( 'content' => $result['achstpaa_content'], 'truncated' => (int) $result['achstpaa_length'] > self::CELL_CHARS, 'limit' => self::CELL_CHARS, ) ); } public static function ajax_batch() { self::authorize(); check_ajax_referer( self::ACTION, 'nonce' ); $mode = self::requested_mode(); $table = isset( $_POST['table'] ) && is_string( $_POST['table'] ) ? sanitize_text_field( wp_unslash( $_POST['table'] ) ) : ''; if ( ! $mode || ! is_string( $table ) || ! in_array( $table, self::tables(), true ) ) { wp_send_json_error( array( 'message' => __( 'Invalid table or scan scope.', 'ach-stored-payload-audit' ) ), 400 ); } $offset = isset( $_POST['offset'] ) && is_string( $_POST['offset'] ) ? filter_var( sanitize_text_field( wp_unslash( $_POST['offset'] ) ), FILTER_VALIDATE_INT ) : false; $cursor = isset( $_POST['cursor'] ) && is_string( $_POST['cursor'] ) ? filter_var( sanitize_text_field( wp_unslash( $_POST['cursor'] ) ), FILTER_VALIDATE_INT ) : false; $max = isset( $_POST['max'] ) && is_string( $_POST['max'] ) ? filter_var( sanitize_text_field( wp_unslash( $_POST['max'] ) ), FILTER_VALIDATE_INT ) : false; if ( false === $offset || false === $cursor || false === $max || $offset < 0 || $cursor < 0 || ! in_array( $max, array( 0, 1000, 10000 ), true ) ) { wp_send_json_error( array( 'message' => __( 'Invalid scan parameters.', 'ach-stored-payload-audit' ) ), 400 ); } if ( $max && $offset >= $max ) { wp_send_json_success( array( 'done' => true, 'read' => 0, 'next' => $offset, 'cursor' => $cursor, 'total' => 0, 'findings' => array(), 'limited' => true ) ); } global $wpdb; $wpdb->suppress_errors( true ); $schema = self::columns( $table ); if ( null === $schema || empty( $schema['text'] ) ) { $wpdb->suppress_errors( false ); wp_send_json_error( array( 'message' => __( 'Unable to read the table columns.', 'ach-stored-payload-audit' ) ), 500 ); } $take = $max ? min( self::CHUNK, $max - $offset ) : self::CHUNK; // Columns are read from SHOW COLUMNS; %i escapes identifiers (WP 6.2+). // A single prepare() call binds every identifier and every pagination // value, even if a plugin uses unusual characters in a column name. $select = array(); $arguments = array(); if ( null !== $schema['key'] ) { $select[] = '%i AS `achstpaa_record_key`'; $arguments[] = $schema['key']; } foreach ( $schema['text'] as $index => $column ) { $select[] = 'LEFT(%i, %d) AS %i'; $arguments[] = $column; $arguments[] = self::CELL_CHARS; $arguments[] = 'achstpaa_field_' . $index; } $statement = 'SELECT ' . implode( ', ', $select ) . ' FROM %i'; $arguments[] = $table; if ( null !== $schema['key'] ) { $statement .= ' WHERE %i > %d ORDER BY %i ASC LIMIT %d'; array_push( $arguments, $schema['key'], $cursor, $schema['key'], $take ); } else { $statement .= ' LIMIT %d OFFSET %d'; array_push( $arguments, $take, $offset ); } // The variable query template contains only fixed SQL fragments; // metadata-derived identifiers and client-supplied values are bound. // $statement uses fixed SELECT/WHERE/ORDER/LIMIT fragments only; every dynamic // identifier and numeric value is supplied through the matching %i/%d placeholder. // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- The template is assembled solely from trusted literal SQL fragments before prepare(). $sql = $wpdb->prepare( $statement, $arguments ); // $sql is the result of prepare() above, never raw client input. The selected table // is server-side allow-listed; all column names come from database metadata. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching,WordPress.DB.PreparedSQL.NotPrepared,PluginCheck.Security.DirectDB.UnescapedDBParameter -- Execute the prepared read-only query; audit results must not be cached. $rows = $wpdb->get_results( $sql, ARRAY_A ); $query_error = ! empty( $wpdb->last_error ); $wpdb->suppress_errors( false ); if ( $query_error || ! is_array( $rows ) ) { wp_send_json_error( array( 'message' => __( 'Database read error. Check the database/server logs.', 'ach-stored-payload-audit' ) ), 500 ); } $findings = array(); $total = 0; $next_cursor = $cursor; foreach ( $rows as $row_number => $row ) { $record = sprintf( /* translators: %d: Position of a row without a numeric primary key. */ __( 'row #%d', 'ach-stored-payload-audit' ), $offset + $row_number + 1 ); if ( null !== $schema['key'] && isset( $row['achstpaa_record_key'] ) ) { $record = sprintf( /* translators: %s: Numeric record ID. */ __( 'ID %s', 'ach-stored-payload-audit' ), (string) $row['achstpaa_record_key'] ); $next_cursor = max( $next_cursor, (int) $row['achstpaa_record_key'] ); } foreach ( $schema['text'] as $index => $column ) { $key = 'achstpaa_field_' . $index; if ( ! isset( $row[ $key ] ) || ! is_string( $row[ $key ] ) || '' === $row[ $key ] ) { continue; } $meta_key = ''; if ( $table === $wpdb->postmeta && 'meta_value' === $column ) { $meta_index = array_search( 'meta_key', $schema['text'], true ); if ( false !== $meta_index && isset( $row[ 'achstpaa_field_' . $meta_index ] ) ) { $meta_key = (string) $row[ 'achstpaa_field_' . $meta_index ]; } } $signals = self::detect( $row[ $key ], $meta_key ); if ( empty( $signals ) ) { continue; } ++$total; if ( count( $findings ) < self::FINDINGS_PER_BATCH ) { $findings[] = array( 'table' => $table, 'record' => $record, 'recordContext' => self::record_context( $table, $schema, $row ), 'selector' => null !== $schema['key'] ? (string) $row['achstpaa_record_key'] : (string) ( $offset + $row_number ), 'column' => $column, 'signal' => implode( ', ', array_keys( $signals ) ), 'form' => implode( ', ', array_unique( array_values( $signals ) ) ), 'openUrl' => self::public_post_url( $table, $schema, $row ), ); } } } $read = count( $rows ); $next = $offset + $read; $limited = ( 0 !== $max && $next >= $max ); wp_send_json_success( array( 'done' => ( $read < $take || $limited ), 'limited' => $limited, 'read' => $read, 'next' => $next, 'cursor' => $next_cursor, 'total' => $total, 'findings' => $findings, ) ); } /** * Parse only ordinary quoted HTML attributes. Duplicate or unexpected * attributes prevent an oEmbed exemption rather than silently hiding them. * This parser is intentionally narrower than an HTML parser: it is only * used to recognize the exact WordPress-generated cache structure below. */ private static function embed_attributes( $source, $allowed ) { $attributes = array(); $offset = 0; $length = strlen( $source ); while ( $offset < $length ) { if ( ! preg_match( '/\G\s+([a-z][a-z0-9-]*)\s*=\s*(["\x27])([^"\x27<>]*)\2/is', $source, $matches, 0, $offset ) ) { return null; } $key = strtolower( $matches[1] ); if ( ! in_array( $key, $allowed, true ) || isset( $attributes[ $key ] ) ) { return null; } $attributes[ $key ] = html_entity_decode( $matches[3], ENT_QUOTES | ENT_HTML5, 'UTF-8' ); $offset += strlen( $matches[0] ); } return $attributes; } /** * Ignore only the narrow, benign, same-site iframe markup that WordPress * normally caches under an _oembed_ postmeta key. An _oembed_ name * alone is NOT evidence of safety: cache rows can also contain bad HTML. * Any unfamiliar attribute/tag, executable event, foreign host, or extra * markup keeps the entry visible in the audit results. */ private static function benign_wp_oembed( $value, $meta_key ) { if ( ! preg_match( '/\A_oembed_[a-f0-9]{32}\z/D', $meta_key ) ) { return false; } $pattern = '~\A\s*(?:]*)>\s*]*)>([^<]*)\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*\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 = '~]*\stype\s*=\s*(?:"application/ld\+json"|\x27application/ld\+json\x27|application/ld\+json(?=[\s/>])))[^>]*>(.*?)~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( '&#x' . ( '' !== $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();