PluginProbe
Optimole – Optimize Images | Convert WebP & AVIF | CDN & Lazy Load | Image Optimization / trunk
Optimole – Optimize Images | Convert WebP & AVIF | CDN & Lazy Load | Image Optimization vtrunk
4.2.15 4.2.14 4.2.13 4.2.12 4.2.11 4.2.10 4.2.9 4.2.8 4.2.7 4.2.6 4.2.5 2.5.5 2.5.6 2.5.7 3.0.0 3.0.1 3.1.0 3.1.1 3.1.2 3.1.3 3.10.0 3.11.0 3.11.1 3.11.2 3.11.3 All 136 releases
← All changes | inc/v2/BgOptimizer/Lazyload.php +86 -28 4.2.6 → trunk View file →
@@ -13,8 +13,27 @@
13 13 */
14 14 class Lazyload {
15 15 const MARKER = '/* OPTML_VIEWPORT_BG_SELECTORS */';
16 16 /**
17 + * Shape of a plain selector the frontend getUniqueSelector() reports: ids, tags, classes, ' > ' and :nth-of-type(N).
18 + */
19 + const SAFE_SELECTOR_PATTERN = '/^(?:[\w\-#. >\x{00A0}-\x{10FFFF}]|:nth-of-type\(\d+\))+$/u';
20 +
21 + /**
22 + * Check whether a client-reported selector is a plain selector safe to embed in the stylesheet as-is.
23 + *
24 + * A selector carrying CSS metacharacters (`(`, `)`, `{`, `;`, `:` beyond nth-of-type, `/`, …) is not
25 + * embedded: the browser already discards the whole rule when it sees one, so treating it as unsafe
26 + * keeps the current behaviour while making CSS injection impossible.
27 + *
28 + * @param mixed $selector The selector to check.
29 + *
30 + * @return bool Whether the selector matches the plain-selector shape.
31 + */
32 + public static function is_safe_selector( $selector ): bool {
33 + return is_string( $selector ) && $selector !== '' && preg_match( self::SAFE_SELECTOR_PATTERN, $selector ) === 1;
34 + }
35 + /**
17 36 * Get the current personalized CSS for lazy loading.
18 37 *
19 38 * @return string The personalized CSS.
20 39 */
@@ -32,43 +51,28 @@
32 51 $lazyload_selectors = array_values( Optml_Lazyload_Replacer::get_background_lazyload_selectors() );
33 52 $lazyload_selectors = array_fill_keys( $lazyload_selectors, true );
34 53 $css_selectors = [];
35 54 $preload_urls = [];
55 + $tainted = [];
36 56 foreach ( Profile::get_active_devices() as $device ) {
37 57 $personalized_selectors = $data[ $device ]['bg'] ?? [];
38 58 $lcp_data = $data[ $device ]['lcp'] ?? [];
59 + // Guard against malformed profile shapes (e.g. a non-normalizing custom storage backend).
60 + $personalized_selectors = is_array( $personalized_selectors ) ? $personalized_selectors : [];
61 + $lcp_data = is_array( $lcp_data ) ? $lcp_data : [];
39 62 if ( OPTML_DEBUG ) {
40 63 do_action( 'optml_log', 'personalized_selectors: ' . $device . ' ' . print_r( $personalized_selectors, true ) );
41 64 do_action( 'optml_log', 'LCP data: ' . $device . ' ' . print_r( $lcp_data, true ) );
42 65 }
43 - $css_selectors[ $device ] = [];
44 - foreach ( $personalized_selectors as $selector => $above_fold_selectors ) {
45 - if ( ! isset( $lazyload_selectors[ $selector ] ) ) {
46 - continue;
47 - }
48 - if ( empty( $above_fold_selectors ) ) {
49 - $css_selectors[ $device ][] = 'html ' . strip_tags( $selector ) . ':not(.optml-bg-lazyloaded)';
50 - } else {
51 - foreach ( $above_fold_selectors as $above_fold_selector => $bg_urls ) {
52 - $css_selectors[ $device ][] = 'html ' . strip_tags( $selector ) . ':not(' . strip_tags( $above_fold_selector ) . '):not(.optml-bg-lazyloaded)';
53 - }
54 - }
55 - }
56 66
67 + $selectors = self::collect_device_selectors( $personalized_selectors, $lcp_data, $lazyload_selectors );
68 + // null means a client selector was unsafe, so the whole device rule is voided.
69 + $tainted[ $device ] = null === $selectors;
70 + $css_selectors[ $device ] = $tainted[ $device ] ? [] : $selectors;
71 +
57 72 $preload_urls[ $device ] = [];
58 - $css_selectors[ $device ] = array_unique( $css_selectors[ $device ] );
59 - if ( isset( $lcp_data['type'] ) && $lcp_data['type'] === 'bg' ) {
60 - if ( ! empty( $lcp_data['bgSelector'] ) ) {
61 - $css_selectors[ $device ] = array_map(
62 - function ( $selector ) use ( $lcp_data ) {
63 - return $selector . ':not(' . strip_tags( $lcp_data['bgSelector'] ) . ')';
64 - },
65 - $css_selectors[ $device ]
66 - );
67 - }
68 - if ( ! empty( $lcp_data['bgUrls'] ) ) {
69 - $preload_urls[ $device ] = array_merge( $preload_urls[ $device ], $lcp_data['bgUrls'] );
70 - }
73 + if ( isset( $lcp_data['type'] ) && $lcp_data['type'] === 'bg' && ! empty( $lcp_data['bgUrls'] ) ) {
74 + $preload_urls[ $device ] = array_merge( $preload_urls[ $device ], $lcp_data['bgUrls'] );
71 75 }
72 76 }
73 77 if ( OPTML_DEBUG ) {
74 78 do_action( 'optml_log', 'BGCSS selectors: ' . print_r( $css_selectors, true ) );
@@ -79,11 +83,22 @@
79 83 Links::add_link( [ 'url' => $url, 'priority' => 'high' ] );
80 84 }
81 85
82 86 $hide_rule = ' { background-image: none !important; }';
83 - $mobile_selectors = implode( ',', $css_selectors[ Profile::DEVICE_TYPE_MOBILE ] );
84 - $desktop_selectors = implode( ',', $css_selectors[ Profile::DEVICE_TYPE_DESKTOP ] );
87 + $mobile = $css_selectors[ Profile::DEVICE_TYPE_MOBILE ];
88 + $desktop = $css_selectors[ Profile::DEVICE_TYPE_DESKTOP ];
85 89
90 + // Keep the surviving rule scoped when the other device was voided.
91 + if ( $tainted[ Profile::DEVICE_TYPE_MOBILE ] !== $tainted[ Profile::DEVICE_TYPE_DESKTOP ] ) {
92 + if ( $tainted[ Profile::DEVICE_TYPE_MOBILE ] ) {
93 + return empty( $desktop ) ? '' : '@media (min-width: 600px) { ' . implode( ',', $desktop ) . $hide_rule . ' }';
94 + }
95 + return empty( $mobile ) ? '' : '@media (max-width: 600px) { ' . implode( ',', $mobile ) . $hide_rule . ' }';
96 + }
97 +
98 + $mobile_selectors = implode( ',', $mobile );
99 + $desktop_selectors = implode( ',', $desktop );
100 +
86 101 if ( $mobile_selectors === $desktop_selectors ) {
87 102 return empty( $mobile_selectors ) ? '' : $mobile_selectors . $hide_rule;
88 103 }
89 104 // if any of those are empty, return the other one
@@ -96,6 +111,49 @@
96 111
97 112 // generate media query for desktop and mobile
98 113 $media_query = '@media (max-width: 600px) { ' . $mobile_selectors . $hide_rule . ' } @media (min-width: 600px) { ' . $desktop_selectors . $hide_rule . ' }';
99 114 return $media_query;
115 + }
116 + /**
117 + * Build the background-hide selectors for one device.
118 + *
119 + * @param array<string, array<string, mixed>> $personalized_selectors Stored bg data: watcher => [ above-fold selector => urls ].
120 + * @param array<string, mixed> $lcp_data Stored LCP data for the device.
121 + * @param array<string, bool> $lazyload_selectors Allowed watcher selectors as keys.
122 + *
123 + * @return array<int, string>|null Selector strings, or null when a client selector is unsafe (the device rule is voided).
124 + */
125 + private static function collect_device_selectors( array $personalized_selectors, array $lcp_data, array $lazyload_selectors ) {
126 + $css_selectors = [];
127 + foreach ( $personalized_selectors as $selector => $above_fold_selectors ) {
128 + if ( ! isset( $lazyload_selectors[ $selector ] ) ) {
129 + continue;
130 + }
131 + if ( empty( $above_fold_selectors ) ) {
132 + $css_selectors[] = 'html ' . strip_tags( $selector ) . ':not(.optml-bg-lazyloaded)';
133 + continue;
134 + }
135 + foreach ( $above_fold_selectors as $above_fold_selector => $bg_urls ) {
136 + if ( ! self::is_safe_selector( $above_fold_selector ) ) {
137 + return null;
138 + }
139 + $css_selectors[] = 'html ' . strip_tags( $selector ) . ':not(' . $above_fold_selector . '):not(.optml-bg-lazyloaded)';
140 + }
141 + }
142 +
143 + $css_selectors = array_unique( $css_selectors );
144 +
145 + if ( isset( $lcp_data['type'] ) && $lcp_data['type'] === 'bg' && ! empty( $lcp_data['bgSelector'] ) ) {
146 + if ( ! self::is_safe_selector( $lcp_data['bgSelector'] ) ) {
147 + return null;
148 + }
149 + $css_selectors = array_map(
150 + function ( $selector ) use ( $lcp_data ) {
151 + return $selector . ':not(' . $lcp_data['bgSelector'] . ')';
152 + },
153 + $css_selectors
154 + );
155 + }
156 +
157 + return array_values( $css_selectors );
100 158 }
101 159 }