PluginProbe
404 Solution / trunk
404 Solution vtrunk
4.3.5 4.3.4 4.3.3 4.3.2 4.3.1 4.3.0 4.2.0 4.1.19 4.1.18 4.1.17 4.1.16 4.1.15 4.1.13 4.1.12 4.1.11 4.1.10 4.1.9 4.1.8 4.1.7 4.1.6 4.1.5 4.1.4 4.1.3 trunk 2.30.0 All 109 releases
404-solution / includes / services / RequestInputNormalizer.php

RequestInputNormalizer.php in 404 Solution trunk, at includes/services/RequestInputNormalizer.php

328 lines 13.3 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2
3 if (!defined('ABSPATH')) {
4 exit;
5 }
6
7 /**
8 * Normalizes request values that arrive from WordPress superglobals.
9 */
10 class ABJ_404_Solution_RequestInputNormalizer {
11
12 /**
13 * Safely unslash request data when wp_unslash exists and is callable.
14 * Some test environments report wp_unslash as existing but throw when called.
15 *
16 * @param mixed $value
17 * @return mixed
18 */
19 public static function safeWpUnslash($value) {
20 if (!function_exists('wp_unslash')) {
21 return $value;
22 }
23
24 try {
25 return wp_unslash($value);
26 } catch (Throwable $e) { // allow-silent-catch: wp_unslash() failure; pass-through preserves the original value which is always usable
27 return $value;
28 }
29 }
30
31 /**
32 * Normalize request input to a scalar string to avoid warnings when arrays/objects are passed.
33 *
34 * @param mixed $value
35 * @return string
36 */
37 public static function normalizeScalar($value): string {
38 $value = self::safeWpUnslash($value);
39 if (!is_scalar($value)) {
40 return '';
41 }
42 return (string)$value;
43 }
44
45 /**
46 * One single-line request field, unslashed and then sanitized.
47 *
48 * This pair (and readTextarea() below) exists so a handler can never get
49 * the order wrong. wp_magic_quotes() backslash-escapes every superglobal
50 * at boot and sanitize_text_field() does not undo that, so a call site
51 * that sanitizes a raw superglobal stores O\'Brien instead of O'Brien --
52 * which is exactly how support report 282 and uninstall reports 50 / 57
53 * came to hold literal backslashes the reporters never typed.
54 *
55 * Non-scalar values (an array posted where a string was expected) yield
56 * the default rather than a PHP array-to-string warning.
57 *
58 * @param array<string|int, mixed> $source One of the request superglobals.
59 * @param array{name: string, default?: string} $options Named read options.
60 * @return string
61 */
62 public static function readText(array $source, array $options): string {
63 return self::readSanitized($source, array(
64 'name' => $options['name'],
65 'default' => $options['default'] ?? '',
66 'sanitizer' => 'sanitize_text_field',
67 ));
68 }
69
70 /**
71 * One multi-line request field, unslashed and then sanitized.
72 * Same contract as readText(), but preserves newlines.
73 *
74 * @param array<string|int, mixed> $source One of the request superglobals.
75 * @param array{name: string, default?: string} $options Named read options.
76 * @return string
77 */
78 public static function readTextarea(array $source, array $options): string {
79 return self::readSanitized($source, array(
80 'name' => $options['name'],
81 'default' => $options['default'] ?? '',
82 'sanitizer' => 'sanitize_textarea_field',
83 ));
84 }
85
86 /**
87 * Shared body of readText()/readTextarea(): unslash first, sanitize
88 * second, never the other way around.
89 *
90 * @param array<string|int, mixed> $source
91 * @param array{name: string, default: string, sanitizer: string} $options
92 * @return string
93 */
94 private static function readSanitized(array $source, array $options): string {
95 $name = $options['name'];
96 $default = $options['default'];
97 $sanitizer = $options['sanitizer'];
98 if (!array_key_exists($name, $source) || !is_scalar($source[$name])) {
99 return $default;
100 }
101
102 $unslashed = self::normalizeScalar($source[$name]);
103 if (!function_exists($sanitizer)) {
104 // Only reachable where WordPress core itself is unavailable
105 // (sanitize_text_field exists since 2.9, sanitize_textarea_field
106 // since 4.7). Unslashing still happened, so returning the value
107 // degrades rather than silently dropping the user's input.
108 return $unslashed;
109 }
110 $clean = $sanitizer($unslashed);
111 return is_string($clean) ? $clean : $default;
112 }
113
114 /**
115 * Read a named request value under a byte ceiling and decode it as JSON.
116 *
117 * The size is measured on the RAW superglobal, before wp_unslash() or
118 * sanitize_text_field() touches it, and an oversized payload is refused
119 * without either. Reading through getPostOrGetSanitize() first and bounding
120 * afterwards inverts the point of the ceiling: a megabyte of observations
121 * was fully unslashed and sanitized and only then rejected for being too
122 * big, so the limit cost more on the input it exists to refuse than on the
123 * ones it accepts.
124 *
125 * @param array{name: string, max_bytes: int, unavailable_label: string} $options
126 * @return array{status: 'available', observations: array<mixed>}|array{
127 * status: 'unavailable',
128 * unavailable: array{code: string, message: string, payloadBytes: int, maxBytes: int}
129 * }
130 */
131 public static function decodeBoundedJsonRequestValue(array $options): array {
132 $bounded = self::readBoundedRaw($options['name'], $options['max_bytes']);
133 return self::decodeBoundedJsonArray(array(
134 'raw' => $bounded['value'],
135 'raw_bytes' => $bounded['bytes'],
136 'max_bytes' => $options['max_bytes'],
137 'unavailable_label' => $options['unavailable_label'],
138 ));
139 }
140
141 /**
142 * One request value, measured first and normalized only if it fits.
143 *
144 * @return array{value: string, bytes: int} `value` is empty when the raw
145 * payload exceeded the ceiling; `bytes` is always the real raw length, so
146 * the caller can report what the client sent.
147 */
148 private static function readBoundedRaw(string $name, int $maxBytes): array {
149 $maxBytes = max(1, $maxBytes);
150 $raw = isset($_GET[$name]) ? $_GET[$name] : (isset($_POST[$name]) ? $_POST[$name] : null);
151 if ($raw === null || !is_scalar($raw)) {
152 return array('value' => '', 'bytes' => 0);
153 }
154 $rawString = (string)$raw;
155 $bytes = strlen($rawString);
156 if ($bytes > $maxBytes) {
157 return array('value' => '', 'bytes' => $bytes);
158 }
159 // safeWpUnslash() is declared mixed because it forwards whatever
160 // wp_unslash() returns; a scalar in yields a string out, and falling
161 // back to the raw value keeps this total rather than assuming it.
162 $unslashed = self::safeWpUnslash($rawString);
163 $unslashedString = is_string($unslashed) ? $unslashed : $rawString;
164 $clean = function_exists('sanitize_text_field')
165 ? sanitize_text_field($unslashedString) : $unslashedString;
166 return array('value' => is_string($clean) ? $clean : '', 'bytes' => $bytes);
167 }
168
169 /**
170 * Decode a size-bounded JSON array without truncating valid input into an
171 * invalid document or silently converting parse failure to an empty array.
172 *
173 * `raw_bytes` overrides the measured length of `raw`, for a caller that
174 * already refused an oversized payload WITHOUT normalizing it (see
175 * decodeBoundedJsonRequestValue below). Such a caller passes an empty
176 * `raw` with the real size, so the refusal still reports what the client
177 * actually sent rather than zero.
178 *
179 * @param array{raw: string, max_bytes: int, unavailable_label: string, raw_bytes?: int} $options
180 * @return array{status: 'available', observations: array<mixed>}|array{
181 * status: 'unavailable',
182 * unavailable: array{code: string, message: string, payloadBytes: int, maxBytes: int}
183 * }
184 */
185 public static function decodeBoundedJsonArray(array $options): array {
186 $raw = $options['raw'];
187 $maxBytes = max(1, $options['max_bytes']);
188 $label = $options['unavailable_label'];
189 $payloadBytes = array_key_exists('raw_bytes', $options)
190 ? (int)$options['raw_bytes'] : strlen($raw);
191
192 if ($payloadBytes === 0) {
193 $code = 'payload_missing';
194 $message = $label . ' payload is missing.';
195 } elseif ($payloadBytes > $maxBytes) {
196 $code = 'payload_truncated';
197 $message = $label . ' payload truncated at ' . $maxBytes . ' bytes.';
198 } else {
199 $decoded = json_decode($raw, true);
200 if (json_last_error() !== JSON_ERROR_NONE) {
201 $code = 'invalid_json';
202 $message = $label . ' JSON is invalid (' . json_last_error_msg() . ').';
203 } elseif (!is_array($decoded)) {
204 $code = 'invalid_shape';
205 $message = $label . ' JSON must decode to an array.';
206 } else {
207 return array('status' => 'available', 'observations' => $decoded);
208 }
209 }
210
211 return array(
212 'status' => 'unavailable',
213 'unavailable' => array(
214 'code' => $code,
215 'message' => $message,
216 'payloadBytes' => $payloadBytes,
217 'maxBytes' => $maxBytes,
218 ),
219 );
220 }
221
222 /**
223 * Normalize and sanitize feedback issue selections from request data.
224 *
225 * @param mixed $issuesRaw
226 * @return array<int, string>
227 */
228 public static function sanitizeFeedbackIssues($issuesRaw): array {
229 $issuesRaw = self::safeWpUnslash($issuesRaw);
230 if (!is_array($issuesRaw)) {
231 $issuesRaw = array($issuesRaw);
232 }
233
234 $issues = array();
235 foreach ($issuesRaw as $issue) {
236 if (!is_scalar($issue)) {
237 continue;
238 }
239 $clean = sanitize_text_field((string)$issue);
240 if ($clean !== '') {
241 $issues[] = $clean;
242 }
243 }
244 return $issues;
245 }
246
247 /**
248 * Read one request parameter, unslashed and sanitized.
249 *
250 * wp_magic_quotes() slash-escapes every superglobal at boot and
251 * sanitize_text_field() does not undo it, so an unslashed read hands back
252 * {\"v\":1,...} for JSON and O\'Brien for a search. Hence core's
253 * sanitize_text_field( wp_unslash( ... ) ) order.
254 *
255 * Called from {@see ABJ_404_Solution_Functions::getPostOrGetSanitize()},
256 * which stays the public entry point so DI-based test doubles can
257 * substitute request values without touching real superglobals.
258 *
259 * @param string $name The key to retrieve the value for.
260 * @param string|null $defaultValue The value to return if the value is not set.
261 * @return string The sanitized value.
262 */
263 public static function getPostOrGetSanitize($name, $defaultValue = null) {
264 $returnValue = isset($_GET[$name]) ? $_GET[$name] : (isset($_POST[$name]) ? $_POST[$name] : null);
265 if ($returnValue === null && $name === 'action') {
266 $returnValue = isset($_GET['abj404action']) ? $_GET['abj404action'] : (isset($_POST['abj404action']) ? $_POST['abj404action'] : null);
267 }
268 $returnValue = self::applyBulkActionFallback($name, $returnValue);
269 if ($returnValue !== null) {
270 $returnValue = self::safeWpUnslash($returnValue);
271 if (is_array($returnValue)) {
272 $returnValue = array_map('sanitize_text_field', $returnValue);
273 } else {
274 $returnValue = sanitize_text_field($returnValue);
275 }
276 }
277 $finalValue = $returnValue ?? $defaultValue;
278 return is_string($finalValue) ? $finalValue : (is_string($defaultValue) ? $defaultValue : '');
279 }
280
281 /**
282 * Native WP_List_Table renders bulk-action <select>s at top and bottom of
283 * the table using name="action" and name="action2". The 404 Solution
284 * wrappers mirror this with abj404action (top) and abj404action2 (bottom).
285 * When the top select is empty (default placeholder), fall back to the
286 * bottom select's value so Apply submits from either utility row.
287 *
288 * @param string $name
289 * @param mixed $current
290 * @return mixed
291 */
292 private static function applyBulkActionFallback($name, $current) {
293 if ($name !== 'abj404action') {
294 return $current;
295 }
296 if ($current !== null && $current !== '' && $current !== '-1') {
297 return $current;
298 }
299 $alt = isset($_GET['abj404action2']) ? $_GET['abj404action2'] : (isset($_POST['abj404action2']) ? $_POST['abj404action2'] : null);
300 if ($alt === null || $alt === '' || $alt === '-1') {
301 return $current;
302 }
303 return $alt;
304 }
305
306 /**
307 * @param string $name The key to retrieve the value for.
308 * @param string|null $defaultValue The value to return if the value is not set.
309 * @return string|array<string>|null The normalized URL value.
310 */
311 public static function getPostOrGetSanitizeUrl($name, $defaultValue = null) {
312 $returnValue = isset($_GET[$name]) ? $_GET[$name] : (isset($_POST[$name]) ? $_POST[$name] : null);
313 if ($returnValue === null) {
314 return $defaultValue;
315 }
316
317 $sanitizer = abj_service('sanitizer');
318 if (is_array($returnValue)) {
319 return array_map(static function($value) use ($sanitizer) {
320 return $sanitizer->normalizeUrlString(
321 ABJ_404_Solution_RequestInputNormalizer::safeWpUnslash($value));
322 }, $returnValue);
323 }
324 return $sanitizer->normalizeUrlString(
325 ABJ_404_Solution_RequestInputNormalizer::safeWpUnslash($returnValue));
326 }
327 }
328