PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / 16.1-beta
Jetpack – WP Security, Backup, Speed, & Growth v16.1-beta
16.1-beta 16.1-beta.2 16.1-beta.3 16.1-a.5 16.1-a.3 16.0.1 16.1-a.1 16.0 16.0-beta 16.0-a.7 16.0-a.5 15.9.1 16.0-a.3 16.0-a.1 15.9 15.9-beta 15.9-a.7 15.9-a.5 15.9-a.3 15.9-a.1 15.8 15.8-beta 15.8-a.7 15.8-a.5 5.2.5 5.3.4 5.4.4 5.5.5 5.6.5 5.7.5 5.8.4 5.9.4 6.0.4 6.1 6.1.1 6.1.2 6.1.3 6.1.4 6.1.5 6.2 6.2.1 6.2.2 6.2.3 6.2.4 6.2.5 6.3 6.3.1 6.3.2 6.3.3 6.3.4 6.3.5 6.3.6 6.3.7 6.4 6.4.1 6.4.2 6.4.3 6.4.4 6.4.5 6.4.6 6.5 6.5.1 6.5.2 6.5.3 6.5.4 6.6 6.6.1 6.6.2 6.6.3 6.6.4 6.6.5 6.7 6.7.1 6.7.2 6.7.3 6.7.4 6.8 6.8.1 6.8.2 6.8.3 6.8.4 6.8.5 6.9 6.9.1 6.9.2 6.9.3 6.9.4 7.0 7.0.1 7.0.2 7.0.3 7.0.4 7.0.5 7.1 7.1.1 7.1.2 7.1.3 7.1.4 7.1.5 7.2 7.2.1 7.2.1.1 7.2.2 7.2.3 7.2.4 7.2.5 7.3 7.3.0.1 7.3.1 7.3.1.1 7.3.2 7.3.3 7.3.4 7.3.5 7.4 7.4.1 7.4.2 7.4.3 7.4.4 7.4.5 7.5 7.5.0.1 7.5.1 7.5.2 7.5.3 7.5.4 7.5.5 7.5.6 7.5.7 7.6 7.6.1 7.6.2 7.6.3 7.6.4 7.7 7.7.1 7.7.2 7.7.3 7.7.4 7.7.5 7.7.6 7.8 7.8.1 7.8.2 7.8.3 7.8.4 7.9 7.9.1 7.9.2 7.9.3 7.9.4 8.0 8.0.1 8.0.2 8.0.3 8.1 8.1.1 8.1.2 8.1.3 8.1.4 8.2 8.2.0.1 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.2.6 8.3 8.3.1 8.3.2 8.3.3 8.4 8.4.1 8.4.2 8.4.3 8.4.4 8.4.5 8.5 8.5.1 8.5.2 8.5.3 8.6 8.6.1 8.6.2 8.6.3 8.6.4 8.7 8.7.0.1 8.7.1 8.7.2 8.7.3 8.7.4 8.8 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.9 8.9.1 8.9.2 8.9.3 8.9.4 9.0 9.0.1 9.0.2 9.0.3 9.0.4 9.0.5 9.1 9.1.1 9.1.2 9.1.3 9.2 9.2.1 9.2.2 9.2.3 9.2.4 9.3 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.4 9.4.1 9.4.2 9.4.3 9.4.4 9.5 9.5.1 9.5.2 9.5.3 9.5.4 9.5.5 9.6 9.6.1 9.6.2 9.6.3 9.6.4 9.7 9.7.1 9.7.2 15.7-beta.2 9.7.3 15.7.1 9.8 15.8-a.1 9.8.1 15.8-a.3 9.8.2 2.0.9 9.8.3 2.1.7 9.9 2.2.10 9.9.1 2.3.10 9.9.2 2.4.7 9.9.3 2.5.5 2.6.6 2.7.5 2.8.5 2.9.6 3.0.6 3.1.5 3.2.5 3.3.6 3.4.6 3.5.6 3.6.4 3.7.5 3.8.5 3.9.10 4.0.7 4.1.4 4.2.5 4.3.5 4.4.5 4.5.3 4.6.3 4.7.4 4.8.5 4.9.3 5.0.3 5.1.4 trunk 10.0 10.0.1 10.0.2 10.1 10.1.1 10.1.2 10.2 10.2.1 10.2.2 10.2.3 10.3 10.3.1 10.3.2 10.4 10.4.1 10.4.2 10.5 10.5.1 10.5.2 10.5.3 10.6 10.6.1 10.6.2 10.7 10.7.1 10.7.2 10.8 10.8.1 10.8.2 10.9 10.9.1 10.9.2 10.9.3 11.0 11.0.1 11.0.2 11.1 11.1.1 11.1.2 11.1.3 11.1.4 11.2 11.2.1 11.2.2 11.3 11.3.1 11.3.2 11.3.3 11.3.4 11.4 11.4.1 11.4.2 11.5 11.5.1 11.5.2 11.5.3 11.6 11.6.1 11.6.2 11.7 11.7.1 11.7.2 11.7.3 11.8 11.8.3 11.8.4 11.8.5 11.8.6 11.9 11.9.1 11.9.2 11.9.3 12.0 12.0.1 12.0.2 12.1 12.1.1 12.1.2 12.2 12.2.1 12.2.2 12.3 12.3.1 12.4 12.4.1 12.5 12.5.1 12.6 12.6.1 12.6.2 12.6.3 12.7 12.7.1 12.7.2 12.8 12.8.1 12.8.2 12.9 12.9.1 12.9.2 12.9.3 12.9.4 13.0 13.0.1 13.1 13.1.1 13.1.2 13.1.3 13.1.4 13.2 13.2.1 13.2.2 13.2.3 13.3 13.3.1 13.3.2 13.4 13.4.1 13.4.2 13.4.3 13.4.4 13.5 13.5.1 13.6 13.6.1 13.7 13.7.1 13.8 13.8.1 13.8.2 13.9 13.9.1 14.0 14.1 14.2 14.2.1 14.3 14.4 14.4.1 14.5 14.6 14.7 14.8 14.9 14.9.1 15.0 15.0.1 15.0.2 15.1 15.1.1 15.2 15.3 15.3.1 15.4 15.5 15.6 15.7 15.7-a.1 15.7-a.3 15.7-a.5 15.7-a.7 15.7-beta
jetpack / vendor / wp-php-toolkit / reprint-exporter / src / class-hmac-server.php
jetpack / vendor / wp-php-toolkit / reprint-exporter / src Last commit date
class-file-tree-producer.php 6 days ago class-hmac-client.php 6 days ago class-hmac-server.php 6 days ago class-http-server.php 6 days ago class-mysql-dump-producer.php 6 days ago class-pdo-polyfill.php 6 days ago class-sqlite-driver-pdo.php 6 days ago class-staged-artifacts.php 6 days ago class-staged-endpoints.php 6 days ago class-staged-push-stream-protocol.php 6 days ago class-wpdb-driver-pdo.php 6 days ago export.php 6 days ago utils.php 6 days ago
class-hmac-server.php
354 lines
1 <?php
2
3 /**
4 * HMAC Server for the Site Export API.
5 *
6 * This class verifies the HMAC-authenticated control requests generated by
7 * Site_Export_HMAC_Client. It validates the required X-Auth-* headers and
8 * checks request freshness before any caller-provided body hash is evaluated.
9 */
10 final class Site_Export_HMAC_Server {
11
12 /**
13 * Value of the X-Auth-Content-Hash header when the request body is
14 * deliberately not signed: this literal string stands where a body hash
15 * would otherwise be. Must match Site_Export_HMAC_Client::UNSIGNED_PAYLOAD.
16 */
17 public const UNSIGNED_PAYLOAD = 'UNSIGNED-PAYLOAD';
18
19 private const DEFAULT_MAX_CONTROL_BODY_BYTES = 1048576;
20
21 /** @var string */
22 private $secret;
23
24 /** @var int */
25 private $timestamp_tolerance;
26
27 public function __construct(string $secret, int $timestamp_tolerance = 300) {
28 $this->secret = $secret;
29 $this->timestamp_tolerance = $timestamp_tolerance;
30 }
31
32 /**
33 * Verify a small command request with a bounded body.
34 *
35 * HMAC is the guard for small protocol commands such as preflight, session
36 * start, plan confirmation, and abort/resume. Large data uploads should use
37 * authenticated sessions plus per-chunk hashes instead of signing one
38 * multi-megabyte request body.
39 */
40 public function verify_control_request(array $headers = [], string $body = '', ?float $now = null, ?int $max_body_bytes = null): ?string {
41 $body_limit = $max_body_bytes ?? self::DEFAULT_MAX_CONTROL_BODY_BYTES;
42 if (strlen($body) > $body_limit) {
43 return sprintf(
44 'HMAC control request body exceeds %d bytes',
45 $body_limit
46 );
47 }
48
49 return $this->verify($headers, $body, [], $now);
50 }
51
52 /**
53 * Verify a request using explicit inputs.
54 *
55 * Returns null on success, or an error string on failure. This convenience
56 * method receives the full body as a string and is only appropriate for
57 * compatibility with existing small request flows. New protocol code should
58 * use verify_control_request() for HMAC-protected commands and avoid
59 * HMAC-signing large data uploads.
60 *
61 * When $files is non-empty, the content hash is computed from uploaded file
62 * contents rather than $body so multipart uploads verify consistently.
63 */
64 public function verify(array $headers = [], ?string $body = null, array $files = [], ?float $now = null): ?string {
65 return $this->verify_content_hash_callback(
66 $headers,
67 function () use ($body, $files): string {
68 return $this->compute_received_content_hash($body, $files);
69 },
70 $now
71 );
72 }
73
74 /**
75 * Verify a request when the caller already computed the received digest.
76 *
77 * This exists for bounded protocol code that has already computed the body
78 * digest. It does not read a request body. Push chunk uploads should use
79 * session/request capabilities plus per-chunk hashes instead of routing
80 * large bodies through HMAC verification.
81 */
82 public function verify_content_hash(array $headers, string $received_content_hash, ?float $now = null): ?string {
83 return $this->verify_content_hash_callback(
84 $headers,
85 function () use ($received_content_hash): string {
86 return $received_content_hash;
87 },
88 $now
89 );
90 }
91
92 /**
93 * Verify the signed content hash header before the request body is read.
94 *
95 * This only authenticates the claim in X-Auth-Content-Hash; it does not
96 * authenticate any request bytes by itself. Callers must still compare the
97 * returned hash with the digest of a bounded control payload before acting
98 * on that payload. Do not use this as a large-upload authentication scheme.
99 *
100 * @return array{error:?string,content_hash:?string}
101 */
102 public function verify_signed_content_hash(array $headers, ?float $now = null): array {
103 $auth = $this->collect_auth_headers($headers);
104 $auth_error = $this->verify_auth_headers($auth, $now);
105 if ($auth_error !== null) {
106 return [
107 'error' => $auth_error,
108 'content_hash' => null,
109 ];
110 }
111
112 return [
113 'error' => null,
114 'content_hash' => $auth['content_hash'],
115 ];
116 }
117
118 /**
119 * The received content hash must not be computed until the timestamp,
120 * nonce, and signature checks pass: bodies and multipart uploads can be
121 * large and live on slow disks, and unauthenticated callers should not be
122 * able to force the server to hash them.
123 */
124 private function verify_content_hash_callback(array $headers, callable $received_content_hash, ?float $now = null): ?string {
125 $auth = $this->collect_auth_headers($headers);
126 $auth_error = $this->verify_auth_headers($auth, $now);
127 if ($auth_error !== null) {
128 return $auth_error;
129 }
130
131 try {
132 $actual_content_hash = $received_content_hash();
133 } catch (RuntimeException $e) {
134 return $e->getMessage();
135 }
136
137 if (!hash_equals($auth['content_hash'], $actual_content_hash)) {
138 return 'Content hash mismatch: body was modified in transit';
139 }
140
141 return null;
142 }
143
144 /**
145 * Verify a request whose body is deliberately not signed.
146 *
147 * Instead of a body hash, the signature covers exactly four values:
148 * the nonce, the timestamp, the HTTP method, and the request target
149 * (the "path?query" part of the URL). A request body of any size can then
150 * stream through without either side hashing it, and a captured set of
151 * auth headers still cannot be reused for a different endpoint or
152 * method. Protecting the body from tampering is TLS's job.
153 *
154 * The X-Auth-Content-Hash header must be the literal string
155 * UNSIGNED-PAYLOAD. Because of that, headers signed for this check can
156 * never pass the body-signed checks and vice versa — the two signatures
157 * are computed over strings that can never be equal. Each route decides
158 * which check it calls, so a client cannot make a command endpoint
159 * that requires verify_control_request() accept this body-less check.
160 *
161 * @param string $request_target The "path?query" form of the request URL.
162 */
163 public function verify_envelope(array $headers, string $method, string $request_target, ?float $now = null): ?string {
164 $auth = $this->collect_auth_headers($headers);
165 if ($auth['content_hash'] !== self::UNSIGNED_PAYLOAD) {
166 return 'Envelope verification requires the literal UNSIGNED-PAYLOAD content hash';
167 }
168
169 $freshness_error = $this->verify_freshness($auth, $now);
170 if ($freshness_error !== null) {
171 return $freshness_error;
172 }
173
174 $message = $auth['nonce'] . $auth['timestamp'] . self::UNSIGNED_PAYLOAD . "\n" . strtoupper($method) . "\n" . $request_target;
175 $expected_signature = hash_hmac('sha256', $message, $this->secret);
176 if (!hash_equals($expected_signature, $auth['signature'])) {
177 return 'HMAC signature verification failed';
178 }
179
180 return null;
181 }
182
183 /**
184 * Verify the current PHP request using superglobals.
185 *
186 * Returns null on success, or an error string on failure. This legacy
187 * convenience path buffers php://input and should only be used for small
188 * request bodies. New command endpoints should bound the body and call
189 * verify_control_request(). Large data routes should not use whole-body
190 * HMAC verification.
191 */
192 public function verify_globals(?float $now = null): ?string {
193 $body = file_get_contents('php://input');
194 if ($body === false) {
195 $body = '';
196 }
197
198 return $this->verify($this->collect_global_headers(), $body, $_FILES, $now);
199 }
200
201 private function collect_auth_headers(array $headers): array {
202 return [
203 'signature' => $this->get_header($headers, 'X-Auth-Signature'),
204 'nonce' => $this->get_header($headers, 'X-Auth-Nonce'),
205 'timestamp' => $this->get_header($headers, 'X-Auth-Timestamp'),
206 'content_hash' => $this->get_header($headers, 'X-Auth-Content-Hash'),
207 ];
208 }
209
210 private function verify_auth_headers(array $auth, ?float $now = null): ?string {
211 $freshness_error = $this->verify_freshness($auth, $now);
212 if ($freshness_error !== null) {
213 return $freshness_error;
214 }
215
216 $expected_signature = hash_hmac('sha256', $auth['nonce'] . $auth['timestamp'] . $auth['content_hash'], $this->secret);
217 if (!hash_equals($expected_signature, $auth['signature'])) {
218 return 'HMAC signature verification failed';
219 }
220
221 return null;
222 }
223
224 /**
225 * Checks header presence, timestamp tolerance, and nonce length —
226 * everything except the signature. Body-signed and envelope-signed
227 * requests compute their signatures over different strings, so each
228 * caller does its own signature check after this passes.
229 */
230 private function verify_freshness(array $auth, ?float $now = null): ?string {
231 $signature = $auth['signature'];
232 $nonce = $auth['nonce'];
233 $timestamp = $auth['timestamp'];
234 $signed_content_hash = $auth['content_hash'];
235 if ($signature === null || $signature === '') {
236 return 'Missing X-Auth-Signature header';
237 }
238 if ($nonce === null || $nonce === '') {
239 return 'Missing X-Auth-Nonce header';
240 }
241 if ($timestamp === null || $timestamp === '') {
242 return 'Missing X-Auth-Timestamp header';
243 }
244 if ($signed_content_hash === null || $signed_content_hash === '') {
245 return 'Missing X-Auth-Content-Hash header';
246 }
247
248 if (!is_numeric($timestamp)) {
249 return 'Invalid timestamp format';
250 }
251
252 $request_time = (float) $timestamp;
253 $current_time = $now ?? microtime(true);
254 $time_diff = abs($current_time - $request_time);
255
256 if ($time_diff > $this->timestamp_tolerance) {
257 return sprintf(
258 'Request timestamp expired. Difference: %.2f seconds, max allowed: %d seconds',
259 $time_diff,
260 $this->timestamp_tolerance
261 );
262 }
263
264 if (strlen($nonce) < 16) {
265 return 'Nonce must be at least 16 characters';
266 }
267
268 return null;
269 }
270
271 private function collect_global_headers(): array {
272 $headers = [];
273
274 if (function_exists('getallheaders')) {
275 $all_headers = getallheaders();
276 if (is_array($all_headers)) {
277 $headers = $all_headers;
278 }
279 }
280
281 foreach ($_SERVER as $key => $value) {
282 if (strpos($key, 'HTTP_') !== 0 || !is_string($value)) {
283 continue;
284 }
285
286 $headers[$key] = $value;
287 }
288
289 return $headers;
290 }
291
292 private function get_header(array $headers, string $name): ?string {
293 foreach ($headers as $key => $value) {
294 if (!is_string($value)) {
295 continue;
296 }
297
298 if (strcasecmp($key, $name) === 0) {
299 return $value;
300 }
301
302 if (strcasecmp($key, 'HTTP_' . strtoupper(str_replace('-', '_', $name))) === 0) {
303 return $value;
304 }
305 }
306
307 return null;
308 }
309
310 private function compute_received_content_hash(?string $body, array $files): string {
311 if (empty($files)) {
312 return hash('sha256', $body ?? '');
313 }
314
315 $context = hash_init('sha256');
316 $this->append_file_hashes($context, $files);
317 return hash_final($context);
318 }
319
320 /**
321 * Walk a PHP $_FILES-style structure in a deterministic order.
322 */
323 private function append_file_hashes($context, array $files): void {
324 ksort($files);
325
326 foreach ($files as $file_info) {
327 if (!is_array($file_info)) {
328 continue;
329 }
330
331 $tmp_name = $file_info['tmp_name'] ?? null;
332 $this->append_tmp_name_hash($context, $tmp_name);
333 }
334 }
335
336 private function append_tmp_name_hash($context, $tmp_name): void {
337 if (is_array($tmp_name)) {
338 ksort($tmp_name);
339 foreach ($tmp_name as $nested_tmp_name) {
340 $this->append_tmp_name_hash($context, $nested_tmp_name);
341 }
342 return;
343 }
344
345 if (!is_string($tmp_name) || $tmp_name === '' || !is_readable($tmp_name)) {
346 return;
347 }
348
349 if (!@hash_update_file($context, $tmp_name)) {
350 throw new RuntimeException('Cannot hash uploaded file.');
351 }
352 }
353 }
354