PluginProbe
SureCookie – GDPR Cookie Consent Banner, Cookie Scanner & Script Blocking / trunk
SureCookie – GDPR Cookie Consent Banner, Cookie Scanner & Script Blocking vtrunk
1.5.0 1.4.0 1.3.0 1.3.1 trunk 0.0.0-alpha.1 0.0.0-alpha.2 0.0.0-alpha.3 0.0.1-beta.1 0.0.1-beta.2 0.0.1-beta.3 0.0.1-beta.4 1.0.0 1.1.0 1.2.0 1.2.1 1.2.2 1.2.3 1.2.4
surecookie / inc / modules / script-blocking / dom-guard.php

dom-guard.php in SureCookie – GDPR Cookie Consent Banner, Cookie Scanner & Script Blocking trunk, at inc/modules/script-blocking/dom-guard.php

355 lines 12.6 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * DOM guard injection.
4 *
5 * The tag-level passes in Blocker only ever see the HTML WordPress sent, so a
6 * tracker that a page builder, lazy-loader or tag manager builds in the browser
7 * loads no matter what the visitor chose. This prints a small script at the very
8 * top of `<head>` that intercepts the assignment of a URL to a `<script>` or
9 * `<iframe>` and parks it in the same `data-surecookie-*` shape the PHP blocker
10 * produces, so consentManager.js restores it on accept with no extra plumbing.
11 *
12 * Injected into the buffer rather than hooked onto `wp_head`, because a theme or
13 * plugin can print a tag manager into `<head>` before any hook fires; the only
14 * reliable "first" is the top of the finished document.
15 *
16 * The payload is consent-agnostic (patterns only), so it is safe to serve from a
17 * full-page cache. The guard reads the consent cookie itself at runtime.
18 *
19 * @package SureCookie\Inc\Modules\ScriptBlocking
20 * @since 1.4.0
21 */
22
23 namespace SureCookie\Inc\Modules\ScriptBlocking;
24
25 use SureCookie\Inc\Functions\Settings;
26 use SureCookie\Inc\Modules\Services\Pattern_Kinds;
27 use SureCookie\Inc\Traits\GetInstance;
28
29 if ( ! defined( 'ABSPATH' ) ) {
30 exit; // Exit if accessed directly.
31 }
32
33 /**
34 * Dom_Guard class.
35 *
36 * @since 1.4.0
37 */
38 class Dom_Guard {
39 use GetInstance;
40
41 /**
42 * Cached guard source, read once per request.
43 *
44 * @var string|null
45 */
46 private ?string $source = null;
47
48 /**
49 * Insert the guard at the top of `<head>`.
50 *
51 * @since 1.4.0
52 * @param string $buffer Page HTML.
53 * @return string
54 */
55 public function inject( string $buffer ): string {
56 /**
57 * Filter whether the client-side DOM guard runs.
58 *
59 * The guard wraps the native `src` accessors, so this is the kill switch
60 * for a site where that conflicts with another plugin.
61 *
62 * @since 1.4.0
63 * @param bool $enabled Whether to inject the guard. Default true.
64 */
65 if ( ! apply_filters( 'surecookie_dom_guard_enabled', true ) ) {
66 return $buffer;
67 }
68
69 $patterns = $this->build_patterns();
70 if ( empty( $patterns['s'] ) && empty( $patterns['i'] ) && empty( $patterns['y'] ) ) {
71 return $buffer;
72 }
73
74 $source = $this->source();
75 if ( $source === '' ) {
76 return $buffer;
77 }
78
79 // Match the opening <head> tag with its attributes, so the guard lands
80 // immediately inside it and ahead of every other tag on the page.
81 if ( preg_match( '/<head\b[^>]*>/i', $buffer, $match, PREG_OFFSET_CAPTURE ) !== 1 ) {
82 return $buffer;
83 }
84
85 // Pooling makes the two maps all but identical, and this tag is inlined
86 // into every page, so the wire carries what they share once and leaves
87 // `p`/`f` for the entries that genuinely differ per kind.
88 $shared = array_filter(
89 $patterns['s'],
90 static fn( $entry, $pattern ) => ( $patterns['i'][ $pattern ] ?? null ) === $entry,
91 ARRAY_FILTER_USE_BOTH
92 );
93
94 $config = wp_json_encode(
95 [
96 'a' => $shared,
97 'p' => array_diff_key( $patterns['s'], $shared ),
98 'f' => array_diff_key( $patterns['i'], $shared ),
99 // The link map is `styles` over the pooled script/iframe set, so
100 // only the styles bucket and the tag_scoped exclusions have to
101 // ship: the guard rebuilds the rest from a/p/f. Sending the whole
102 // link map would put every pattern twice into a payload that
103 // rides every page.
104 'y' => $patterns['y'],
105 't' => array_keys( $patterns['t'] ),
106 'e' => Blocking_Surface::skippable_categories(),
107 'm' => (string) Settings::get( 'consent_model' ),
108 'r' => (int) Settings::get( 'consent_renewed_at' ),
109 // Core prefixes plus the host they belong to, so the guard spares
110 // core exactly as Blocker::is_core_asset() does. The host is sent,
111 // not read from `location`: on a domain-mapped install (WPML
112 // domain-per-language) the two would disagree about which is ours.
113 'c' => array_values( array_map( static fn( array $b ): string => $b[1], Blocker::core_bases() ) ),
114 'h' => (string) ( Blocker::core_bases()[0][0] ?? '' ),
115 ],
116 // HEX_TAG keeps a pattern from closing the script tag; slashes stay
117 // unescaped because every pattern is a URL fragment and `\/` would
118 // add roughly half a kilobyte to every page for nothing.
119 JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_UNESCAPED_SLASHES
120 );
121
122 if ( $config === false ) {
123 return $buffer;
124 }
125
126 // data-cfasync stops Cloudflare Rocket Loader from deferring the guard,
127 // which would put it behind the scripts it exists to intercept.
128 $tag = '<script data-cfasync="false" data-surecookie-guard="1">'
129 . 'window.surecookieGuard=' . $config . ';'
130 . $source
131 . '</script>';
132
133 $offset = $match[0][1] + strlen( $match[0][0] );
134
135 return substr( $buffer, 0, $offset ) . $tag . substr( $buffer, $offset );
136 }
137
138 /**
139 * Flatten the blocking catalog into `pattern => [ category, service ]`, one
140 * map per element type.
141 *
142 * Pools a service's `scripts` and `iframes` patterns exactly as the tag
143 * passes do, so the two layers agree: a pattern names a third-party host, and
144 * a vendor shipping both an embed and a JS API is the same connection either
145 * way. Own kind wins a collision, so pooling only adds coverage. A producer
146 * that meant its arrays literally sets `tag_scoped`.
147 *
148 * Reads the same `surecookie_known_scripts` view the tag passes block from,
149 * and applies the same per-resource decisions on top: a resource the admin
150 * excluded is dropped, and a category override wins over the catalog's. Skip
151 * either and the guard would contradict the tag passes - blocking something
152 * the admin allowed, or holding a resource under a category the visitor has
153 * already consented to, which consentManager would then restore and the
154 * guard would immediately park again.
155 *
156 * @since 1.4.0
157 * @return array{s: array<string, array{0: string, 1: string}>, i: array<string, array{0: string, 1: string}>, y: array<string, array{0: string, 1: string}>, t: array<string, bool>}
158 */
159 private function build_patterns(): array {
160 $catalog = apply_filters( 'surecookie_known_scripts', [] );
161
162 if ( ! is_array( $catalog ) ) {
163 return [
164 's' => [],
165 'i' => [],
166 'y' => [],
167 't' => [],
168 ];
169 }
170
171 return [
172 's' => $this->flatten_for( $catalog, 'script', 'scripts' ),
173 'i' => $this->flatten_for( $catalog, 'iframe', 'iframes' ),
174 // Scoped as a script: a stylesheet row carries the script kind, so
175 // its exclusion and override are keyed that way. Own bucket, so
176 // un-pooled - a `<link rel=stylesheet>` IS where a styles pattern
177 // is observed. Mirrors Blocker::get_link_patterns().
178 'y' => $this->flatten_patterns( $catalog, 'script', 'styles', false ),
179 // A tag_scoped rule means its arrays literally, so it must not reach
180 // the link map at all; the guard drops these from the pooled set.
181 't' => $this->tag_scoped_patterns( $catalog ),
182 ];
183 }
184
185 /**
186 * Build one element type's map, mirroring `Blocker::build_patterns()`: the
187 * own bucket wins, the pooled one only fills patterns it did not declare.
188 * The two have to agree down to the service name, or the guard parks under a
189 * label the server pass never used.
190 *
191 * @since 1.5.0
192 * @param array<string, mixed> $catalog Known-scripts view.
193 * @param string $kind Resource kind Resource_Categories scopes by ('script'|'iframe').
194 * @param string $own Catalog key this element type declares under.
195 * @return array<string, array{0: string, 1: string}>
196 */
197 /**
198 * Patterns whose producer meant its arrays literally, as a set.
199 *
200 * The link map pools scripts and iframes cross-kind, and a `tag_scoped`
201 * rule opts out of pooling - so an admin rule scoped to "Script" must not
202 * gate a `<link>`. The server drops these in `flatten_patterns()`; the
203 * guard rebuilds the pooled set from a/p/f and so needs them by name.
204 *
205 * @since 1.5.0
206 * @param array<string, mixed> $catalog Known-scripts view.
207 * @return array<string, bool>
208 */
209 private function tag_scoped_patterns( array $catalog ): array {
210 $scoped = [];
211
212 foreach ( $catalog as $services ) {
213 if ( ! is_array( $services ) ) {
214 continue;
215 }
216
217 foreach ( $services as $service ) {
218 if ( ! is_array( $service ) || empty( $service['tag_scoped'] ) ) {
219 continue;
220 }
221
222 foreach ( Pattern_Kinds::buckets() as $bucket ) {
223 foreach ( (array) ( $service[ $bucket ] ?? [] ) as $pattern ) {
224 $pattern = (string) $pattern;
225 if ( $pattern !== '' ) {
226 $scoped[ $pattern ] = true;
227 }
228 }
229 }
230 }
231 }
232
233 return $scoped;
234 }
235
236 /**
237 * Build one element type's map: the own bucket wins, the pooled one only
238 * fills patterns it did not declare.
239 *
240 * @since 1.5.0
241 * @param array<string, mixed> $catalog Known-scripts view.
242 * @param string $kind Resource kind Resource_Categories scopes by ('script'|'iframe').
243 * @param string $own Catalog key this element type declares under.
244 * @return array<string, array{0: string, 1: string}>
245 */
246 private function flatten_for( array $catalog, string $kind, string $own ): array {
247 $cross = $own === 'scripts' ? 'iframes' : 'scripts';
248
249 return $this->flatten_patterns( $catalog, $kind, $own, false )
250 + $this->flatten_patterns( $catalog, $kind, $cross, true );
251 }
252
253 /**
254 * Collect one bucket of the catalog into `pattern => [ category, service ]`,
255 * with the admin's per-resource decisions for `$kind` already applied.
256 *
257 * @since 1.5.0
258 * @param array<string, mixed> $catalog Known-scripts view.
259 * @param string $kind Resource kind ('script'|'iframe').
260 * @param string $bucket Catalog key to read ('scripts'|'iframes').
261 * @param bool $cross_kind Whether this is the pooled pass, which a
262 * `tag_scoped` producer opts out of.
263 * @return array<string, array{0: string, 1: string}>
264 */
265 private function flatten_patterns( array $catalog, string $kind, string $bucket, bool $cross_kind ): array {
266 $patterns = [];
267
268 foreach ( $catalog as $category => $services ) {
269 if ( ! is_array( $services ) ) {
270 continue;
271 }
272
273 foreach ( $services as $service_key => $service ) {
274 if ( ! is_array( $service ) || ! is_array( $service[ $bucket ] ?? null ) ) {
275 continue;
276 }
277
278 // `path` narrows a rule to resources that also contain that
279 // fragment. The guard matches on a single substring, so a
280 // narrowed rule is skipped rather than over-blocking its host.
281 if ( ! empty( $service['path'] ) ) {
282 continue;
283 }
284
285 // `tag_scoped` means the producer meant its arrays literally -
286 // the admin's script/iframe rule type is what sets it.
287 if ( $cross_kind && ! empty( $service['tag_scoped'] ) ) {
288 continue;
289 }
290
291 foreach ( $service[ $bucket ] as $pattern ) {
292 $pattern = (string) $pattern;
293 if ( $pattern === '' || Resource_Categories::matches_excluded_src( $pattern, $kind ) ) {
294 continue;
295 }
296
297 /**
298 * Filter: leave a pattern out of the browser guard's map.
299 *
300 * The guard exists to catch what the server pass cannot see,
301 * so anything the server is going to let through has to be
302 * dropped here too or the two layers disagree - which is how
303 * an always-allowed resource ended up loading in the page and
304 * still being intercepted in the browser. Pro uses this for
305 * its whitelist; the free exclusion is handled above.
306 *
307 * The subject here is the catalog PATTERN, not a URL, so an
308 * entry narrower than the pattern cannot drop it: that one
309 * resource keeps its browser-side placeholder rather than the
310 * whole service being released, which is the safe direction.
311 * Closing that gap needs per-URL evaluation in the guard.
312 *
313 * @since 1.5.0
314 * @param bool $skip Whether to omit this pattern.
315 * @param string $pattern Blocking pattern.
316 * @param string $kind Resource kind ('script'|'iframe').
317 */
318 if ( apply_filters( 'surecookie_guard_skip_pattern', false, $pattern, $kind ) ) {
319 continue;
320 }
321
322 // The pattern carries the host the override keys match on.
323 $patterns[ $pattern ] = [
324 Resource_Categories::resolve( $pattern, (string) $category, $kind ),
325 (string) $service_key,
326 ];
327 }
328 }
329 }
330
331 return $patterns;
332 }
333
334 /**
335 * The guard's built (minified) source.
336 *
337 * @since 1.4.0
338 * @return string
339 */
340 private function source(): string {
341 if ( $this->source !== null ) {
342 return $this->source;
343 }
344
345 $path = SURECOOKIE_DIR . 'build/dom-guard.js';
346
347 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reading a bundled plugin asset, not a remote resource.
348 $contents = is_readable( $path ) ? file_get_contents( $path ) : false;
349
350 $this->source = is_string( $contents ) ? trim( $contents ) : '';
351
352 return $this->source;
353 }
354 }
355