|null Sanitized structure, or null when * the input isn't array-shaped at all (not a valid JSON-LD * node/graph). */ public static function sanitize( mixed $decoded ): ?array { if ( ! is_array( $decoded ) ) { return null; } return self::sanitize_array( $decoded, 0 ); } private static function sanitize_value( mixed $value, int $depth ): mixed { if ( $depth > self::MAX_DEPTH ) { return null; } if ( is_string( $value ) ) { return self::sanitize_string( $value ); } if ( is_int( $value ) || is_float( $value ) || is_bool( $value ) || null === $value ) { return $value; } if ( is_array( $value ) ) { return self::sanitize_array( $value, $depth ); } // json_decode( ..., true ) never produces anything else (no objects, // no resources) — reject defensively if it somehow ever did. return null; } /** * @param array $value * @return array */ private static function sanitize_array( array $value, int $depth ): array { $clean = array(); $count = 0; foreach ( $value as $key => $item ) { if ( $count >= self::MAX_ARRAY_ITEMS ) { break; } // json_decode( ..., true ) only ever produces string or sequential // int keys — guarded defensively anyway. if ( ! is_string( $key ) && ! is_int( $key ) ) { continue; } // Property names are user input too ("@type", "name", …) — same // tag stripping as values; a key that strips down to nothing is // not a usable property and is dropped. if ( is_string( $key ) ) { $key = wp_strip_all_tags( wp_check_invalid_utf8( $key ), true ); if ( '' === $key ) { continue; } } $sanitized_item = self::sanitize_value( $item, $depth + 1 ); // A hard depth/type rejection returns null for a non-null input — // drop just that one entry rather than the whole structure (same // "drop rather than error" doctrine as sanitize_custom_schemas()). if ( null === $sanitized_item && null !== $item ) { continue; } $clean[ $key ] = $sanitized_item; ++$count; } return $clean; } private static function sanitize_string( string $value ): string { $value = wp_check_invalid_utf8( $value ); // Strip HTML tags but keep \n — schema.org text properties (recipe // instructions, an article body pasted as a custom node) legitimately // span multiple lines. wp_strip_all_tags() rather than // sanitize_text_field(): schema values routinely carry percent-encoded // URLs, and sanitize_text_field() strips percent-hex-looking sequences // (the %hex-stripping trap, see TemplateSanitizer) while // wp_strip_all_tags() leaves them intact. $value = wp_strip_all_tags( $value, false ); // Strip ASCII control characters (incl. NUL) but keep \n/\t. $value = (string) preg_replace( '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/', '', $value ); if ( strlen( $value ) <= self::MAX_STRING_LEN ) { return $value; } // The byte-wise substr() keeps MAX_STRING_LEN a real byte budget, but it // can land mid-codepoint and leave a dangling UTF-8 continuation byte — // which the wp_check_invalid_utf8() above cannot catch, because it ran // BEFORE the truncation. That broken byte would reach post meta, with // only wp_json_encode()'s own sanity check papering over it downstream. // mb_substr() re-cuts the byte slice on a character boundary and resolves // the partial character — native ext-mbstring substitutes it with '?', // WordPress's own compat.php polyfill drops it; both leave valid UTF-8 // within the byte budget, and both are a no-op when the byte cut already // fell on a character boundary. Safe to call unconditionally: WordPress // polyfills mb_substr() when ext-mbstring is unavailable. return mb_substr( substr( $value, 0, self::MAX_STRING_LEN ), 0, self::MAX_STRING_LEN, 'UTF-8' ); } }