PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.16
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.16
1.10.18 1.10.17 1.10.16 1.10.15 1.10.13 1.10.14 1.10.12 1.10.11 1.10.10 1.10.9 1.10.8 untagged-3d9b7ccddc54df87c672 1.10.7 1.10.6 1.10.5 1.10.3 1.10.4 1.10.2 1.10.1 1.10.0 1.9.17 1.9.15 1.9.16 1.9.14 1.9.13 All 162 releases
woocommerce-pos / includes / Services / Core_Order_Audit_Guard.php

Core_Order_Audit_Guard.php in WCPOS – Point of Sale (POS) plugin for WooCommerce 1.10.16, at includes/Services/Core_Order_Audit_Guard.php

321 lines 10.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Core-route POS audit-meta guard.
4 *
5 * WCPOS Bearer tokens authenticate every REST request (Init hooks
6 * determine_current_user globally, without requiring the X-WCPOS marker), so a
7 * cashier whose capabilities allow order writes could bypass the
8 * server-authoritative `_pos_*` audit enforcement on the wcpos namespaces by
9 * writing the same keys through core routes such as `wc/v3/orders` or its
10 * batch endpoint. This guard rejects those writes.
11 *
12 * It MUST be registered unconditionally from Init (alongside
13 * determine_current_user_early), never from the X-WCPOS-gated API class — an
14 * attacker controls whether that marker is sent.
15 *
16 * Cookie- and application-password-authenticated requests are intentionally
17 * not guarded: a user whose capabilities allow editing order meta through
18 * wp-admin or core REST keeps that power. The guard only closes the gap where
19 * a POS token grants more than the POS surface would allow.
20 *
21 * @package WCPOS\WooCommercePOS\Services
22 */
23
24 namespace WCPOS\WooCommercePOS\Services;
25
26 use WCPOS\WooCommercePOS\Sync\Meta_Entry;
27 use WP_Error;
28 use WP_REST_Request;
29 use WCPOS\WooCommercePOS\API;
30
31 /**
32 * Core_Order_Audit_Guard service.
33 */
34 final class Core_Order_Audit_Guard {
35 /**
36 * User authenticated before WCPOS's priority-20 JWT filter.
37 *
38 * @var int
39 */
40 private $pre_wcpos_user_id = 0;
41
42 /**
43 * Register the guard on the REST dispatch pipeline.
44 */
45 public function register_hooks(): void {
46 add_filter( 'determine_current_user', array( $this, 'record_prior_authentication' ), 20 );
47 add_filter( 'rest_pre_dispatch', array( $this, 'rest_pre_dispatch' ), 10, 3 );
48 }
49
50 /**
51 * Record authentication completed before WCPOS's priority-20 JWT filter.
52 *
53 * @param false|int|WP_Error $user_id User ID if already authenticated, false or error otherwise.
54 *
55 * @return false|int|WP_Error
56 */
57 public function record_prior_authentication( $user_id ) {
58 $this->pre_wcpos_user_id = \is_numeric( $user_id ) ? absint( $user_id ) : 0;
59
60 return $user_id;
61 }
62
63 /**
64 * Reject non-wcpos REST writes of POS audit meta by WCPOS-JWT-authenticated requests.
65 *
66 * @param mixed $result Response to replace the requested version with.
67 * @param \WP_REST_Server $server Server instance.
68 * @param WP_REST_Request $request Request used to generate the response.
69 *
70 * @return mixed
71 */
72 public function rest_pre_dispatch( $result, $server, $request ) {
73 $error = $this->guard( $request );
74 $this->pre_wcpos_user_id = 0;
75
76 return is_wp_error( $error ) ? $error : $result;
77 }
78
79 /**
80 * Evaluate one request. Cheap checks run first: no token cryptography and no
81 * order loads unless the payload actually carries suspicious meta entries.
82 *
83 * @param WP_REST_Request $request REST request.
84 *
85 * @return null|WP_Error WP_Error to short-circuit the dispatch, null to pass through.
86 */
87 private function guard( WP_REST_Request $request ) {
88 if ( ! \in_array( $request->get_method(), array( 'POST', 'PUT', 'PATCH' ), true ) ) {
89 return null;
90 }
91
92 // WordPress matches REST routes case-insensitively, so every route
93 // comparison below must be case-insensitive too.
94 $route = $request->get_route();
95 if ( $this->is_wcpos_route( $route ) ) {
96 // The wcpos controllers enforce server-authoritative audit meta themselves.
97 return null;
98 }
99 if ( ! preg_match( '#/orders(?:/\d+|/batch)?/?$#i', $route ) ) {
100 return null;
101 }
102
103 $is_order_batch = (bool) preg_match( '#/orders/batch/?$#i', $route );
104
105 // An order batch beyond WooCommerce's own limit is rejected by
106 // check_batch_limit() with a 413 before any item is written, so skip
107 // scanning it rather than doing unbounded work here.
108 if ( $is_order_batch && $this->batch_over_limit( $request ) ) {
109 return null;
110 }
111
112 // Candidate meta_data sets: top-level, plus per-item for batch requests.
113 // Each candidate is array{ 0: mixed meta_data, 1: int order_id }.
114 $single_order_id = 0;
115 if ( preg_match( '#/orders/(\d+)/?$#i', $route, $matches ) ) {
116 $single_order_id = (int) $matches[1];
117 }
118
119 $candidates = array( array( $request->get_param( 'meta_data' ), $single_order_id ) );
120 foreach ( array( 'create', 'update' ) as $operation ) {
121 foreach ( (array) $request->get_param( $operation ) as $item ) {
122 $item = (array) $item;
123 $order_id = ( $is_order_batch && 'update' === $operation ) ? (int) ( $item['id'] ?? 0 ) : 0;
124 if ( isset( $item['meta_data'] ) ) {
125 $candidates[] = array( $item['meta_data'], $order_id );
126 }
127 }
128 }
129
130 // Pass 1 — audit keys by name. No auth or order lookup needed to detect,
131 // and only a request that is actually suspicious pays for token validation.
132 $audit_keys = Pos_Order_Audit::audit_meta_keys();
133 $has_audit_key = false;
134 foreach ( $candidates as $candidate ) {
135 foreach ( $this->meta_entries( $candidate[0] ) as $entry ) {
136 if ( \in_array( Meta_Entry::key( $entry ), $audit_keys, true ) ) {
137 $has_audit_key = true;
138 break 2;
139 }
140 }
141 }
142
143 if ( $has_audit_key ) {
144 return $this->is_wcpos_jwt_authenticated() ? $this->forbidden() : null;
145 }
146
147 // Pass 2 — id-addressed entries. WooCommerce resolves a meta_data entry by
148 // its numeric `id` BEFORE its `key` and overwrites the row, so an entry
149 // under a harmless key can rename an audit row away. Only order-targeted
150 // candidates can be checked, and each order is loaded at most once, only
151 // for JWT-authenticated requests with an id-addressed entry present.
152 $ids_by_order = array();
153 foreach ( $candidates as $candidate ) {
154 if ( $candidate[1] <= 0 ) {
155 continue;
156 }
157 foreach ( $this->meta_entries( $candidate[0] ) as $entry ) {
158 if ( is_numeric( $entry['id'] ?? null ) ) {
159 $ids_by_order[ $candidate[1] ][] = (int) $entry['id'];
160 }
161 }
162 }
163
164 if ( array() === $ids_by_order || ! $this->is_wcpos_jwt_authenticated() ) {
165 return null;
166 }
167
168 foreach ( $ids_by_order as $order_id => $meta_ids ) {
169 $protected = Pos_Order_Audit::audit_meta_ids( wc_get_order( $order_id ) );
170 if ( array() !== array_intersect( $meta_ids, $protected ) ) {
171 return $this->forbidden();
172 }
173 }
174
175 return null;
176 }
177
178 /**
179 * The rejection response.
180 *
181 * @return WP_Error
182 */
183 private function forbidden(): WP_Error {
184 return new WP_Error(
185 'woocommerce_pos_rest_audit_meta_forbidden',
186 __( 'POS audit metadata cannot be modified through this REST route.', 'woocommerce-pos' ),
187 array( 'status' => 403 )
188 );
189 }
190
191 /**
192 * Whether the route belongs to a WCPOS REST namespace (case-insensitive,
193 * mirroring WordPress route matching). Same namespace list as
194 * API::get_route_namespaces(), which is not static and lives on the
195 * X-WCPOS-gated API class this guard must not depend on being constructed.
196 *
197 * @param string $route REST route, e.g. `/wc/v3/orders`.
198 *
199 * @return bool
200 */
201 private function is_wcpos_route( string $route ): bool {
202 /** This filter is documented in includes/API.php */
203 $namespaces = apply_filters( 'woocommerce_pos_rest_namespaces', API::ROUTE_NAMESPACES );
204 $namespaces = array_unique( array_merge( API::ROUTE_NAMESPACES, (array) $namespaces ) );
205
206 foreach ( $namespaces as $namespace ) {
207 if ( \is_string( $namespace ) && '' !== $namespace && 0 === stripos( trailingslashit( $route ), '/' . trailingslashit( $namespace ) ) ) {
208 return true;
209 }
210 }
211
212 return false;
213 }
214
215 /**
216 * Whether an order batch exceeds WooCommerce's own batch limit.
217 *
218 * Mirrors WC_REST_Controller::check_batch_limit(), which rejects the whole
219 * request with a 413 before processing any item.
220 *
221 * @param WP_REST_Request $request REST request.
222 *
223 * @return bool
224 */
225 private function batch_over_limit( WP_REST_Request $request ): bool {
226 $total = 0;
227 foreach ( array( 'create', 'update', 'delete' ) as $operation ) {
228 $items = $request->get_param( $operation );
229 $total += \is_array( $items ) ? \count( $items ) : 0;
230 }
231
232 /** This filter is documented in WooCommerce's WC_REST_Controller::check_batch_limit() */
233 $limit = (int) apply_filters( 'woocommerce_rest_batch_items_limit', 100, 'orders' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WooCommerce core hook.
234
235 return $total > $limit;
236 }
237
238 /**
239 * Normalize a request meta_data value into an array of entry arrays.
240 *
241 * @param mixed $meta_data Raw meta_data parameter (array of arrays/objects, or anything else).
242 *
243 * @return array<int, array>
244 */
245 private function meta_entries( $meta_data ): array {
246 if ( ! \is_array( $meta_data ) ) {
247 return array();
248 }
249
250 return array_map(
251 static function ( $entry ) {
252 return (array) $entry;
253 },
254 $meta_data
255 );
256 }
257
258 /**
259 * Whether the current request was authenticated by a WCPOS token.
260 *
261 * Authentication filters before WCPOS's priority-20 filter retain provenance
262 * for cookie and other credentials. If determine_current_user was skipped
263 * because the user was already loaded, that also represents prior auth.
264 * Otherwise, re-validate the token and require it to resolve to the
265 * current user.
266 *
267 * @return bool
268 */
269 private function is_wcpos_jwt_authenticated(): bool {
270 $user_id = get_current_user_id();
271 if ( $user_id <= 0 ) {
272 return false;
273 }
274 if ( $this->pre_wcpos_user_id > 0 ) {
275 return false;
276 }
277
278 $auth_header = $this->get_auth_header();
279 if ( ! \is_string( $auth_header ) || '' === $auth_header ) {
280 return false;
281 }
282
283 $auth_service = Auth::instance();
284 $token = $auth_service->extract_token( $auth_header );
285 if ( null === $token ) {
286 return false;
287 }
288
289 $decoded = $auth_service->validate_token( $token );
290 if ( is_wp_error( $decoded ) ) {
291 return false;
292 }
293
294 return absint( $decoded->data->user->id ) === $user_id;
295 }
296
297 /**
298 * Extract the Authorization credential from the request environment.
299 *
300 * Same three sources as Init::get_auth_header_early() (private there);
301 * candidates for consolidation into the Auth service.
302 *
303 * @return false|string
304 */
305 private function get_auth_header() {
306 if ( ! empty( $_SERVER['HTTP_AUTHORIZATION'] ) ) {
307 return sanitize_text_field( wp_unslash( $_SERVER['HTTP_AUTHORIZATION'] ) );
308 }
309
310 if ( ! empty( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) ) {
311 return sanitize_text_field( wp_unslash( $_SERVER['REDIRECT_HTTP_AUTHORIZATION'] ) );
312 }
313
314 if ( ! empty( $_GET['authorization'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
315 return sanitize_text_field( wp_unslash( $_GET['authorization'] ) );
316 }
317
318 return false;
319 }
320 }
321