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 / services / dataset-validator.php

dataset-validator.php in SureCookie – GDPR Cookie Consent Banner, Cookie Scanner & Script Blocking trunk, at inc/modules/services/dataset-validator.php

299 lines 9.2 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Dataset Validator.
4 *
5 * Coercive validators for the datasets the plugin consumes. The live remote
6 * dataset is the unified services.json (validate_services); the blocking-scripts
7 * / service-cookies validators are retained for validating the bundled legacy
8 * floors. Every validator follows a drop-bad-keep-good policy: a single
9 * malformed entry is dropped, never the whole payload, so a partially-corrupt
10 * remote response can still contribute its valid rows over the bundled floor.
11 *
12 * @package SureCookie\Inc\Modules\Services
13 * @since 1.2.5
14 */
15
16 namespace SureCookie\Inc\Modules\Services;
17
18 use SureCookie\Inc\Functions\Get;
19 use SureCookie\Inc\Functions\Sanitize;
20 use SureCookie\Inc\Traits\IpManager;
21 use SureCookie\Inc\Utils\Logger;
22
23 if ( ! defined( 'ABSPATH' ) ) {
24 exit; // Exit if accessed directly.
25 }
26
27 /**
28 * Dataset_Validator
29 *
30 * Static, coercive validation for remote-served datasets.
31 *
32 * @since 1.2.5
33 */
34 class Dataset_Validator {
35 // Reuse the shared local/dev host detection (is_local_url) instead of
36 // duplicating it - see is_allowed_url().
37 use IpManager;
38
39 /**
40 * Maximum number of services accepted from a service-cookies payload.
41 *
42 * A cap belongs here, but it is a guard against a malformed payload, not a
43 * product limit: the catalog is expected to keep growing and was already at
44 * 159. Passing it truncated the payload silently, in whatever order the JSON
45 * happened to be in, so services would simply stop being blocked with no
46 * signal anywhere.
47 *
48 * @since 1.2.5
49 */
50 private const MAX_SERVICES = 1000;
51
52 /**
53 * Maximum number of cookies accepted per service.
54 *
55 * @since 1.2.5
56 */
57 private const MAX_COOKIES_PER_SERVICE = 50;
58
59 /**
60 * Validate a unified services dataset (service slug => {label, category,
61 * gcm_compatible?, patterns keyed by Pattern_Kinds bucket, cookies:[...]}).
62 *
63 * Coercive (drop-bad-keep-good): the `_meta` key is dropped, non-slug keys are
64 * skipped, the service-level `category` is coerced to a valid consent key
65 * (falling back to `uncategorized`), patterns are reduced to non-empty strings,
66 * each cookie is normalised via validate_cookie() so its OWN category is
67 * preserved (never inherited from the service), and a service that ends up with
68 * neither patterns nor cookies is dropped. Counts are capped.
69 *
70 * @param array<string, mixed> $raw Raw decoded payload.
71 * @since 1.3.0
72 * @return array<string, array<string, mixed>> Validated unified catalog.
73 */
74 public static function validate_services( array $raw ): array {
75 unset( $raw['_meta'] );
76
77 $valid_categories = Get::default_cookie_categories_keys();
78 $out = [];
79 $service_count = 0;
80
81 foreach ( $raw as $slug => $service ) {
82 if ( $service_count >= self::MAX_SERVICES ) {
83 self::log_truncation( count( $raw ) );
84 break;
85 }
86
87 if ( ! is_string( $slug ) || preg_match( '/^[a-z0-9-]+$/', $slug ) !== 1 ) {
88 continue;
89 }
90
91 if ( ! is_array( $service ) ) {
92 continue;
93 }
94
95 $patterns = [];
96 $has_pattern = false;
97
98 foreach ( Pattern_Kinds::buckets() as $bucket ) {
99 $patterns[ $bucket ] = self::clean_pattern_list( $service['patterns'][ $bucket ] ?? [] );
100 $has_pattern = $has_pattern || $patterns[ $bucket ] !== [];
101 }
102
103 $clean_cookies = [];
104 $cookie_count = 0;
105 foreach ( (array) ( $service['cookies'] ?? [] ) as $cookie ) {
106 if ( $cookie_count >= self::MAX_COOKIES_PER_SERVICE ) {
107 break;
108 }
109 $clean = self::validate_cookie( $cookie, $valid_categories );
110 if ( $clean === null ) {
111 continue;
112 }
113 $clean_cookies[] = $clean;
114 ++$cookie_count;
115 }
116
117 // A service with neither patterns nor cookies contributes nothing.
118 if ( ! $has_pattern && empty( $clean_cookies ) ) {
119 continue;
120 }
121
122 $category = isset( $service['category'] ) ? (string) $service['category'] : 'uncategorized';
123 if ( ! in_array( $category, $valid_categories, true ) ) {
124 $category = 'uncategorized';
125 }
126
127 $entry = [
128 'label' => isset( $service['label'] ) ? Sanitize::text( $service['label'] ) : $slug,
129 'description' => isset( $service['description'] ) ? Sanitize::text( $service['description'] ) : '',
130 'category' => $category,
131 // Known Services tiering flag (drives the plugin's Add gate only;
132 // blocking ignores it). Default false so an unknown/legacy payload
133 // treats a service as free rather than surprise-locking it.
134 'pro' => ! empty( $service['pro'] ),
135 'gcm_compatible' => ! empty( $service['gcm_compatible'] ),
136 'patterns' => $patterns,
137 'cookies' => $clean_cookies,
138 ];
139
140 $out[ $slug ] = $entry;
141 ++$service_count;
142 }
143
144 return $out;
145 }
146
147 /**
148 * Whether a URL may be fetched: HTTPS is always allowed; HTTP only for a
149 * genuine local/dev host, so a compromised or misconfigured endpoint cannot
150 * downgrade a production fetch to plain HTTP.
151 *
152 * The local-host test is is_local_host() below - deliberately NOT
153 * IpManager::is_local_url(), which short-circuits to false whenever
154 * SURECOOKIE_ALLOW_LOCAL_SCAN (or the surecookie_bypass_local_url_check
155 * filter) is set. Those flags govern whether to SCAN a local site, not the
156 * dataset transport; reusing is_local_url() here meant enabling local
157 * scanning wrongly rejected an http:// local agent URL and silently pinned
158 * the plugin to the bundled floor.
159 *
160 * @param string $url URL to test.
161 * @since 1.2.5
162 * @return bool
163 */
164 public static function is_allowed_url( string $url ): bool {
165 $scheme = wp_parse_url( $url, PHP_URL_SCHEME );
166 $scheme = is_string( $scheme ) ? strtolower( $scheme ) : '';
167
168 if ( $scheme === 'https' ) {
169 return true;
170 }
171
172 return $scheme === 'http' && self::is_local_host( $url );
173 }
174
175 /**
176 * Whether a host is a genuine local/dev host: a loopback or private IP,
177 * "localhost", or a *.localhost / *.local suffix. Unlike
178 * IpManager::is_local_url(), this does NOT consult SURECOOKIE_ALLOW_LOCAL_SCAN
179 * or the surecookie_bypass_local_url_check filter (see is_allowed_url()).
180 *
181 * @param string $url URL to test.
182 * @since 1.3.0
183 * @return bool
184 */
185 private static function is_local_host( string $url ): bool {
186 $host = wp_parse_url( $url, PHP_URL_HOST );
187 if ( empty( $host ) || ! is_string( $host ) ) {
188 return false;
189 }
190
191 // Normalize: lowercase and strip IPv6 brackets so "[::1]" matches "::1".
192 $host = strtolower( trim( $host, '[]' ) );
193
194 if ( filter_var( $host, FILTER_VALIDATE_IP ) ) {
195 return inet_pton( $host ) === inet_pton( '::1' )
196 || strpos( $host, '127.' ) === 0
197 || self::is_private_ip( $host );
198 }
199
200 if ( $host === 'localhost' ) {
201 return true;
202 }
203
204 foreach ( [ '.localhost', '.local' ] as $suffix ) {
205 if ( substr( $host, -strlen( $suffix ) ) === $suffix ) {
206 return true;
207 }
208 }
209
210 return false;
211 }
212
213 /**
214 * Validate and normalise a single cookie row.
215 *
216 * @param mixed $cookie Raw cookie row.
217 * @param array<int, string> $valid_categories Allowed category ids.
218 * @since 1.2.5
219 * @return array<string, mixed>|null Normalised cookie, or null when dropped.
220 */
221 private static function validate_cookie( $cookie, array $valid_categories ): ?array {
222 if ( ! is_array( $cookie ) ) {
223 return null;
224 }
225
226 $name = isset( $cookie['name'] ) && is_string( $cookie['name'] ) ? trim( $cookie['name'] ) : '';
227 if ( $name === '' ) {
228 return null;
229 }
230
231 $category = isset( $cookie['category'] ) ? (string) $cookie['category'] : 'uncategorized';
232 if ( ! in_array( $category, $valid_categories, true ) ) {
233 $category = 'uncategorized';
234 }
235
236 return [
237 'name' => $name,
238 'domain' => Sanitize::cookie_domain( $cookie['domain'] ?? '' ),
239 'duration_days' => absint( $cookie['duration_days'] ?? 0 ),
240 'category' => $category,
241 'provider' => Sanitize::text( $cookie['provider'] ?? '' ),
242 'purpose' => sanitize_textarea_field( (string) ( $cookie['purpose'] ?? '' ) ),
243 'description' => sanitize_textarea_field( (string) ( $cookie['description'] ?? '' ) ),
244 ];
245 }
246
247 /**
248 * Say so when a payload is truncated, instead of dropping services silently.
249 *
250 * Silent truncation looks exactly like a service that was never in the
251 * catalog: it stops being blocked and nothing anywhere says why.
252 *
253 * @since 1.5.0
254 * @param int $received How many services the payload carried.
255 * @return void
256 */
257 private static function log_truncation( int $received ): void {
258 $message = sprintf(
259 'SureCookie: services dataset truncated at %d of %d entries. Blocking patterns beyond the cap were dropped.',
260 self::MAX_SERVICES,
261 $received
262 );
263
264 // save_log() too: log() only writes in development mode, and dropping
265 // blocking patterns is precisely what a production site must be told.
266 $logger = Logger::get_instance();
267 $logger->log( $message, 'warning' );
268 $logger->save_log( $message );
269 }
270
271 /**
272 * Reduce a raw pattern list to trimmed, non-empty strings.
273 *
274 * @param mixed $list Raw pattern list.
275 * @since 1.2.5
276 * @return array<int, string>
277 */
278 private static function clean_pattern_list( $list ): array {
279 if ( ! is_array( $list ) ) {
280 return [];
281 }
282
283 $out = [];
284 foreach ( $list as $item ) {
285 if ( ! is_string( $item ) ) {
286 continue;
287 }
288
289 $item = trim( $item );
290 if ( $item !== '' ) {
291 $out[] = $item;
292 }
293 }
294
295 return $out;
296 }
297
298 }
299