PluginProbe
WCPOS – Point of Sale (POS) plugin for WooCommerce / 1.10.19
WCPOS – Point of Sale (POS) plugin for WooCommerce v1.10.19
1.10.19 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 All 163 releases
← All changes | includes/Sync/Meta_Normalizer.php +207 -2 1.10.81.10.19 View file →
@@ -6,13 +6,54 @@
6 6 */
7 7
8 8 namespace WCPOS\WooCommercePOS\Sync;
9 9
10 +use WCPOS\WooCommercePOS\Logger;
11 +
10 12 /**
11 13 * Normalizes structured meta values before sync documents are hashed or emitted.
12 14 */
13 15 final class Meta_Normalizer {
14 16 /**
17 + * Upper bound on array elements and object properties in ONE meta value, counted
18 + * across every nesting level. The POS's own structured meta (`_woocommerce_pos_data`,
19 + * attribute maps, line-item meta) is a few hundred nodes; anything past this is another
20 + * plugin's bulk data and is not served: one store carried a 16.7-million-element meta
21 + * value, and the JSON round trip below killed every request that touched the record
22 + * (Sentry WOOCOMMERCE-POS-2KQ, ~22 fatals an hour).
23 + */
24 + public const OVERSIZED_META_NODE_LIMIT = 20000;
25 +
26 + /**
27 + * Upper bound on string bytes in ONE meta value, summed across nesting and keys (8 MiB):
28 + * the same cut-off for values that are few entries but enormous strings.
29 + *
30 + * Deliberately well clear of legitimate use. `Test_Catalog_Proxy_Meta_Scaling` pins a
31 + * 1 MiB meta value as something that must round-trip, so the cut-off sits eight times
32 + * above the largest size this repo asserts is normal, and still three orders of
33 + * magnitude below the gigabyte that took a store's requests down.
34 + */
35 + public const OVERSIZED_META_BYTE_LIMIT = 8388608;
36 +
37 + /**
38 + * Meta keys already reported as oversized in this request (one warning per key).
39 + *
40 + * @var array<string, true>
41 + */
42 + private static array $oversized_logged = array();
43 +
44 + /**
45 + * Clear the per-request set of already-reported keys.
46 + *
47 + * The dedupe is request-scoped in production, where the process ends with the
48 + * response. Long-lived processes and the test suite share one process across many
49 + * requests, so they reset at the boundary like the other request-scoped collectors.
50 + */
51 + public static function reset_request_state(): void {
52 + self::$oversized_logged = array();
53 + }
54 +
55 + /**
15 56 * Register the shared pre-stamping normalization seams.
16 57 */
17 58 public static function register_hooks(): void {
18 59 add_filter( 'woocommerce_pos_sync_proxy_response', array( __CLASS__, 'normalize' ), 5 );
@@ -90,17 +131,27 @@
90 131 *
91 132 * @return array
92 133 */
93 134 private static function normalize_meta_data( array $meta_data ): array {
135 + $was_list = array_keys( $meta_data ) === array_keys( array_values( $meta_data ) );
136 + $dropped = false;
94 137 foreach ( $meta_data as $index => $entry ) {
95 138 // Top-level entity meta reaches the filters as live WC_Meta_Data objects
96 139 // (they only become arrays at JSON-encode time); convert a copy to the
97 140 // exact shape it would serialize to, and only swap it in when normalization
98 141 // actually happens — untouched entries keep their original form so
99 - // revision hashes of scalar-only records are unchanged.
142 + // revision hashes of scalar-only records are unchanged. The budget check
143 + // runs on the live value BEFORE the round trip: encoding an oversized value
144 + // is the allocation that took the whole request down.
100 145 $is_meta_object = $entry instanceof \WC_Meta_Data;
101 146 if ( $is_meta_object ) {
102 - $entry = json_decode( wp_json_encode( $entry ), true );
147 + $data = $entry->get_data();
148 + if ( self::exceeds_value_budget( $data['value'] ?? null ) ) {
149 + self::drop_oversized( $meta_data, $index, $data );
150 + $dropped = true;
151 + continue;
152 + }
153 + $entry = json_decode( wp_json_encode( $data ), true );
103 154 }
104 155
105 156 if ( ! is_array( $entry ) ) {
106 157 continue;
@@ -105,8 +156,14 @@
105 156 if ( ! is_array( $entry ) ) {
106 157 continue;
107 158 }
108 159
160 + if ( ! $is_meta_object && self::exceeds_value_budget( $entry['value'] ?? null ) ) {
161 + self::drop_oversized( $meta_data, $index, $entry );
162 + $dropped = true;
163 + continue;
164 + }
165 +
109 166 $entry_changed = false;
110 167 if ( isset( $entry['value'] ) && is_string( $entry['value'] ) ) {
111 168 $raw = $entry['value'];
112 169 $trimmed = trim( $raw );
@@ -130,9 +187,157 @@
130 187 $meta_data[ $index ] = $entry;
131 188 }
132 189 }
133 190
191 + if ( $dropped && $was_list ) {
192 + // A list with a hole JSON-encodes as an object; the wire expects a list.
193 + $meta_data = array_values( $meta_data );
194 + }
195 +
134 196 return $meta_data;
197 + }
198 +
199 + /**
200 + * Remove an oversized entry from the payload and say so once per key per request.
201 + * The value itself is never logged.
202 + *
203 + * @param array $meta_data Serialized REST meta entries (by reference).
204 + * @param int|string $index Index of the entry to drop.
205 + * @param array $entry Array form of the entry (`id`, `key`, `value`).
206 + */
207 + private static function drop_oversized( array &$meta_data, $index, array $entry ): void {
208 + unset( $meta_data[ $index ] );
209 + self::note_oversized_meta(
210 + isset( $entry['key'] ) ? (string) $entry['key'] : '',
211 + (int) ( $entry['id'] ?? 0 )
212 + );
213 + }
214 +
215 + /**
216 + * Record that an oversized meta entry was withheld, once per key per request.
217 + * The value is never logged. Shared with the v1 lane, which drops the same
218 + * entries at its own serializer rather than through this class.
219 + *
220 + * @param string $key Meta key that was withheld.
221 + * @param int $meta_id Meta row id, when known.
222 + */
223 + public static function note_oversized_meta( string $key, int $meta_id = 0 ): void {
224 + if ( isset( self::$oversized_logged[ $key ] ) ) {
225 + return;
226 + }
227 + self::$oversized_logged[ $key ] = true;
228 + Logger::warning(
229 + sprintf(
230 + 'WCPOS sync: dropped oversized meta "%s" (meta id %d) from the POS payload; the stored value exceeds %d nodes or %d bytes and cannot be served to the POS.',
231 + $key,
232 + $meta_id,
233 + self::OVERSIZED_META_NODE_LIMIT,
234 + self::OVERSIZED_META_BYTE_LIMIT
235 + )
236 + );
237 + }
238 +
239 + /**
240 + * Whether a meta value is too large to serve. Walks iteratively and stops the moment a
241 + * limit is crossed, so the cost is bounded by the limits, not by the value: a
242 + * 16-million-element value costs the same as a 20,001-element one.
243 + *
244 + * EVERY object is expanded, not just stdClass. WordPress unserializes stored meta, so a
245 + * value can come back as an instance of some other plugin's class, and json_encode
246 + * serializes its public properties just the same — a custom object wrapping the
247 + * multi-million-element array would otherwise walk straight past this check into the
248 + * encode that killed the request. `get_object_vars()` is called from outside the value's
249 + * class, so it sees exactly the public properties the encoder will. Traversal is by
250 + * refcount, never a deep copy.
251 + *
252 + * Keys count toward both budgets: a value can be a few entries under enormous keys.
253 + *
254 + * A self-referencing object graph terminates on the node limit and reports oversized,
255 + * which is correct — json_encode cannot represent one either.
256 + *
257 + * @param mixed $value Meta value as stored.
258 + *
259 + * @return bool
260 + */
261 + public static function exceeds_value_budget( $value ): bool {
262 + $unencodable = false;
263 + $value = self::as_encoded( $value, $unencodable );
264 + if ( $unencodable ) {
265 + return true;
266 + }
267 + if ( is_string( $value ) ) {
268 + return \strlen( $value ) > self::OVERSIZED_META_BYTE_LIMIT;
269 + }
270 + if ( ! is_array( $value ) && ! \is_object( $value ) ) {
271 + return false;
272 + }
273 +
274 + $nodes = 0;
275 + $bytes = 0;
276 + $stack = array( is_array( $value ) ? $value : get_object_vars( $value ) );
277 + while ( array() !== $stack ) {
278 + $current = array_pop( $stack );
279 + foreach ( $current as $child_key => $child ) {
280 + ++$nodes;
281 + if ( is_string( $child_key ) ) {
282 + $bytes += \strlen( $child_key );
283 + }
284 + $child = self::as_encoded( $child, $unencodable );
285 + if ( $unencodable ) {
286 + return true;
287 + }
288 + if ( is_string( $child ) ) {
289 + $bytes += \strlen( $child );
290 + } elseif ( is_array( $child ) ) {
291 + $stack[] = $child;
292 + } elseif ( \is_object( $child ) ) {
293 + $stack[] = get_object_vars( $child );
294 + }
295 + if ( $nodes > self::OVERSIZED_META_NODE_LIMIT || $bytes > self::OVERSIZED_META_BYTE_LIMIT ) {
296 + return true;
297 + }
298 + }
299 + }
300 +
301 + return false;
302 + }
303 +
304 + /**
305 + * What `json_encode()` will actually serialize for a value.
306 + *
307 + * A `JsonSerializable` object is encoded from `jsonSerialize()`, NOT from its public
308 + * properties, so an object can expose nothing and still return a multi-million-element
309 + * array to the encoder. Budgeting `get_object_vars()` alone would wave exactly that
310 + * through. The encoder is going to call this method moments later anyway, so calling it
311 + * here adds no execution that was not already going to happen.
312 + *
313 + * A chain deeper than a handful of levels, or one that throws, is reported as
314 + * unencodable and the entry is withheld: `json_encode()` would fail on it too, and
315 + * failing there is the fatal this guard exists to prevent.
316 + *
317 + * @param mixed $value Value to resolve.
318 + * @param bool $unencodable Set to true when the value cannot be resolved safely.
319 + *
320 + * @return mixed
321 + */
322 + private static function as_encoded( $value, bool &$unencodable ) {
323 + $depth = 0;
324 + while ( $value instanceof \JsonSerializable ) {
325 + if ( ++$depth > 8 ) {
326 + $unencodable = true;
327 +
328 + return null;
329 + }
330 + try {
331 + $value = $value->jsonSerialize();
332 + } catch ( \Throwable $error ) {
333 + $unencodable = true;
334 +
335 + return null;
336 + }
337 + }
338 +
339 + return $value;
135 340 }
136 341
137 342 /**
138 343 * Convert JSON objects to arrays unless doing so would change their wire shape.