PluginProbe
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder / 2.14.0
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder v2.14.0
2.14.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 All 81 releases
ablocks / addons / cookie-consent / ajax.php

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

394 lines 14.8 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 /**
9 * Admin-side plumbing for the settings screen.
10 *
11 * The dashboard app already speaks admin-ajax with a shared nonce, so this
12 * follows that rather than introducing a second convention for one screen.
13 */
14 class Ajax {
15
16 public static function init() {
17 $self = new self();
18 add_action( 'wp_ajax_ablocks/cookie_consent/get_settings', [ $self, 'get_settings' ] );
19 add_action( 'wp_ajax_ablocks/cookie_consent/save_settings', [ $self, 'save_settings' ] );
20 add_action( 'wp_ajax_ablocks/cookie_consent/get_report', [ $self, 'get_report' ] );
21 add_action( 'wp_ajax_ablocks/cookie_consent/clear_report', [ $self, 'clear_report' ] );
22 add_action( 'wp_ajax_ablocks/cookie_consent/get_records', [ $self, 'get_records' ] );
23 add_action( 'wp_ajax_ablocks/cookie_consent/delete_records', [ $self, 'delete_records' ] );
24 }
25
26 /**
27 * Every handler calls this first.
28 *
29 * The nonce sniff cannot see through a helper, so the `$_POST` reads below
30 * are flagged despite always running after `check_ajax_referer`.
31 *
32 * phpcs:disable WordPress.Security.NonceVerification.Missing
33 */
34 private function guard() {
35 check_ajax_referer( 'ablocks_nonce', 'security' );
36 if ( ! current_user_can( 'manage_options' ) ) {
37 wp_send_json_error( __( 'You are not allowed to do that.', 'ablocks' ), 403 );
38 }
39 }
40
41 public function get_settings() {
42 $this->guard();
43 wp_send_json_success( Helper::get_settings() );
44 }
45
46 public function save_settings() {
47 $this->guard();
48
49 $defaults = Helper::defaults();
50 $saved = [];
51
52 foreach ( [ 'enabled', 'buffer_gating', 'embed_gating', 'dry_run', 'consent_mode', 'consent_mode_ads', 'record_enabled', 'record_ip', 'hide_for_admins' ] as $key ) {
53 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
54 $saved[ $key ] = isset( $_POST[ $key ] ) ? \ABlocks\Helper::sanitize_checkbox_field( $_POST[ $key ] ) : $defaults[ $key ];
55 }
56
57 foreach ( [ 'policy_version', 'cookie_days', 'reconsent_days', 'consent_mode_wait', 'record_retention_days' ] as $key ) {
58 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash
59 $saved[ $key ] = isset( $_POST[ $key ] ) ? absint( $_POST[ $key ] ) : $defaults[ $key ];
60 }
61
62 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash
63 $mode = isset( $_POST['mode'] ) ? sanitize_key( $_POST['mode'] ) : $defaults['mode'];
64 $saved['mode'] = in_array( $mode, [ 'optin', 'notice' ], true ) ? $mode : 'optin';
65
66 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash
67 // Measurement IDs. Kept to the characters Google and Meta issue, so a
68 // pasted snippet or a stray quote cannot reach the head; the printer
69 // checks the full shape again before writing anything.
70 foreach ( [ 'tag_gtm', 'tag_ga4', 'tag_meta_pixel' ] as $key ) {
71 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash
72 $value = isset( $_POST[ $key ] ) ? sanitize_text_field( wp_unslash( $_POST[ $key ] ) ) : '';
73 $saved[ $key ] = preg_replace( '/[^A-Za-z0-9\-]/', '', $value );
74 }
75
76 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash
77 $mode = isset( $_POST['cookie_name_mode'] ) ? sanitize_key( $_POST['cookie_name_mode'] ) : '';
78 $saved['cookie_name_mode'] = 'custom' === $mode ? 'custom' : 'default';
79
80 // On 'default' the field is not even shown, so whatever the browser
81 // last sent for it is ignored rather than trusted.
82 if ( 'custom' === $saved['cookie_name_mode'] ) {
83 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash
84 $cookie_name = isset( $_POST['cookie_name'] ) ? sanitize_text_field( wp_unslash( $_POST['cookie_name'] ) ) : '';
85 $cookie_name = preg_replace( '/[^A-Za-z0-9_\-]/', '', $cookie_name );
86 $saved['cookie_name'] = $cookie_name ? $cookie_name : $defaults['cookie_name'];
87 } else {
88 $saved['cookie_name'] = $defaults['cookie_name'];
89 }
90
91 // Renaming the cookie would otherwise make every decision already given
92 // unreadable — the banner would come back for the whole audience and
93 // the old cookie would sit in their browser until its own expiry.
94 // Remembering the previous name lets the client carry the decision
95 // across once. One hop only: rename twice before visitors return and
96 // the older one is genuinely gone.
97 $current = Helper::get( 'cookie_name', $defaults['cookie_name'] );
98 $saved['cookie_name_previous'] = $saved['cookie_name'] === $current
99 ? Helper::get( 'cookie_name_previous', '' )
100 : $current;
101
102 $saved['categories'] = $this->sanitize_categories( $this->post_json( 'categories' ), $defaults['categories'] );
103 $saved['rules'] = $this->sanitize_rules( $this->post_json( 'rules' ) );
104
105 // Embeds carry no pattern: the shipped list is the whole list and only
106 // the two decisions the screen offers can come back from the browser.
107 // Anything else in the payload is dropped rather than merged, so a
108 // crafted request cannot introduce a matcher of its own.
109 $saved['embed_rules'] = $this->sanitize_provider_rules( $this->post_json( 'embed_rules' ) );
110 $saved['pixel_rules'] = $this->sanitize_provider_rules( $this->post_json( 'pixel_rules' ) );
111 $saved['banner'] = $this->sanitize_banner( $this->post_json( 'banner' ), $defaults['banner'] );
112
113 Helper::save_settings( $saved );
114
115 // A table that was never created — the addon enabled before this
116 // version shipped, say — is created on first save rather than leaving
117 // recording silently broken.
118 if ( $saved['record_enabled'] && Helper::can( 'records' ) && ! Database::table_exists() ) {
119 Database::create_table();
120 }
121
122 wp_send_json_success( Helper::get_settings() );
123 }
124
125 public function get_report() {
126 $this->guard();
127 wp_send_json_success(
128 [
129 'entries' => Report::get(),
130 'dry_run' => (bool) Helper::get( 'dry_run', false ),
131 ]
132 );
133 }
134
135 public function clear_report() {
136 $this->guard();
137 Report::clear();
138 wp_send_json_success( [ 'entries' => [] ] );
139 }
140
141 public function get_records() {
142 $this->guard();
143 if ( ! Helper::can( 'records' ) ) {
144 wp_send_json_success(
145 [
146 'records' => [],
147 'total' => 0,
148 'per_page' => 20,
149 'locked' => true,
150 ]
151 );
152 }
153 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash
154 $page = isset( $_POST['page'] ) ? max( 1, absint( $_POST['page'] ) ) : 1;
155 $per = 20;
156
157 wp_send_json_success(
158 [
159 'records' => Record::recent( $per, ( $page - 1 ) * $per ),
160 'total' => Record::count(),
161 'per_page' => $per,
162 ]
163 );
164 }
165
166 public function delete_records() {
167 $this->guard();
168 if ( ! Helper::can( 'records' ) ) {
169 wp_send_json_error( __( 'Consent records are a Pro feature.', 'ablocks' ), 403 );
170 }
171 Record::delete_all();
172 wp_send_json_success( [ 'total' => 0 ] );
173 }
174
175 /**
176 * Read one POST field that the dashboard sent as JSON.
177 *
178 * @param string $key Field name.
179 * @return array
180 */
181 private function post_json( $key ) {
182 if ( ! isset( $_POST[ $key ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- guard() ran first.
183 return [];
184 }
185 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Missing
186 $decoded = json_decode( wp_unslash( $_POST[ $key ] ), true );
187 return is_array( $decoded ) ? $decoded : [];
188 }
189
190 // phpcs:enable WordPress.Security.NonceVerification.Missing
191
192 /**
193 * @param array $input Submitted categories.
194 * @param array $defaults Shipped categories.
195 * @return array
196 */
197 private function sanitize_categories( $input, $defaults ) {
198 if ( empty( $input ) ) {
199 return $defaults;
200 }
201
202 $locked_slugs = wp_list_pluck( array_filter( $defaults, function ( $c ) {
203 return ! empty( $c['locked'] );
204 } ), 'slug' );
205
206 $clean = [];
207 foreach ( $input as $category ) {
208 $slug = isset( $category['slug'] ) ? sanitize_key( $category['slug'] ) : '';
209 if ( '' === $slug ) {
210 continue;
211 }
212 $is_locked = in_array( $slug, $locked_slugs, true );
213 $clean[] = [
214 'slug' => $slug,
215 'label' => isset( $category['label'] ) ? sanitize_text_field( $category['label'] ) : $slug,
216 'description' => isset( $category['description'] ) ? sanitize_textarea_field( $category['description'] ) : '',
217 // The locked flag is not the submitter's to set: a category
218 // that cannot be refused is a decision about the site, not a
219 // field on a form.
220 'locked' => $is_locked,
221 'enabled' => $is_locked ? true : ! empty( $category['enabled'] ),
222 'cookies' => $this->sanitize_cookies( isset( $category['cookies'] ) ? $category['cookies'] : [] ),
223 ];
224 }
225
226 return $clean ? $clean : $defaults;
227 }
228
229 /**
230 * The per-category cookie disclosure table.
231 *
232 * Every field is plain text shown to a visitor, so all four go through the
233 * same sanitiser. A row with no name is dropped rather than kept as an
234 * empty line: the table is a disclosure, and a blank row discloses nothing
235 * while looking like it does.
236 *
237 * The cap is there because this list is printed on every page of the site.
238 *
239 * @param array $input Submitted cookie rows.
240 * @return array
241 */
242 private function sanitize_cookies( $input ) {
243 $clean = [];
244 foreach ( (array) $input as $cookie ) {
245 if ( ! is_array( $cookie ) ) {
246 continue;
247 }
248 $name = isset( $cookie['name'] ) ? sanitize_text_field( $cookie['name'] ) : '';
249 if ( '' === $name ) {
250 continue;
251 }
252 $clean[] = [
253 'name' => $name,
254 'provider' => isset( $cookie['provider'] ) ? sanitize_text_field( $cookie['provider'] ) : '',
255 'duration' => isset( $cookie['duration'] ) ? sanitize_text_field( $cookie['duration'] ) : '',
256 'purpose' => isset( $cookie['purpose'] ) ? sanitize_text_field( $cookie['purpose'] ) : '',
257 ];
258 if ( count( $clean ) >= 50 ) {
259 break;
260 }
261 }
262 return $clean;
263 }
264
265 /**
266 * @param array $input Submitted rules.
267 * @return array
268 */
269 /**
270 * Embed and pixel overrides: an id, whether it is on, and which category.
271 *
272 * Nothing else survives. The pattern that decides what a rule matches is
273 * not editable and is never read from the request, which is the reason a
274 * mis-typed setting here cannot blank a payment iframe.
275 *
276 * @param array $rules Raw rules from the request.
277 * @return array
278 */
279 private function sanitize_provider_rules( $rules ) {
280 if ( ! is_array( $rules ) ) {
281 return [];
282 }
283
284 $clean = [];
285 foreach ( $rules as $rule ) {
286 if ( empty( $rule['id'] ) ) {
287 continue;
288 }
289 $clean[] = [
290 'id' => sanitize_key( $rule['id'] ),
291 'enabled' => ! empty( $rule['enabled'] ),
292 'category' => isset( $rule['category'] ) ? sanitize_key( $rule['category'] ) : '',
293 ];
294 }
295
296 return $clean;
297 }
298
299 private function sanitize_rules( $input ) {
300 $can_author = Helper::can( 'custom_rules' );
301 $shipped = Helper::shipped_rule_ids();
302 $defaults = [];
303 foreach ( Helper::default_rules() as $rule ) {
304 $defaults[ $rule['id'] ] = $rule;
305 }
306
307 $clean = [];
308 foreach ( (array) $input as $rule ) {
309 $id = isset( $rule['id'] ) ? sanitize_key( $rule['id'] ) : '';
310 if ( '' === $id ) {
311 $id = 'rule-' . substr( md5( wp_json_encode( $rule ) ), 0, 8 );
312 }
313
314 // Without Pro, only the shipped rules survive, and only their
315 // on/off state and category are the submitter's to change. The
316 // patterns come back from the defaults, so a crafted request
317 // cannot author a matcher the UI does not offer.
318 if ( ! $can_author ) {
319 if ( ! in_array( $id, $shipped, true ) ) {
320 continue;
321 }
322 $clean[] = array_merge(
323 $defaults[ $id ],
324 [
325 'category' => isset( $rule['category'] ) ? sanitize_key( $rule['category'] ) : $defaults[ $id ]['category'],
326 'enabled' => ! empty( $rule['enabled'] ),
327 ]
328 );
329 continue;
330 }
331
332 $clean[] = [
333 'id' => $id,
334 'label' => isset( $rule['label'] ) ? sanitize_text_field( $rule['label'] ) : $id,
335 'category' => isset( $rule['category'] ) ? sanitize_key( $rule['category'] ) : '',
336 // Patterns are regular expressions, so they cannot be run
337 // through a sanitiser that strips punctuation. They are only
338 // ever used as the body of a preg_match, never echoed
339 // unescaped, and a malformed one fails closed in Gating.
340 'src' => isset( $rule['src'] ) ? wp_strip_all_tags( (string) $rule['src'] ) : '',
341 'inline' => isset( $rule['inline'] ) ? wp_strip_all_tags( (string) $rule['inline'] ) : '',
342 'handles' => isset( $rule['handles'] ) ? sanitize_text_field( $rule['handles'] ) : '',
343 'enabled' => ! empty( $rule['enabled'] ),
344 ];
345 }//end foreach
346 return $clean;
347 }
348
349 /**
350 * @param array $input Submitted banner settings.
351 * @param array $defaults Shipped banner settings.
352 * @return array
353 */
354 private function sanitize_banner( $input, $defaults ) {
355 $clean = [];
356
357 foreach ( $defaults as $key => $default ) {
358 if ( ! array_key_exists( $key, $input ) ) {
359 $clean[ $key ] = $default;
360 continue;
361 }
362 $value = $input[ $key ];
363
364 if ( is_bool( $default ) ) {
365 $clean[ $key ] = (bool) filter_var( $value, FILTER_VALIDATE_BOOLEAN );
366 } elseif ( is_int( $default ) ) {
367 $clean[ $key ] = absint( $value );
368 } elseif ( in_array( $key, [ 'policy_url', 'cookie_policy_url' ], true ) ) {
369 $clean[ $key ] = esc_url_raw( $value );
370 } elseif ( in_array( $key, [ 'message', 'prefs_intro' ], true ) ) {
371 $clean[ $key ] = wp_kses_post( $value );
372 } else {
373 $clean[ $key ] = sanitize_text_field( $value );
374 }
375 }
376
377 $clean['layout'] = in_array( $clean['layout'], [ 'bar', 'box', 'popup' ], true ) ? $clean['layout'] : 'bar';
378 $clean['position'] = in_array( $clean['position'], [ 'bottom', 'top', 'bottom-left', 'bottom-right', 'center' ], true ) ? $clean['position'] : 'bottom';
379 $clean['reopen_position'] = in_array( $clean['reopen_position'], [ 'bottom-left', 'bottom-right' ], true ) ? $clean['reopen_position'] : 'bottom-left';
380 $clean['prefs_layout'] = in_array( $clean['prefs_layout'], [ 'inline', 'modal' ], true ) ? $clean['prefs_layout'] : 'inline';
381 $clean['settings_style'] = in_array( $clean['settings_style'], [ 'link', 'outline', 'solid' ], true ) ? $clean['settings_style'] : 'link';
382 $clean['close_behaviour'] = in_array( $clean['close_behaviour'], [ 'dismiss', 'reject' ], true ) ? $clean['close_behaviour'] : 'dismiss';
383 $clean['dismiss_days'] = min( 365, (int) $clean['dismiss_days'] );
384 $clean['locked_style'] = in_array( $clean['locked_style'], [ 'text', 'badge' ], true ) ? $clean['locked_style'] : 'text';
385 $clean['switch_style'] = in_array( $clean['switch_style'], [ 'switch', 'checkbox' ], true ) ? $clean['switch_style'] : 'switch';
386
387 // A modal that is narrower than the banner it opens from is a modal
388 // nobody asked for; a bar's 1180px is not a dialog width either.
389 $clean['prefs_max_width'] = min( 1000, max( 320, (int) $clean['prefs_max_width'] ) );
390
391 return $clean;
392 }
393 }
394