PluginProbe
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder / 2.13.0
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder v2.13.0
2.13.0 2.13.1 2.12.0 2.11.1 2.11.0 2.10.0 2.9.0 2.7.4 2.7.5 2.7.6 2.7.7 2.8.0 2.8.1 2.9.1 trunk 1.0 1.0-beta1 1.0-beta2 1.0-beta3 1.0.1 1.0.2 1.0.3 1.1.0 1.1.1 1.1.2 All 80 releases
ablocks / addons / cookie-consent / buffer.php

buffer.php in aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder 2.13.0, at addons/cookie-consent/buffer.php

390 lines 10.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 namespace ABlocksCookieConsent;
3
4 if ( ! defined( 'ABSPATH' ) ) {
5 exit;
6 }
7
8 use ABlocks\Performance\ScriptGate;
9
10 /**
11 * Layer two: everything that never went through `wp_enqueue_script`.
12 *
13 * This is the layer that decides whether the feature works. GTM, GA4 and the
14 * Meta Pixel are, on the overwhelming majority of sites, pasted into a header
15 * hook or injected by another plugin — none of that passes through
16 * `script_loader_tag`, so layer one never sees it.
17 *
18 * Buffering the whole page is the highest-risk thing this addon does, and the
19 * mitigations are deliberate rather than incidental:
20 *
21 * - it only runs when the addon is on and the mode is opt-in;
22 * - it only rewrites a script a rule matched with confidence, and leaves
23 * everything else exactly as it found it;
24 * - anything third-party it could not classify is reported rather than guessed
25 * at, so an incomplete configuration is visible instead of silent;
26 * - `dry_run` runs the whole pass and rewrites nothing, which is how a site
27 * owner finds out what would break before it breaks.
28 */
29 class Buffer {
30
31 /**
32 * Script types that actually execute. A `application/ld+json` block or an
33 * `text/html` template is not JavaScript and must be left alone — gating a
34 * template would silently change the page.
35 *
36 * @var array
37 */
38 private static $executable_types = [
39 '',
40 'text/javascript',
41 'application/javascript',
42 'text/ecmascript',
43 'application/ecmascript',
44 'module',
45 ];
46
47 public static function init() {
48 if ( ! Helper::is_gating_active() ) {
49 return;
50 }
51 // Two independent reasons to buffer. A site can hold scripts back and
52 // leave its videos alone, or the reverse, so neither switch may speak
53 // for the other.
54 if ( ! Helper::get( 'buffer_gating', true ) && ! Helper::get( 'embed_gating', true ) ) {
55 return;
56 }
57
58 $self = new self();
59 // Started as early as a theme's output can be, so this buffer is the
60 // outermost one and its callback therefore runs on the finished
61 // document — after every inner buffer has flushed into it.
62 add_action( 'template_redirect', [ $self, 'start' ], -9999 );
63 }
64
65 public function start() {
66 if ( is_feed() || is_embed() || is_robots() ) {
67 return;
68 }
69 ob_start( [ $this, 'filter' ] );
70 }
71
72 /**
73 * @param string $html The rendered document.
74 * @return string
75 */
76 public function filter( $html ) {
77 if ( ! is_string( $html ) || '' === $html ) {
78 return $html;
79 }
80 // Cheap bail-outs first: this callback runs on every page.
81 if ( false === stripos( $html, '<script' )
82 && false === stripos( $html, '<iframe' )
83 && false === stripos( $html, '<img' ) ) {
84 return $html;
85 }
86 if ( ! $this->is_html_response() ) {
87 return $html;
88 }
89
90 $dry_run = (bool) Helper::get( 'dry_run', false );
91
92 if ( Helper::get( 'buffer_gating', true ) ) {
93 $filtered = preg_replace_callback(
94 '#<script\b([^>]*)>(.*?)</script\s*>#is',
95 function ( $match ) use ( $dry_run ) {
96 return $this->process( $match, $dry_run );
97 },
98 $html
99 );
100
101 // A backtrack limit or a catastrophic pattern returns null.
102 // Serving the original page ungated is bad; serving an empty page
103 // is worse.
104 $html = null === $filtered ? $html : $filtered;
105 }
106
107 if ( Helper::get( 'embed_gating', true ) ) {
108 $html = $this->gate_elements( $html, $dry_run );
109 }
110
111 return $html;
112 }
113
114 /**
115 * The iframe and pixel pass.
116 *
117 * Runs on everything *between* the script elements rather than on the whole
118 * document. An inline script is perfectly entitled to contain the text
119 * `<img src="…facebook.com/tr…">` — in a template string, in a JSON blob,
120 * in an example — and rewriting it there would not gate a request, it would
121 * corrupt the script. Splitting on script blocks and skipping the captured
122 * halves costs one more pass and removes the whole class of problem.
123 *
124 * @param string $html The document.
125 * @param bool $dry_run Report instead of rewrite.
126 * @return string
127 */
128 private function gate_elements( $html, $dry_run ) {
129 $has_iframe = false !== stripos( $html, '<iframe' );
130 $has_img = false !== stripos( $html, '<img' );
131
132 if ( ! $has_iframe && ! $has_img ) {
133 return $html;
134 }
135
136 $parts = preg_split(
137 '#(<script\b[^>]*>.*?</script\s*>)#is',
138 $html,
139 -1,
140 PREG_SPLIT_DELIM_CAPTURE
141 );
142
143 if ( ! is_array( $parts ) ) {
144 return $html;
145 }
146
147 foreach ( $parts as $index => $part ) {
148 // Odd indices are the captured script blocks themselves.
149 if ( 1 === $index % 2 || '' === $part ) {
150 continue;
151 }
152
153 if ( $has_iframe ) {
154 $done = preg_replace_callback(
155 '#<iframe\b([^>]*)>(.*?)</iframe\s*>#is',
156 function ( $match ) use ( $dry_run ) {
157 return $this->process_embed( $match, $dry_run );
158 },
159 $part
160 );
161 $part = null === $done ? $part : $done;
162 }
163
164 if ( $has_img ) {
165 $done = preg_replace_callback(
166 '#<img\b([^>]*?)/?>#is',
167 function ( $match ) use ( $dry_run ) {
168 return $this->process_pixel( $match, $dry_run );
169 },
170 $part
171 );
172 $part = null === $done ? $part : $done;
173 }
174
175 $parts[ $index ] = $part;
176 }
177
178 return implode( '', $parts );
179 }
180
181 /**
182 * One iframe: leave it, or stand a consent card where it was.
183 *
184 * @param array $match Regex match: 0 whole, 1 attributes, 2 contents.
185 * @param bool $dry_run Report instead of rewrite.
186 * @return string
187 */
188 private function process_embed( $match, $dry_run ) {
189 $whole = $match[0];
190 $attrs = $match[1];
191
192 if ( false !== stripos( $attrs, 'data-ablocks-consent' ) ) {
193 return $whole;
194 }
195
196 $src = $this->attribute( $attrs, 'src' );
197 if ( '' === (string) $src ) {
198 return $whole;
199 }
200
201 $rule = Embeds::match( $src, 'embed' );
202
203 if ( ! $rule ) {
204 // An unrecognised third-party iframe is the same kind of gap as an
205 // unrecognised third-party script, and worth the same report.
206 if ( Gating::is_third_party( $src ) ) {
207 Report::add( $src, '', 'iframe' );
208 }
209 return $whole;
210 }
211
212 if ( $dry_run ) {
213 Report::add( $src, $rule['category'], 'iframe' );
214 return $whole;
215 }
216
217 // An invisible beacon is stripped the way a pixel is. It occupies no
218 // space, so there is no gap to explain and nothing to offer to load.
219 if ( ! empty( $rule['beacon'] ) ) {
220 return $this->strip_source( $whole, 'iframe', $rule['category'] );
221 }
222
223 return Embeds::placeholder( $whole, $rule );
224 }
225
226 /**
227 * One image: leave it, or take its source away.
228 *
229 * No placeholder and no announcement. A tracking pixel is not content the
230 * visitor is missing, and drawing a consent card where a 1×1 beacon used to
231 * be would invent a loss to apologise for.
232 *
233 * @param array $match Regex match: 0 whole, 1 attributes.
234 * @param bool $dry_run Report instead of rewrite.
235 * @return string
236 */
237 private function process_pixel( $match, $dry_run ) {
238 $whole = $match[0];
239 $attrs = $match[1];
240
241 if ( false !== stripos( $attrs, 'data-ablocks-consent' ) ) {
242 return $whole;
243 }
244
245 $src = $this->attribute( $attrs, 'src' );
246 if ( '' === (string) $src ) {
247 return $whole;
248 }
249
250 $rule = Embeds::match( $src, 'pixel' );
251 if ( ! $rule ) {
252 return $whole;
253 }
254
255 if ( $dry_run ) {
256 Report::add( $src, $rule['category'], 'pixel' );
257 return $whole;
258 }
259
260 return $this->strip_source( $whole, 'img', $rule['category'] );
261 }
262
263 /**
264 * Move an element's `src` aside and label it with its category.
265 *
266 * Renaming the attribute is the whole mechanism: a browser does not fetch
267 * `data-ablocks-src`, and the element stays exactly where the author put
268 * it, keeping whatever size and styling it had.
269 *
270 * @param string $tag The whole element.
271 * @param string $name Tag name, `img` or `iframe`.
272 * @param string $category Category slug.
273 * @return string
274 */
275 private function strip_source( $tag, $name, $category ) {
276 $rewritten = preg_replace( '/\ssrc=/i', ' data-ablocks-src=', $tag, 1 );
277
278 // `\b`, not `\s`: the same trap that made `ScriptGate` silently skip a
279 // `<script>` whose only attribute was its type.
280 return preg_replace(
281 '/^<' . $name . '\b/i',
282 sprintf( '<%s data-ablocks-consent="%s" ', $name, esc_attr( $category ) ),
283 $rewritten,
284 1
285 );
286 }
287
288 /**
289 * Decide what to do with one script element.
290 *
291 * @param array $match Regex match: 0 whole, 1 attributes, 2 contents.
292 * @param bool $dry_run Report instead of rewrite.
293 * @return string
294 */
295 private function process( $match, $dry_run ) {
296 $whole = $match[0];
297 $attrs = $match[1];
298 $code = $match[2];
299 $open = '<script ' . ltrim( $attrs ) . '>';
300
301 if ( ScriptGate::is_gated( $open ) ) {
302 return $whole;
303 }
304 // The consent scripts must never gate themselves.
305 if ( false !== stripos( $attrs, 'data-ablocks-consent' ) ) {
306 return $whole;
307 }
308 if ( ! $this->is_executable( $attrs ) ) {
309 return $whole;
310 }
311
312 $src = $this->attribute( $attrs, 'src' );
313
314 if ( $src ) {
315 $category = Gating::match_src( $src );
316 $subject = $src;
317 $source = 'external';
318 } else {
319 $category = Gating::match_inline( $code );
320 $subject = $code;
321 $source = 'inline';
322 }
323
324 if ( '' === $category ) {
325 // Not classified. Report a third-party script so the gap is
326 // visible; leave a first-party or unrecognised inline script alone
327 // and say nothing, because reporting every one is noise.
328 if ( $src && Gating::is_third_party( $src ) ) {
329 Report::add( $src, '', $source );
330 }
331 return $whole;
332 }
333
334 if ( $dry_run ) {
335 Report::add( $subject, $category, $source );
336 return $whole;
337 }
338
339 return ScriptGate::rewrite_tag( $open, 'text/plain', [ 'data-ablocks-consent' => $category ] )
340 . $code
341 . '</script>';
342 }
343
344 /**
345 * Whether this script would run if left alone.
346 *
347 * @param string $attrs Raw attribute string.
348 * @return bool
349 */
350 private function is_executable( $attrs ) {
351 $type = strtolower( trim( (string) $this->attribute( $attrs, 'type' ) ) );
352 return in_array( $type, self::$executable_types, true );
353 }
354
355 /**
356 * Pull one attribute's value out of a raw attribute string.
357 *
358 * @param string $attrs Raw attribute string.
359 * @param string $name Attribute name.
360 * @return string
361 */
362 private function attribute( $attrs, $name ) {
363 if ( preg_match( '/\s' . preg_quote( $name, '/' ) . '\s*=\s*("([^"]*)"|\'([^\']*)\'|([^\s>]+))/i', ' ' . $attrs, $found ) ) {
364 foreach ( [ 2, 3, 4 ] as $group ) {
365 if ( isset( $found[ $group ] ) && '' !== $found[ $group ] ) {
366 return $found[ $group ];
367 }
368 }
369 }
370 return '';
371 }
372
373 /**
374 * Only rewrite documents that are actually HTML. A plugin that hijacks the
375 * request to return XML or a file download still passes through this
376 * buffer, and must come out the other side untouched.
377 *
378 * @return bool
379 */
380 private function is_html_response() {
381 foreach ( headers_list() as $header ) {
382 if ( 0 === stripos( $header, 'content-type:' ) ) {
383 return (bool) stripos( $header, 'text/html' );
384 }
385 }
386 // No explicit header means PHP's default, which is text/html.
387 return true;
388 }
389 }
390