PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3
16.3 16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 All 508 releases
← All changes | vendor/wp-php-toolkit/reprint-server/src/class-http-server.php +414 -0 16.2-beta → 16.3 View file →
@@ -1,0 +1,414 @@
1 +<?php
2 +
3 +use WordPress\Reprint\Server\ResourceBudget;
4 +
5 +use function WordPress\Reprint\Server\parse_size;
6 +
7 +require_once __DIR__ . '/utils.php';
8 +
9 +if (!class_exists('WordPress\\Reprint\\Server\\ResourceBudget', false)) {
10 + require_once __DIR__ . '/class-resource-budget.php';
11 +}
12 +if (!class_exists('Site_Export_Push_Configuration_Exception', false)) {
13 + require_once __DIR__ . '/class-push-configuration-exception.php';
14 +}
15 +
16 +/**
17 + * HTTP dispatcher for the Site Export API.
18 + */
19 +final class Site_Export_HTTP_Server {
20 +
21 + private const PUSH_ENDPOINT_METHODS = [
22 + 'push_create' => 'create',
23 + 'push_upload' => 'upload',
24 + 'push_status' => 'status',
25 + 'push_commit' => 'commit',
26 + 'push_remove' => 'remove',
27 + ];
28 +
29 + /** @var array<string, callable> */
30 + private $handlers;
31 +
32 + /** @var callable */
33 + private $budget_factory;
34 +
35 + /** @var callable */
36 + private $body_reader;
37 +
38 + /** @var string */
39 + private $cursor_header_name;
40 +
41 + /** @var string|null */
42 + private $default_directory;
43 +
44 + /** @var Site_Export_Push_Endpoints|null */
45 + private $push_endpoints;
46 +
47 + public function __construct(array $options = []) {
48 + $this->budget_factory = $options['budget_factory'] ?? [$this, 'default_budget_factory'];
49 + $this->body_reader = $options['body_reader'] ?? static function (): string {
50 + $body = file_get_contents('php://input');
51 + return $body === false ? '' : $body;
52 + };
53 + $this->cursor_header_name = $options['cursor_header_name'] ?? 'HTTP_X_EXPORT_CURSOR';
54 + $this->default_directory = $options['default_directory'] ?? null;
55 + $this->push_endpoints = null;
56 + if (array_key_exists('push', $options)) {
57 + try {
58 + if (!is_array($options['push'])) {
59 + throw new InvalidArgumentException('The push HTTP server option must be an array.');
60 + }
61 + if (!class_exists('Site_Export_Push_Endpoints', false)) {
62 + require_once __DIR__ . '/class-push-endpoints.php';
63 + }
64 + $this->push_endpoints = new Site_Export_Push_Endpoints($options['push']);
65 + } catch (InvalidArgumentException $exception) {
66 + // phpcs:disable WordPress.Security.EscapeOutput.ExceptionNotEscaped -- This rethrows a trusted configuration error; it does not render output.
67 + throw new Site_Export_Push_Configuration_Exception(
68 + $exception->getMessage(),
69 + 0,
70 + $exception
71 + );
72 + // phpcs:enable WordPress.Security.EscapeOutput.ExceptionNotEscaped
73 + }
74 + }
75 + $this->handlers = $options['handlers'] ?? $this->default_handlers();
76 + }
77 +
78 + public function handle_request(array $request = []): void {
79 + $server = $request['server'] ?? $_SERVER;
80 + $get = $request['get'] ?? $_GET;
81 + $post = $request['post'] ?? $_POST;
82 + // Push parameters travel in the signed query string. push_upload streams
83 + // php://input itself; reading JSON for any push endpoint would either
84 + // buffer an upload or let a control request buffer an unused body.
85 + $endpoint = $get['endpoint'] ?? null;
86 + $uses_push_request_contract = is_string($endpoint) && strpos($endpoint, 'push_') === 0;
87 + $body = '';
88 + if ($uses_push_request_contract) {
89 + // $_POST must not override the signed query endpoint after the
90 + // router has applied push authorization to that query endpoint.
91 + $post = [];
92 + } else {
93 + $body = array_key_exists('body', $request)
94 + ? (string) $request['body']
95 + : ( $this->is_json_content_type($server) ? call_user_func($this->body_reader) : '' );
96 + }
97 + $config = $request['config'] ?? $this->parse_http_config(
98 + $get,
99 + $post,
100 + $server,
101 + $body
102 + );
103 + $config = $this->normalize_config($config, $server);
104 + $this->dispatch($config);
105 + }
106 +
107 + /**
108 + * Emits CORS headers and terminates OPTIONS preflight requests.
109 + *
110 + * Must be called BEFORE authentication runs — browsers send
111 + * preflight OPTIONS without credentials, so the consumer must not
112 + * require auth headers before this check passes.
113 + *
114 + * A wildcard origin ('*') is safe when authentication happens
115 + * out-of-band (e.g., HMAC with a pre-shared secret) — an attacker
116 + * without the secret cannot export anything regardless of origin.
117 + *
118 + * For OPTIONS requests this terminates the process. For all other
119 + * methods it just emits the headers and returns so the caller can
120 + * continue with authentication and dispatch.
121 + *
122 + * @param string|true $origin The Access-Control-Allow-Origin value,
123 + * or true as a shorthand for '*'.
124 + * @param string $allow_headers The Access-Control-Allow-Headers value.
125 + * Defaults to '*' to permit all headers. Pass a comma-separated
126 + * list to restrict (e.g. 'Content-Type, X-Auth-Signature').
127 + * @param array<string, mixed> $server Request server array (defaults to $_SERVER).
128 + * @param array<string, callable>|null $io Optional overrides for
129 + * 'header' (emitter) and 'exit' (preflight terminator). Used
130 + * only by tests.
131 + */
132 + public static function handle_cors_headers_and_terminate_on_options(
133 + $origin = '*',
134 + string $allow_headers = '*',
135 + array $server = [],
136 + ?array $io = null
137 + ): void {
138 + if ($origin === true) {
139 + $origin = '*';
140 + }
141 + if (!is_string($origin) || $origin === '') {
142 + throw new InvalidArgumentException(
143 + 'CORS origin must be a non-empty string or true'
144 + );
145 + }
146 +
147 + $emit_header = ($io['header'] ?? null) ?? static function (string $h): void {
148 + header($h);
149 + };
150 + $terminate = ($io['exit'] ?? null) ?? static function (): void {
151 + exit;
152 + };
153 +
154 + $emit_header('Access-Control-Allow-Origin: ' . $origin);
155 + $emit_header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
156 + $emit_header('Access-Control-Allow-Headers: ' . $allow_headers);
157 +
158 + $request_server = $server === [] ? $_SERVER : $server;
159 + $method = isset($request_server['REQUEST_METHOD']) ? (string) $request_server['REQUEST_METHOD'] : '';
160 + if (strtoupper($method) !== 'OPTIONS') {
161 + return;
162 + }
163 +
164 + $emit_header('Allow: GET, POST, OPTIONS');
165 + $terminate();
166 + }
167 +
168 + /**
169 + * One-call convenience entry point: loads export.php, constructs
170 + * the server, and dispatches the current request.
171 + *
172 + * Equivalent to:
173 + *
174 + * require_once __DIR__ . '/export.php';
175 + * $server = new Site_Export_HTTP_Server($options);
176 + * $server->handle_request();
177 + *
178 + * export.php is only required once. Callers that need to run CORS
179 + * or their own authentication must do that before calling this method.
180 + *
181 + * @param array<string, mixed> $options Forwarded to the constructor.
182 + * @throws Exception If request parsing or endpoint dispatch fails.
183 + */
184 + public static function serve(array $options = []): void {
185 + // endpoint_preflight is defined by export.php — use it as a
186 + // cheap sentinel to detect whether the runtime is already
187 + // loaded. require_once would be safe either way, but this
188 + // avoids re-running the stat() on hot paths.
189 + if (!function_exists('endpoint_preflight')) {
190 + require_once __DIR__ . '/export.php';
191 + }
192 +
193 + $server = new self($options);
194 + $server->handle_request();
195 + }
196 +
197 + /**
198 + * @param array<string, mixed> $get
199 + * @param array<string, mixed> $post
200 + * @param array<string, mixed> $server
201 + * @return array<string, mixed>
202 + */
203 + public function parse_http_config(array $get = [], array $post = [], array $server = [], string $body = ''): array {
204 + $config = [];
205 + $params = array_merge($get, $post);
206 +
207 + $content_type = $server['CONTENT_TYPE'] ?? '';
208 + $content_type_main = strtolower(trim((string) strtok((string) $content_type, ';')));
209 + if ($content_type_main === 'application/json' && $body !== '') {
210 + $json_data = json_decode($body, true);
211 + if (is_array($json_data)) {
212 + $params = array_merge($json_data, $params);
213 + }
214 + }
215 +
216 + foreach ($params as $key => $value) {
217 + $key = str_replace('-', '_', (string) $key);
218 +
219 + if (
220 + in_array($key, [
221 + 'max_execution_time',
222 + 'min_ctime',
223 + 'chunk_size',
224 + 'fragments_per_batch',
225 + 'batch_size',
226 + 'db_query_time_limit',
227 + 'tables_per_batch',
228 + ], true)
229 + ) {
230 + $value = (int) $value;
231 + } elseif (in_array($key, ['memory_threshold'], true)) {
232 + $value = (float) $value;
233 + } elseif (in_array($key, ['create_table_query', 'db_unbuffered', 'follow_symlinks'], true)) {
234 + $value = filter_var($value, FILTER_VALIDATE_BOOLEAN);
235 + } elseif ($key === 'paths' && is_string($value)) {
236 + $decoded = json_decode($value, true);
237 + if (is_array($decoded)) {
238 + $value = $decoded;
239 + }
240 + }
241 +
242 + $config[$key] = $value;
243 + }
244 +
245 + return $config;
246 + }
247 +
248 + /**
249 + * @param array<string, mixed> $config
250 + * @param array<string, mixed> $server
251 + * @return array<string, mixed>
252 + */
253 + public function normalize_config(array $config, array $server = []): array {
254 + if (
255 + $this->default_directory !== null &&
256 + !isset($config['directory'])
257 + ) {
258 + $config['directory'] = $this->default_directory;
259 + }
260 +
261 + if (!isset($config['cursor']) && isset($server[$this->cursor_header_name])) {
262 + $config['cursor'] = $server[$this->cursor_header_name];
263 + }
264 +
265 + if (isset($config['cursor']) && $config['cursor'] !== '' && $config['cursor'] !== null) {
266 + $config['cursor'] = $this->decode_cursor((string) $config['cursor']);
267 + }
268 +
269 + $endpoint = $config['endpoint'] ?? null;
270 + if (!is_string($endpoint) || $endpoint === '') {
271 + throw new InvalidArgumentException(
272 + "endpoint parameter is required. Valid endpoints: " . $this->get_valid_endpoints_message()
273 + );
274 + }
275 +
276 + return $config;
277 + }
278 +
279 + public function decode_cursor(string $cursor_b64): string {
280 + $cursor_json = base64_decode($cursor_b64, true);
281 + if ($cursor_json === false) {
282 + throw new InvalidArgumentException(
283 + 'Cursor must be base64-encoded. Received invalid base64: ' . substr($cursor_b64, 0, 50)
284 + );
285 + }
286 +
287 + $cursor_data = json_decode($cursor_json, true);
288 + if ($cursor_data === null && json_last_error() !== JSON_ERROR_NONE) {
289 + throw new InvalidArgumentException(
290 + 'Cursor must be valid JSON after base64 decoding. JSON error: ' . json_last_error_msg()
291 + );
292 + }
293 +
294 + return $cursor_json;
295 + }
296 +
297 + /**
298 + * @param array<string, mixed> $config
299 + * @return mixed
300 + */
301 + public function create_resource_budget(array $config) {
302 + return call_user_func($this->budget_factory, $config);
303 + }
304 +
305 + /**
306 + * @param array<string, mixed> $config
307 + * @param mixed $budget
308 + */
309 + public function dispatch(array $config, $budget = null): void {
310 + $endpoint = $config['endpoint'] ?? null;
311 + if (!is_string($endpoint) || $endpoint === '') {
312 + throw new InvalidArgumentException(
313 + "endpoint parameter is required. Valid endpoints: " . $this->get_valid_endpoints_message()
314 + );
315 + }
316 +
317 + if (!isset($this->handlers[$endpoint])) {
318 + throw new InvalidArgumentException(
319 + "Invalid endpoint: '{$endpoint}'. Valid endpoints: " . $this->get_valid_endpoints_message()
320 + );
321 + }
322 +
323 + $handler = $this->handlers[$endpoint];
324 + if (self::is_push_endpoint($endpoint)) {
325 + if (PHP_VERSION_ID < 70200) {
326 + http_response_code(503);
327 + header('Cache-Control: no-store, no-cache, must-revalidate, max-age=0');
328 + header('Pragma: no-cache');
329 + header('Expires: 0');
330 + header('Content-Type: application/json');
331 + echo json_encode([
332 + 'status' => 'rejected',
333 + 'reason' => 'push_disabled',
334 + 'detail' => 'Push endpoints require PHP 7.2 or newer; observed PHP ' . PHP_VERSION . '.',
335 + ]);
336 + return;
337 + }
338 + call_user_func($handler, $config);
339 + return;
340 + }
341 + if ($endpoint === 'preflight') {
342 + call_user_func($handler, $config);
343 + return;
344 + }
345 +
346 + if ($budget === null) {
347 + $budget = $this->create_resource_budget($config);
348 + }
349 +
350 + call_user_func($handler, $config, $budget);
351 + }
352 +
353 + public static function is_push_endpoint(string $endpoint): bool {
354 + $push_endpoint_methods = self::PUSH_ENDPOINT_METHODS;
355 + return isset($push_endpoint_methods[$endpoint]);
356 + }
357 +
358 + /**
359 + * @return array<string, callable>
360 + */
361 + private function default_handlers(): array {
362 + $handlers = [
363 + 'file_index' => 'endpoint_file_index',
364 + 'file_fetch' => 'endpoint_file_fetch',
365 + 'sql_chunk' => 'endpoint_sql_chunk',
366 + 'db_index' => 'endpoint_db_index',
367 + 'preflight' => 'endpoint_preflight',
368 + ];
369 + if ($this->push_endpoints !== null) {
370 + foreach (self::PUSH_ENDPOINT_METHODS as $endpoint => $method) {
371 + $handlers[$endpoint] = [$this->push_endpoints, $method];
372 + }
373 + }
374 + return $handlers;
375 + }
376 +
377 + private function is_json_content_type(array $server): bool {
378 + $content_type = (string) ( $server['CONTENT_TYPE'] ?? '' );
379 + return strtolower(trim( (string) strtok($content_type, ';'))) === 'application/json';
380 + }
381 +
382 + /**
383 + * @param array<string, mixed> $config
384 + * @return mixed
385 + */
386 + private function default_budget_factory(array $config) {
387 + $max_execution_time = require_int_range(
388 + 'max_execution_time',
389 + (int) ($config['max_execution_time'] ?? 5),
390 + 1,
391 + 60
392 + );
393 + $memory_threshold = require_float_range(
394 + 'memory_threshold',
395 + (float) ($config['memory_threshold'] ?? 0.8),
396 + 0.1,
397 + 0.95
398 + );
399 +
400 + $memory_limit = ini_get('memory_limit');
401 + $max_memory = $memory_limit === '-1' ? PHP_INT_MAX : parse_size((string) $memory_limit);
402 +
403 + return new ResourceBudget(
404 + microtime(true),
405 + $max_execution_time,
406 + $max_memory,
407 + $memory_threshold
408 + );
409 + }
410 +
411 + private function get_valid_endpoints_message(): string {
412 + return "'" . implode("', '", array_keys($this->handlers)) . "'";
413 + }
414 +}