PluginProbe
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder / 2.12.0
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder v2.12.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.12.0, at addons/cookie-consent/buffer.php

199 lines 5.5 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() || ! Helper::get( 'buffer_gating', true ) ) {
49 return;
50 }
51
52 $self = new self();
53 // Started as early as a theme's output can be, so this buffer is the
54 // outermost one and its callback therefore runs on the finished
55 // document — after every inner buffer has flushed into it.
56 add_action( 'template_redirect', [ $self, 'start' ], -9999 );
57 }
58
59 public function start() {
60 if ( is_feed() || is_embed() || is_robots() ) {
61 return;
62 }
63 ob_start( [ $this, 'filter' ] );
64 }
65
66 /**
67 * @param string $html The rendered document.
68 * @return string
69 */
70 public function filter( $html ) {
71 if ( ! is_string( $html ) || '' === $html ) {
72 return $html;
73 }
74 // Cheap bail-outs first: this callback runs on every page.
75 if ( false === stripos( $html, '<script' ) ) {
76 return $html;
77 }
78 if ( ! $this->is_html_response() ) {
79 return $html;
80 }
81
82 $dry_run = (bool) Helper::get( 'dry_run', false );
83
84 $filtered = preg_replace_callback(
85 '#<script\b([^>]*)>(.*?)</script\s*>#is',
86 function ( $match ) use ( $dry_run ) {
87 return $this->process( $match, $dry_run );
88 },
89 $html
90 );
91
92 // A backtrack limit or a catastrophic pattern returns null. Serving the
93 // original page ungated is bad; serving an empty page is worse.
94 return null === $filtered ? $html : $filtered;
95 }
96
97 /**
98 * Decide what to do with one script element.
99 *
100 * @param array $match Regex match: 0 whole, 1 attributes, 2 contents.
101 * @param bool $dry_run Report instead of rewrite.
102 * @return string
103 */
104 private function process( $match, $dry_run ) {
105 $whole = $match[0];
106 $attrs = $match[1];
107 $code = $match[2];
108 $open = '<script ' . ltrim( $attrs ) . '>';
109
110 if ( ScriptGate::is_gated( $open ) ) {
111 return $whole;
112 }
113 // The consent scripts must never gate themselves.
114 if ( false !== stripos( $attrs, 'data-ablocks-consent' ) ) {
115 return $whole;
116 }
117 if ( ! $this->is_executable( $attrs ) ) {
118 return $whole;
119 }
120
121 $src = $this->attribute( $attrs, 'src' );
122
123 if ( $src ) {
124 $category = Gating::match_src( $src );
125 $subject = $src;
126 $source = 'external';
127 } else {
128 $category = Gating::match_inline( $code );
129 $subject = $code;
130 $source = 'inline';
131 }
132
133 if ( '' === $category ) {
134 // Not classified. Report a third-party script so the gap is
135 // visible; leave a first-party or unrecognised inline script alone
136 // and say nothing, because reporting every one is noise.
137 if ( $src && Gating::is_third_party( $src ) ) {
138 Report::add( $src, '', $source );
139 }
140 return $whole;
141 }
142
143 if ( $dry_run ) {
144 Report::add( $subject, $category, $source );
145 return $whole;
146 }
147
148 return ScriptGate::rewrite_tag( $open, 'text/plain', [ 'data-ablocks-consent' => $category ] )
149 . $code
150 . '</script>';
151 }
152
153 /**
154 * Whether this script would run if left alone.
155 *
156 * @param string $attrs Raw attribute string.
157 * @return bool
158 */
159 private function is_executable( $attrs ) {
160 $type = strtolower( trim( (string) $this->attribute( $attrs, 'type' ) ) );
161 return in_array( $type, self::$executable_types, true );
162 }
163
164 /**
165 * Pull one attribute's value out of a raw attribute string.
166 *
167 * @param string $attrs Raw attribute string.
168 * @param string $name Attribute name.
169 * @return string
170 */
171 private function attribute( $attrs, $name ) {
172 if ( preg_match( '/\s' . preg_quote( $name, '/' ) . '\s*=\s*("([^"]*)"|\'([^\']*)\'|([^\s>]+))/i', ' ' . $attrs, $found ) ) {
173 foreach ( [ 2, 3, 4 ] as $group ) {
174 if ( isset( $found[ $group ] ) && '' !== $found[ $group ] ) {
175 return $found[ $group ];
176 }
177 }
178 }
179 return '';
180 }
181
182 /**
183 * Only rewrite documents that are actually HTML. A plugin that hijacks the
184 * request to return XML or a file download still passes through this
185 * buffer, and must come out the other side untouched.
186 *
187 * @return bool
188 */
189 private function is_html_response() {
190 foreach ( headers_list() as $header ) {
191 if ( 0 === stripos( $header, 'content-type:' ) ) {
192 return (bool) stripos( $header, 'text/html' );
193 }
194 }
195 // No explicit header means PHP's default, which is text/html.
196 return true;
197 }
198 }
199