PluginProbe
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder / 2.12.0
aBlocks – Gutenberg Blocks, User Dashboard Builder, Popup Builder, Form Builder & Animation Builder v2.12.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 1.1.2 All 80 releases
ablocks / addons / cookie-consent / rest.php

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

154 lines 4.7 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 use WP_REST_Server;
9 use WP_REST_Response;
10
11 /**
12 * The endpoint that records a decision.
13 *
14 * Public and unauthenticated by necessity — the visitors whose consent matters
15 * most are the ones who are not logged in — so everything arriving here is
16 * untrusted input, and the endpoint is rate-limited per address. There is
17 * nothing here worth attacking except the ability to fill a table, and that is
18 * what the limit is for.
19 *
20 * The cookie itself is written by the client, not here. A `Set-Cookie` on a
21 * response would work, but making the client the single writer keeps one
22 * source of truth and keeps consent working if the request fails.
23 */
24 class Rest {
25
26 const LIMIT_PER_HOUR = 30;
27
28 public static function init() {
29 add_action( 'rest_api_init', [ new self(), 'register_routes' ] );
30 }
31
32 public function register_routes() {
33 register_rest_route(
34 ABLOCKS_REST_NAMESPACE,
35 '/consent',
36 [
37 'methods' => WP_REST_Server::CREATABLE,
38 'callback' => [ $this, 'record' ],
39 'permission_callback' => '__return_true',
40 'args' => [
41 'consent_id' => [
42 'required' => true,
43 'type' => 'string',
44 'sanitize_callback' => 'sanitize_text_field',
45 ],
46 'categories' => [
47 'required' => false,
48 'type' => 'array',
49 'items' => [ 'type' => 'string' ],
50 'default' => [],
51 ],
52 'decision' => [
53 'required' => false,
54 'type' => 'string',
55 'default' => 'save',
56 'sanitize_callback' => 'sanitize_key',
57 ],
58 'page_url' => [
59 'required' => false,
60 'type' => 'string',
61 'default' => '',
62 'sanitize_callback' => 'esc_url_raw',
63 ],
64 ],
65 ]
66 );
67 }
68
69 /**
70 * @param \WP_REST_Request $request Incoming request.
71 * @return WP_REST_Response
72 */
73 public function record( $request ) {
74 if ( ! Helper::get( 'record_enabled', true ) ) {
75 return new WP_REST_Response( [ 'recorded' => false ], 200 );
76 }
77 if ( $this->is_rate_limited() ) {
78 return new WP_REST_Response( [ 'recorded' => false ], 429 );
79 }
80
81 $consent_id = preg_replace( '/[^a-zA-Z0-9\-]/', '', (string) $request->get_param( 'consent_id' ) );
82 if ( strlen( $consent_id ) < 8 ) {
83 return new WP_REST_Response( [ 'recorded' => false ], 400 );
84 }
85
86 $categories = array_map( 'sanitize_key', (array) $request->get_param( 'categories' ) );
87
88 $id = Record::insert(
89 [
90 'consent_id' => $consent_id,
91 'categories' => $categories,
92 'decision' => $request->get_param( 'decision' ),
93 'page_url' => $request->get_param( 'page_url' ),
94 ]
95 );
96
97 return new WP_REST_Response( [ 'recorded' => (bool) $id ], 200 );
98 }
99
100 /**
101 * The address the cap is counted against.
102 *
103 * `REMOTE_ADDR` and nothing else by default, because every forwarded-for
104 * header is set by the client and trusting one by default would make the
105 * cap trivially bypassable.
106 *
107 * Behind a CDN or a reverse proxy that is the proxy's address for every
108 * visitor, so the whole site shares one allowance and recording stops after
109 * thirty decisions an hour. A site in that position knows which header its
110 * own proxy sets and can say so:
111 *
112 * add_filter( 'ablocks/cookie_consent/client_ip', function () {
113 * return $_SERVER['HTTP_CF_CONNECTING_IP'] ?? '';
114 * } );
115 *
116 * Nothing a visitor sees depends on this either way: the decision is
117 * written to their cookie and applied before the request is made, and a
118 * failure here is deliberately not surfaced. What is lost when the cap is
119 * hit is audit rows, which is exactly what a proxied site would want back.
120 *
121 * @return string
122 */
123 private function client_ip() {
124 // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
125 $ip = isset( $_SERVER['REMOTE_ADDR'] ) ? sanitize_text_field( wp_unslash( $_SERVER['REMOTE_ADDR'] ) ) : '';
126 $ip = (string) apply_filters( 'ablocks/cookie_consent/client_ip', $ip );
127
128 // A filter that returns something that is not an address would key
129 // every visitor to the same bucket, which is the failure it was added
130 // to fix.
131 $valid = filter_var( $ip, FILTER_VALIDATE_IP );
132
133 return $valid ? $valid : 'unknown';
134 }
135
136 /**
137 * A coarse per-address cap. Consent is a handful of decisions per visitor
138 * per year; anything approaching thirty an hour is not a visitor.
139 *
140 * @return bool
141 */
142 private function is_rate_limited() {
143 $key = 'ablocks_cc_rl_' . md5( $this->client_ip() );
144
145 $count = (int) get_transient( $key );
146 if ( $count >= self::LIMIT_PER_HOUR ) {
147 return true;
148 }
149
150 set_transient( $key, $count + 1, HOUR_IN_SECONDS );
151 return false;
152 }
153 }
154