Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
89.19% covered (warning)
89.19%
33 / 37
0.00% covered (danger)
0.00%
0 / 1
CRAP
0.00% covered (danger)
0.00%
0 / 1
Body_Close_Locator
89.19% covered (warning)
89.19%
33 / 37
0.00% covered (danger)
0.00%
0 / 1
21.56
0.00% covered (danger)
0.00%
0 / 1
 find
89.19% covered (warning)
89.19%
33 / 37
0.00% covered (danger)
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
8namespace 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 */
33class 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}