Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
89.19% |
33 / 37 |
|
0.00% |
0 / 1 |
CRAP | |
0.00% |
0 / 1 |
| Body_Close_Locator | |
89.19% |
33 / 37 |
|
0.00% |
0 / 1 |
21.56 | |
0.00% |
0 / 1 |
| find | |
89.19% |
33 / 37 |
|
0.00% |
0 / 1 |
21.56 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * Locates the document's real closing body tag in an HTML buffer. |
| 4 | * |
| 5 | * @package automattic/jetpack-boost |
| 6 | */ |
| 7 | |
| 8 | namespace Automattic\Jetpack_Boost\Lib; |
| 9 | |
| 10 | /** |
| 11 | * Finds the byte offset of a document's real closing body tag. |
| 12 | * |
| 13 | * A literal '</body>' can appear in places that are not markup: inside a |
| 14 | * script's source (an HTML string a document.write() call later emits), |
| 15 | * inside a <textarea>, <title> or <style>, inside an HTML comment, or inside |
| 16 | * a quoted attribute value. Inserting markup at such an occurrence corrupts |
| 17 | * the page — the injected '</script>' closes the surrounding script early and |
| 18 | * the remaining JavaScript renders as visible text (BOOST-585). |
| 19 | * |
| 20 | * Rather than model those contexts by hand, this walks the buffer with core's |
| 21 | * spec-compliant HTML tokenizer, which only reports a BODY closer when the |
| 22 | * byte really is one. |
| 23 | * |
| 24 | * Best effort by design: the buffer is an output-buffer window, and a window |
| 25 | * that begins inside a script or comment region whose opening tag was flushed |
| 26 | * in an earlier chunk cannot be tokenized correctly by any scan of the window |
| 27 | * alone. Whenever no trustworthy closing tag is found the locator returns |
| 28 | * null and the caller appends at the end of the buffer instead of rewriting |
| 29 | * existing markup. |
| 30 | * |
| 31 | * @since 4.7.0 |
| 32 | */ |
| 33 | class Body_Close_Locator { |
| 34 | |
| 35 | /** |
| 36 | * Containers whose content the tokenizer reports as ordinary tokens even |
| 37 | * though a BODY closer inside them is never the document's closing tag: |
| 38 | * template content is inert DOM, noscript content is text when scripting |
| 39 | * is enabled, and SVG/MathML are foreign content. Closers seen inside any |
| 40 | * of these are skipped. (Raw-text containers — script, style, textarea, |
| 41 | * title, iframe, xmp, noembed, noframes — need no entry here: the |
| 42 | * tokenizer already withholds their contents.) |
| 43 | * |
| 44 | * @var string[] |
| 45 | */ |
| 46 | const SKIPPED_CONTAINERS = array( 'TEMPLATE', 'NOSCRIPT', 'SVG', 'MATH' ); |
| 47 | |
| 48 | /** |
| 49 | * Buffers above this size are not scanned at all. Bounds the walk's CPU |
| 50 | * cost, which is roughly linear in token count. The buffer this locator |
| 51 | * sees is normally a few hundred bytes to a few tens of kilobytes; it can |
| 52 | * grow when script retention holds earlier chunks back. |
| 53 | * |
| 54 | * @var int |
| 55 | */ |
| 56 | const MAX_SCAN_BYTES = 1000000; |
| 57 | |
| 58 | /** |
| 59 | * Buffers whose widest apparent tag exceeds this are not scanned. The |
| 60 | * tokenizer allocates one attribute token per attribute on the tag it is |
| 61 | * parsing, so peak memory tracks the widest single tag — measured at ~23x |
| 62 | * the tag's width — and a sub-1 MB buffer can still exhaust memory if one |
| 63 | * tag carries tens of thousands of attributes. The width check is the |
| 64 | * quote-blind pre-scan in find(), a best-effort bound rather than an |
| 65 | * exact one; at this ceiling the ordinary worst case is a few megabytes. |
| 66 | * |
| 67 | * @var int |
| 68 | */ |
| 69 | const MAX_TAG_BYTES = 100000; |
| 70 | |
| 71 | /** |
| 72 | * Find the byte offset of the buffer's last top-level closing body tag. |
| 73 | * |
| 74 | * The last one, not the first: the document's own closing tag follows any |
| 75 | * stray closer its content holds. Taking the last candidate is also what |
| 76 | * makes the walk self-correcting when the buffer begins inside a comment |
| 77 | * or raw-text region whose opening tag was flushed in an earlier output |
| 78 | * chunk: the tokenizer misreads that region's text as markup, but any |
| 79 | * false candidate it yields is overwritten as soon as the region ends and |
| 80 | * the document's real closing tag is reached. (This is why the walk must |
| 81 | * not stop early at a closing </html> tag — a false one inside such a |
| 82 | * region would freeze the false candidate. The cost is that a bare, |
| 83 | * uncontained '</body>' in trailing output after the document can shift |
| 84 | * the insertion point past the document's own tag; browsers reparent that |
| 85 | * trailing content into body, so the moved scripts still run.) |
| 86 | * |
| 87 | * When the buffer ends inside an unterminated token (an unclosed comment |
| 88 | * or raw-text region at the end of the window), the tokenizer stops |
| 89 | * without reporting anything from that region; a candidate found before |
| 90 | * it is still valid — it was reached as real markup — and no candidate |
| 91 | * means null and the append fallback. |
| 92 | * |
| 93 | * @param string $buffer HTML buffer. |
| 94 | * |
| 95 | * @return int|null Byte offset of the '<' of the closing body tag, or null when none was found. |
| 96 | */ |
| 97 | public static function find( $buffer ) { |
| 98 | // The walk needs next_token(), which core added in 6.5. WordPress only |
| 99 | // enforces the plugin's 'Requires at least' header at activation, so a |
| 100 | // manual core downgrade can leave Boost active on an older core, where |
| 101 | // calling it would fatal inside the output-buffer callback and blank |
| 102 | // every page. (On cores without the class at all, method_exists() |
| 103 | // returns false rather than erroring.) |
| 104 | if ( ! method_exists( \WP_HTML_Tag_Processor::class, 'next_token' ) ) { |
| 105 | return null; |
| 106 | } |
| 107 | |
| 108 | // mbstring.func_overload (PHP 7.x only, removed in 8.0) rebinds strlen(), |
| 109 | // strpos() and substr() to their multibyte counterparts. The offset |
| 110 | // returned here feeds byte arithmetic (substr_replace), so on such a |
| 111 | // host no position can be trusted. |
| 112 | // phpcs:ignore PHPCompatibility.IniDirectives.RemovedIniDirectives.mbstring_func_overloadDeprecated,PHPCompatibility.IniDirectives.RemovedIniDirectives.mbstring_func_overloadDeprecatedRemoved -- Read, not set: the directive being deprecated and then removed on the supported range is exactly why this check exists. |
| 113 | if ( 2 & (int) ini_get( 'mbstring.func_overload' ) ) { |
| 114 | return null; |
| 115 | } |
| 116 | |
| 117 | if ( strlen( $buffer ) > self::MAX_SCAN_BYTES ) { |
| 118 | return null; |
| 119 | } |
| 120 | |
| 121 | // Refuse a buffer whose widest '<'…'>' run exceeds MAX_TAG_BYTES. |
| 122 | // The scan is quote-blind: a '>' inside a quoted attribute value ends |
| 123 | // a run early, so this is a best-effort bound on the dense-attribute |
| 124 | // shape rather than an exact tag width. Overcounts (comments, raw |
| 125 | // text) only err towards the safe append fallback. |
| 126 | $cursor = strpos( $buffer, '<' ); |
| 127 | while ( false !== $cursor ) { |
| 128 | $close = strpos( $buffer, '>', $cursor + 1 ); |
| 129 | if ( false === $close ) { |
| 130 | break; |
| 131 | } |
| 132 | if ( $close - $cursor > self::MAX_TAG_BYTES ) { |
| 133 | return null; |
| 134 | } |
| 135 | $cursor = strpos( $buffer, '<', $close + 1 ); |
| 136 | } |
| 137 | |
| 138 | $processor = new Position_Aware_Tag_Processor( $buffer ); |
| 139 | $position = null; |
| 140 | $depths = array_fill_keys( self::SKIPPED_CONTAINERS, 0 ); |
| 141 | |
| 142 | while ( $processor->next_token() ) { |
| 143 | if ( '#tag' !== $processor->get_token_type() ) { |
| 144 | continue; |
| 145 | } |
| 146 | |
| 147 | $name = $processor->get_token_name(); |
| 148 | if ( null === $name ) { |
| 149 | continue; |
| 150 | } |
| 151 | |
| 152 | if ( 'PLAINTEXT' === $name && ! $processor->is_tag_closer() ) { |
| 153 | // Everything after a plaintext opener is text (the element |
| 154 | // cannot be closed), but the tokenizer keeps reporting tokens |
| 155 | // there — stop so none of them becomes a false candidate. |
| 156 | break; |
| 157 | } |
| 158 | |
| 159 | if ( isset( $depths[ $name ] ) ) { |
| 160 | if ( $processor->is_tag_closer() ) { |
| 161 | if ( $depths[ $name ] > 0 ) { |
| 162 | --$depths[ $name ]; |
| 163 | } |
| 164 | } elseif ( ! $processor->has_self_closing_flag() || ( 'SVG' !== $name && 'MATH' !== $name ) ) { |
| 165 | // Foreign content honours the self-closing flag; on the |
| 166 | // HTML elements (template, noscript) a browser ignores it |
| 167 | // and opens the region anyway. |
| 168 | ++$depths[ $name ]; |
| 169 | } |
| 170 | continue; |
| 171 | } |
| 172 | |
| 173 | if ( array_sum( $depths ) > 0 || ! $processor->is_tag_closer() ) { |
| 174 | continue; |
| 175 | } |
| 176 | |
| 177 | if ( 'BODY' === $name ) { |
| 178 | $position = $processor->get_token_byte_offset(); |
| 179 | } |
| 180 | } |
| 181 | |
| 182 | return $position; |
| 183 | } |
| 184 | } |