PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.4.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.4.0
2.7.0 2.6.0 2.5.0 2.4.0 2.3.0 2.2.0 2.1.1 2.1.0 2.0.2 2.0.1 2.0.0 1.32.0 1.31.0 1.30.0 1.29.0 1.28.0 1.27.0 1.26.0 1.25.0 trunk 1.0.0 1.0.1 1.0.2 1.1.0 1.10.0 All 48 releases
thinkrank / includes / admin / importers / class-safe-unserializer.php

class-safe-unserializer.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.4.0, at includes/admin/importers/class-safe-unserializer.php

126 lines 4.5 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 /**
4 * Safe Unserializer
5 *
6 * Object-injection-safe replacement for maybe_unserialize() on foreign data.
7 *
8 * @package ThinkRank\Admin\Importers
9 * @since 1.29.0
10 */
11
12 declare(strict_types=1);
13
14 namespace ThinkRank\Admin\Importers;
15
16 if (!defined('ABSPATH')) {
17 exit;
18 }
19
20 /**
21 * Safe Unserializer Class
22 *
23 * The importers read rows written by other SEO plugins — their options, their
24 * meta, their own tables. maybe_unserialize() on that data will happily
25 * instantiate objects, which is the entry point for a POP-gadget chain if the
26 * source row was ever tampered with. Every importer deserialization goes
27 * through here instead, with `allowed_classes => false` so no object can be
28 * constructed: an object payload comes back as __PHP_Incomplete_Class and is
29 * rejected rather than woken up.
30 *
31 * @since 1.29.0
32 */
33 class Safe_Unserializer {
34
35 /**
36 * Maximum nesting depth walked when scanning a decoded payload for objects.
37 *
38 * Real importer rows (robots directives, schema blocks, settings maps) are
39 * shallow; anything past this is treated as untrusted and rejected.
40 */
41 private const MAX_DEPTH = 10;
42
43 /**
44 * Unserialize a value from foreign data without allowing objects.
45 *
46 * Non-strings and non-serialized strings are returned unchanged, matching
47 * maybe_unserialize()'s contract. A serialized payload that fails to decode
48 * — or that decodes to an object — yields $fallback.
49 *
50 * @since 1.29.0
51 *
52 * @param mixed $value Raw value read from the source plugin.
53 * @param mixed $fallback Optional. Value to return when the payload is
54 * undecodable or contains an object. Default null.
55 * @return mixed Unserialized value, the original value, or $fallback.
56 */
57 public static function unserialize($value, $fallback = null) {
58 if (!is_string($value) || !is_serialized($value)) {
59 return $value;
60 }
61
62 // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_unserialize, WordPress.PHP.NoSilencedErrors.Discouraged -- allowed_classes => false is exactly the hardening this helper exists for; the @ suppresses the notice unserialize() emits on a malformed payload, which is a normal reject-path here.
63 $decoded = @unserialize($value, ['allowed_classes' => false]);
64
65 if (false === $decoded) {
66 // Distinguish a genuine serialized false from a decode failure.
67 return 'b:0;' === $value ? false : $fallback;
68 }
69
70 return self::contains_object($decoded) ? $fallback : $decoded;
71 }
72
73 /**
74 * Unserialize and require an array result.
75 *
76 * Convenience for the call sites that only ever want an array shape (schema
77 * blocks, robots directives, settings maps) and should skip anything else.
78 *
79 * @since 1.29.0
80 *
81 * @param mixed $value Raw value read from the source plugin.
82 * @return array Decoded array, or an empty array when the value isn't one.
83 */
84 public static function to_array($value): array {
85 $decoded = self::unserialize($value, null);
86 return is_array($decoded) ? $decoded : [];
87 }
88
89 /**
90 * Whether a decoded payload contains an object at any depth.
91 *
92 * With allowed_classes => false any object arrives as
93 * __PHP_Incomplete_Class; treat that as untrusted and drop it.
94 *
95 * @param mixed $value Decoded value.
96 * @param int $depth Current recursion depth.
97 * @return bool True when an object is present.
98 */
99 private static function contains_object($value, int $depth = 0): bool {
100 if ($depth > self::MAX_DEPTH) {
101 // Refuse rather than walk an absurdly nested payload. A genuine but
102 // deep import row is dropped here too, so leave a breadcrumb under
103 // debug — otherwise the data silently vanishes with no explanation.
104 if (defined('WP_DEBUG') && WP_DEBUG) {
105 // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- Diagnostic only, WP_DEBUG-gated.
106 error_log(sprintf('[ThinkRank] Safe_Unserializer: payload exceeded max depth of %d and was rejected.', self::MAX_DEPTH));
107 }
108 return true;
109 }
110
111 if (is_object($value)) {
112 return true;
113 }
114
115 if (is_array($value)) {
116 foreach ($value as $item) {
117 if (self::contains_object($item, $depth + 1)) {
118 return true;
119 }
120 }
121 }
122
123 return false;
124 }
125 }
126