log = $log; add_action( 'wp_ajax_cookroco_save_consent', [ $this, 'ajax_save_consent' ] ); add_action( 'wp_ajax_nopriv_cookroco_save_consent', [ $this, 'ajax_save_consent' ] ); } /** Laws that require consent before any optional cookie: opt-out is never applied under them. */ public const OPT_IN_LAWS = [ 'gdpr', 'lgpd', 'lfpdppp' ]; /** Decisions the banner can send. Anything else is not a consent decision and is not recorded. */ private const ACTIONS = [ 'accept_all', 'reject_all', 'custom', 'withdraw', 'gpc_opt_out' ]; /** * Records an anonymous visitor's consent decision. * * No nonce, by design. A nonce would have to be written into every page's HTML, which makes * the page uncacheable for strict page caches and changes it every 12 hours for all others, * and for logged-out visitors it is the same token for everyone, so it proves nothing. The * endpoint only appends a record: it accepts known actions and the site's own categories, * and a per-IP limit guards it against flooding. */ public function ajax_save_consent(): void { if ( $this->consent_flood() ) { wp_send_json_success( [ 'recorded' => false ] ); } // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- public, append-only endpoint (see above); the raw value is is_scalar-coerced and sanitize_key'd on the next line. $raw_action = wp_unslash( $_POST['consent_action'] ?? '' ); $action = sanitize_key( is_scalar( $raw_action ) ? (string) $raw_action : '' ); if ( ! in_array( $action, self::ACTIONS, true ) ) { wp_send_json_success( [ 'recorded' => false ] ); } // is_scalar filter FIRST: a crafted nested array (categories[a][b]=x) would otherwise pass an ARRAY // to sanitize_key(), which fatals on WP < 6.6 + PHP 8 — an unauthenticated 500 on this open endpoint. // phpcs:ignore WordPress.Security.NonceVerification.Missing,WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- public, append-only endpoint; each element is is_scalar-filtered then sanitize_key'd via array_map. $categories = isset($_POST['categories']) ? array_map( 'sanitize_key', array_filter( (array) wp_unslash( $_POST['categories'] ), 'is_scalar' ) ) : []; $known = get_option( 'cookroco_cookie_categories', [] ); $known = is_array( $known ) ? array_keys( $known ) : []; $categories = array_values( array_unique( array_intersect( $categories, array_merge( [ 'necessary' ], $known ) ) ) ); $s = get_option( 'cookroco_settings', [] ); $law = ( is_array( $s ) && ! empty( $s['default_law'] ) ) ? sanitize_key( $s['default_law'] ) : 'generic'; // Anything that is not exactly an ISO-3166 alpha-2 code is dropped rather than truncated: this // comes from $_POST, and truncating would turn "