"\'\\\\]+\.)+[^\s/?#:@.<>"\'\\\\]{2,}(?::\d+)?(?:[/?#]\S*)?$~u', $url ); // A scheme-less host gets its http:// from us, because core skips it when a colon appears later // in the value (example.com/a?t=10:30) and then empties the URL. $candidate = $host_like ? 'http://' . $url : $url; $clean = esc_url_raw( $candidate ); // What esc_url_raw() is allowed to hand back: the input with spaces written as %20, square // brackets as %5B and %5D outside the host, and a bare host with http:// in front. Anything else it // did (dropping characters, a different scheme, an empty result) is a rewrite, and a rewrite is a // refusal. $authority = ''; if ( 1 === preg_match( '~^(?:[a-z][a-z0-9+.\-]*://)?[^/?#]*~i', $candidate, $found ) ) { $authority = $found[0]; } $rest = substr( $candidate, strlen( $authority ) ); $expected = str_replace( ' ', '%20', $authority ) . str_replace( array( ' ', '[', ']' ), array( '%20', '%5B', '%5D' ), $rest ); $valid = '' !== $clean && ( $has_scheme || $is_path || $host_like ); if ( $valid && $has_scheme ) { // Needs something after the scheme: a host for scheme://, an address for mailto: and tel:. $after = substr( $url, strpos( $url, ':' ) + 1 ); $valid = '' !== ltrim( $after, '/' ) && 1 !== preg_match( '~^[a-z][a-z0-9+.\-]*://[^/?#]*\s~i', $url ) && ( 0 !== strpos( $after, '//' ) || '' !== (string) wp_parse_url( $url, PHP_URL_HOST ) ); } $valid = $valid && $clean === $expected; if ( ! $valid ) { return new WP_Error( 'aafm_invalid_url', sprintf( /* translators: %s: the name of the input field that held the URL. */ __( 'The %s value is not a valid URL, so nothing was changed. Use an https:// or http:// address, a mailto: or tel: link, a path starting with /, or an anchor starting with #.', 'agent-abilities-for-mcp' ), $field ), array( 'status' => 400 ) ); } return $clean; } /** * The multi-line counterpart, for a field that legitimately keeps its newlines: an order note, a * refund reason, a media caption. * * Same strip, different WordPress sanitizer underneath. sanitize_text_field() would collapse the * newlines out of a multi-line value, which is why these fields use the textarea sanitizer in the * first place, and that one has exactly the same gap: a raw NUL is valid UTF-8, so it passes * wp_check_invalid_utf8() and reaches storage. * * This matters as much as the single-line case, not less. A media caption is stored as * post_excerpt, and post_excerpt is rendered into the site's RSS feed, so a NUL arriving through a * caption breaks the whole feed document exactly the way one in a title does. The needle set * already spares tab, LF and CR, so multi-line text survives intact. * * @param string $value Raw value from input. * @return string Sanitized multi-line plain text. */ function aafm_sanitize_multiline_text( string $value ): string { return str_replace( aafm_unsafe_text_characters(), '', sanitize_textarea_field( $value ) ); } /** * Escape a CDATA terminator on its way into the site's feed. * * The feed wraps each excerpt in ``, so a raw `]]>` inside one ends the section * early, turns everything after it into markup, and makes the whole document not well-formed. That * is the same harm the invisible-character strip above exists to prevent, arriving through a * character nobody enumerated. * * It is fixed HERE, at output, rather than in the sanitizers above, and the difference matters. * Every character those strip is invalid or deceptive in any context, so removing it loses nothing. * `]]>` is ordinary text - a post explaining XML has every reason to contain it - and only breaks * one serialization. Stripping it at storage would be silent data loss on legitimate content, so * what is stored stays exactly as written and only the feed rendering is escaped. * * WordPress already does precisely this for the sibling field: get_the_content_feed() runs * str_replace( ']]>', ']]>' ) over post_content (wp-includes/feed.php). the_excerpt_rss() has no * equivalent - its only filters are convert_chars and ent2ncr, and neither touches the sequence - * so post_excerpt was unprotected where post_content was protected. This closes that asymmetry the * same way core closed it. * * The filter can only ever repair a feed, never break a working one: any excerpt containing the raw * sequence is already producing a malformed document. * * @param string $text Feed text. * @return string */ function aafm_escape_feed_cdata( $text ): string { return str_replace( ']]>', ']]>', (string) $text ); } add_filter( 'the_excerpt_rss', 'aafm_escape_feed_cdata' ); /** * Trim ASCII whitespace and the invisible or non-ASCII space characters that trim() leaves alone * (NBSP, the Unicode spaces, zero-width characters, the byte-order mark) from both ends. Works on raw * bytes with no /u flag, so invalid UTF-8 comes back unchanged instead of null, and a lone A0 byte * inside a multibyte character (the letter a-grave) is never touched. * * @param string $text Text to trim. * @return string */ function aafm_trim_unicode( string $text ): string { $space = '(?:[ \t\n\r\0\x0B\f]|\xC2\xA0|\xE1\x9A\x80|\xE2\x80[\x80-\x8D\xAF]|\xE2\x81[\x9F\xA0]|\xE3\x80\x80|\xEF\xBB\xBF)'; $out = preg_replace( '/\A' . $space . '+|' . $space . '+\z/', '', $text ); return null === $out ? trim( $text ) : $out; }