SignedQueryMiddleware::process()   A
last analyzed

Complexity

Conditions 2
Paths 2

Size

Total Lines 7
Code Lines 3

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 4
CRAP Score 2

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 3
c 1
b 0
f 0
dl 0
loc 7
ccs 4
cts 4
cp 1
rs 10
cc 2
nc 2
nop 2
crap 2
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Ecodev\Felix\Middleware;
6
7
use Cake\Chronos\Chronos;
8
use Ecodev\Felix\Validator\IPRange;
9
use Exception;
10
use Laminas\Diactoros\CallbackStream;
11
use Psr\Http\Message\ResponseInterface;
12
use Psr\Http\Message\ServerRequestInterface;
13
use Psr\Http\Server\MiddlewareInterface;
14
use Psr\Http\Server\RequestHandlerInterface;
15
16
/**
17
 * Validate that the GraphQL query contains a valid signature in the `X-Signature` HTTP header.
18
 *
19
 * The signature payload is the GraphQL operation (or operations in case of batching). That means that the query itself
20
 * and the variables are signed. But it specifically does **not** include uploaded files.
21
 *
22
 * The signature is valid for a limited time only, ~15 minutes.
23
 *
24
 * The signature syntax is:
25
 *
26
 * ```ebnf
27
 * signature = "v1", ".", timestamp, ".", hash
28
 * timestamp = current unix time
29
 * hash = HMAC_SHA256( payload )
30
 * payload = timestamp, graphql operations
31
 * ```
32
 */
33
final class SignedQueryMiddleware implements MiddlewareInterface
34
{
35 45
    public function __construct(
36
        private readonly array $keys,
37
        private readonly array $allowedIps,
38
        private readonly bool $required = true
39
    ) {
40 45
        if ($this->required && !$this->keys) {
41 1
            throw new Exception('Signed queries are required, but no keys are configured');
42
        }
43
    }
44
45 44
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
46
    {
47 44
        if ($this->required) {
48 22
            $request = $this->verify($request);
49
        }
50
51 33
        return $handler->handle($request);
52
    }
53
54 22
    private function verify(ServerRequestInterface $request): ServerRequestInterface
55
    {
56 22
        $signature = $request->getHeader('X-Signature')[0] ?? '';
57 22
        if (!$signature) {
58 4
            if ($this->isAllowedIp($request) || $this->isBingBot($request)) {
59 2
                return $request;
60
            }
61
62 2
            throw new Exception('Missing `X-Signature` HTTP header in signed query', 403);
63
        }
64
65 18
        if (preg_match('~^v1\.(?<timestamp>\d{10})\.(?<hash>[0-9a-f]{64})$~', $signature, $m)) {
66 15
            $timestamp = $m['timestamp'];
67 15
            $hash = $m['hash'];
68
69 15
            $this->verifyTimestamp($request, $timestamp);
70
71 12
            return $this->verifyHash($request, $timestamp, $hash);
72
        }
73
74 3
        throw new Exception('Invalid `X-Signature` HTTP header in signed query', 403);
75
    }
76
77 15
    private function verifyTimestamp(ServerRequestInterface $request, string $timestamp): void
78
    {
79 15
        $now = Chronos::now()->timestamp;
80 15
        $leeway = 15 * 900; // 15 minutes
81 15
        $past = $now - $leeway;
82 15
        $future = $now + $leeway;
83 15
        $isExpired = $timestamp < $past || $timestamp > $future;
84 15
        if ($isExpired && !$this->isGoogleBot($request)) {
85 3
            throw new Exception('Signed query is expired', 403);
86
        }
87
    }
88
89 12
    private function verifyHash(ServerRequestInterface $request, string $timestamp, string $hash): ServerRequestInterface
90
    {
91 12
        ['request' => $request, 'operations' => $operations] = $this->getOperations($request);
92 12
        $payload = $timestamp . $operations;
93
94 12
        foreach ($this->keys as $key) {
95 12
            $computedHash = hash_hmac('sha256', $payload, $key);
96 12
            if ($hash === $computedHash) {
97 9
                return $request;
98
            }
99
        }
100
101 3
        throw new Exception('Invalid signed query', 403);
102
    }
103
104
    /**
105
     * @return array{request: ServerRequestInterface, operations: string}
106
     */
107 12
    private function getOperations(ServerRequestInterface $request): array
108
    {
109 12
        $contents = $request->getBody()->getContents();
110
111 12
        if ($contents) {
112 8
            return [
113
                // Pseudo-rewind the request, even if non-rewindable, so the next
114
                // middleware still accesses the stream from the beginning
115 8
                'request' => $request->withBody(new CallbackStream(fn () => $contents)),
116 8
                'operations' => $contents,
117 8
            ];
118
        }
119
120 4
        $parsedBody = $request->getParsedBody();
121
122 4
        return [
123 4
            'request' => $request,
124 4
            'operations' => $parsedBody['operations'] ?? '',
125 4
        ];
126
    }
127
128 4
    private function isAllowedIp(ServerRequestInterface $request): bool
129
    {
130 4
        $remoteAddress = $request->getServerParams()['REMOTE_ADDR'] ?? '';
131
132 4
        if (!$remoteAddress || !is_string($remoteAddress)) {
133 1
            return false;
134
        }
135
136 3
        return IPRange::matches($remoteAddress, $this->allowedIps);
137
    }
138
139 4
    private function isGoogleBot(ServerRequestInterface $request): bool
140
    {
141 4
        $remoteAddress = $request->getServerParams()['REMOTE_ADDR'] ?? '';
142
143 4
        if (!$remoteAddress || !is_string($remoteAddress)) {
144 2
            return false;
145
        }
146
147
        // Source is https://developers.google.com/search/apis/ipranges/googlebot.json
148
        // Use this one-line command to fetch new list:
149
        //
150
        // ```bash
151
        // php -r 'echo PHP_EOL . implode(PHP_EOL, array_map(fn (array $r) => chr(39) . ($r["ipv6Prefix"] ?? $r["ipv4Prefix"]) . chr(39) . ",", json_decode(file_get_contents("https://developers.google.com/search/apis/ipranges/googlebot.json"), true)["prefixes"])) . PHP_EOL;'
152
        // ```
153 2
        $googleBotIps = [
154 2
            '2001:4860:4801:10::/64',
155 2
            '2001:4860:4801:11::/64',
156 2
            '2001:4860:4801:12::/64',
157 2
            '2001:4860:4801:13::/64',
158 2
            '2001:4860:4801:14::/64',
159 2
            '2001:4860:4801:15::/64',
160 2
            '2001:4860:4801:16::/64',
161 2
            '2001:4860:4801:17::/64',
162 2
            '2001:4860:4801:18::/64',
163 2
            '2001:4860:4801:19::/64',
164 2
            '2001:4860:4801:1a::/64',
165 2
            '2001:4860:4801:1b::/64',
166 2
            '2001:4860:4801:1c::/64',
167 2
            '2001:4860:4801:1d::/64',
168 2
            '2001:4860:4801:1e::/64',
169 2
            '2001:4860:4801:1f::/64',
170 2
            '2001:4860:4801:20::/64',
171 2
            '2001:4860:4801:21::/64',
172 2
            '2001:4860:4801:22::/64',
173 2
            '2001:4860:4801:23::/64',
174 2
            '2001:4860:4801:24::/64',
175 2
            '2001:4860:4801:25::/64',
176 2
            '2001:4860:4801:26::/64',
177 2
            '2001:4860:4801:27::/64',
178 2
            '2001:4860:4801:28::/64',
179 2
            '2001:4860:4801:29::/64',
180 2
            '2001:4860:4801:2::/64',
181 2
            '2001:4860:4801:2a::/64',
182 2
            '2001:4860:4801:2b::/64',
183 2
            '2001:4860:4801:2c::/64',
184 2
            '2001:4860:4801:2d::/64',
185 2
            '2001:4860:4801:2e::/64',
186 2
            '2001:4860:4801:2f::/64',
187 2
            '2001:4860:4801:30::/64',
188 2
            '2001:4860:4801:31::/64',
189 2
            '2001:4860:4801:32::/64',
190 2
            '2001:4860:4801:33::/64',
191 2
            '2001:4860:4801:34::/64',
192 2
            '2001:4860:4801:35::/64',
193 2
            '2001:4860:4801:36::/64',
194 2
            '2001:4860:4801:37::/64',
195 2
            '2001:4860:4801:38::/64',
196 2
            '2001:4860:4801:39::/64',
197 2
            '2001:4860:4801:3a::/64',
198 2
            '2001:4860:4801:3b::/64',
199 2
            '2001:4860:4801:3c::/64',
200 2
            '2001:4860:4801:3d::/64',
201 2
            '2001:4860:4801:3e::/64',
202 2
            '2001:4860:4801:3f::/64',
203 2
            '2001:4860:4801:40::/64',
204 2
            '2001:4860:4801:41::/64',
205 2
            '2001:4860:4801:42::/64',
206 2
            '2001:4860:4801:43::/64',
207 2
            '2001:4860:4801:44::/64',
208 2
            '2001:4860:4801:45::/64',
209 2
            '2001:4860:4801:46::/64',
210 2
            '2001:4860:4801:47::/64',
211 2
            '2001:4860:4801:48::/64',
212 2
            '2001:4860:4801:49::/64',
213 2
            '2001:4860:4801:4a::/64',
214 2
            '2001:4860:4801:4b::/64',
215 2
            '2001:4860:4801:4c::/64',
216 2
            '2001:4860:4801:4d::/64',
217 2
            '2001:4860:4801:50::/64',
218 2
            '2001:4860:4801:51::/64',
219 2
            '2001:4860:4801:52::/64',
220 2
            '2001:4860:4801:53::/64',
221 2
            '2001:4860:4801:54::/64',
222 2
            '2001:4860:4801:55::/64',
223 2
            '2001:4860:4801:56::/64',
224 2
            '2001:4860:4801:57::/64',
225 2
            '2001:4860:4801:60::/64',
226 2
            '2001:4860:4801:61::/64',
227 2
            '2001:4860:4801:62::/64',
228 2
            '2001:4860:4801:63::/64',
229 2
            '2001:4860:4801:64::/64',
230 2
            '2001:4860:4801:65::/64',
231 2
            '2001:4860:4801:66::/64',
232 2
            '2001:4860:4801:67::/64',
233 2
            '2001:4860:4801:68::/64',
234 2
            '2001:4860:4801:69::/64',
235 2
            '2001:4860:4801:6a::/64',
236 2
            '2001:4860:4801:6b::/64',
237 2
            '2001:4860:4801:6c::/64',
238 2
            '2001:4860:4801:6d::/64',
239 2
            '2001:4860:4801:6e::/64',
240 2
            '2001:4860:4801:6f::/64',
241 2
            '2001:4860:4801:70::/64',
242 2
            '2001:4860:4801:71::/64',
243 2
            '2001:4860:4801:72::/64',
244 2
            '2001:4860:4801:73::/64',
245 2
            '2001:4860:4801:74::/64',
246 2
            '2001:4860:4801:75::/64',
247 2
            '2001:4860:4801:76::/64',
248 2
            '2001:4860:4801:77::/64',
249 2
            '2001:4860:4801:78::/64',
250 2
            '2001:4860:4801:79::/64',
251 2
            '2001:4860:4801:7a::/64',
252 2
            '2001:4860:4801:7b::/64',
253 2
            '2001:4860:4801:80::/64',
254 2
            '2001:4860:4801:81::/64',
255 2
            '2001:4860:4801:82::/64',
256 2
            '2001:4860:4801:83::/64',
257 2
            '2001:4860:4801:84::/64',
258 2
            '2001:4860:4801:85::/64',
259 2
            '2001:4860:4801:86::/64',
260 2
            '2001:4860:4801:87::/64',
261 2
            '2001:4860:4801:88::/64',
262 2
            '2001:4860:4801:90::/64',
263 2
            '2001:4860:4801:91::/64',
264 2
            '2001:4860:4801:92::/64',
265 2
            '2001:4860:4801:93::/64',
266 2
            '2001:4860:4801:94::/64',
267 2
            '2001:4860:4801:95::/64',
268 2
            '2001:4860:4801:96::/64',
269 2
            '2001:4860:4801:97::/64',
270 2
            '2001:4860:4801:a0::/64',
271 2
            '2001:4860:4801:a1::/64',
272 2
            '2001:4860:4801:a2::/64',
273 2
            '2001:4860:4801:a3::/64',
274 2
            '2001:4860:4801:a4::/64',
275 2
            '2001:4860:4801:a5::/64',
276 2
            '2001:4860:4801:a6::/64',
277 2
            '2001:4860:4801:a7::/64',
278 2
            '2001:4860:4801:a8::/64',
279 2
            '2001:4860:4801:a9::/64',
280 2
            '2001:4860:4801:aa::/64',
281 2
            '2001:4860:4801:ab::/64',
282 2
            '2001:4860:4801:ac::/64',
283 2
            '2001:4860:4801:b0::/64',
284 2
            '2001:4860:4801:b1::/64',
285 2
            '2001:4860:4801:b2::/64',
286 2
            '2001:4860:4801:b3::/64',
287 2
            '2001:4860:4801:b4::/64',
288 2
            '2001:4860:4801:c::/64',
289 2
            '2001:4860:4801:f::/64',
290 2
            '192.178.4.0/27',
291 2
            '192.178.4.128/27',
292 2
            '192.178.4.160/27',
293 2
            '192.178.4.32/27',
294 2
            '192.178.4.64/27',
295 2
            '192.178.4.96/27',
296 2
            '192.178.5.0/27',
297 2
            '192.178.6.0/27',
298 2
            '192.178.6.128/27',
299 2
            '192.178.6.160/27',
300 2
            '192.178.6.192/27',
301 2
            '192.178.6.224/27',
302 2
            '192.178.6.32/27',
303 2
            '192.178.6.64/27',
304 2
            '192.178.6.96/27',
305 2
            '192.178.7.0/27',
306 2
            '192.178.7.128/27',
307 2
            '192.178.7.160/27',
308 2
            '192.178.7.32/27',
309 2
            '192.178.7.64/27',
310 2
            '192.178.7.96/27',
311 2
            '34.100.182.96/28',
312 2
            '34.101.50.144/28',
313 2
            '34.118.254.0/28',
314 2
            '34.118.66.0/28',
315 2
            '34.126.178.96/28',
316 2
            '34.146.150.144/28',
317 2
            '34.147.110.144/28',
318 2
            '34.151.74.144/28',
319 2
            '34.152.50.64/28',
320 2
            '34.154.114.144/28',
321 2
            '34.155.98.32/28',
322 2
            '34.165.18.176/28',
323 2
            '34.175.160.64/28',
324 2
            '34.176.130.16/28',
325 2
            '34.22.85.0/27',
326 2
            '34.64.82.64/28',
327 2
            '34.65.242.112/28',
328 2
            '34.80.50.80/28',
329 2
            '34.88.194.0/28',
330 2
            '34.89.10.80/28',
331 2
            '34.89.198.80/28',
332 2
            '34.96.162.48/28',
333 2
            '35.247.243.240/28',
334 2
            '66.249.64.0/27',
335 2
            '66.249.64.128/27',
336 2
            '66.249.64.160/27',
337 2
            '66.249.64.192/27',
338 2
            '66.249.64.224/27',
339 2
            '66.249.64.32/27',
340 2
            '66.249.64.64/27',
341 2
            '66.249.64.96/27',
342 2
            '66.249.65.0/27',
343 2
            '66.249.65.128/27',
344 2
            '66.249.65.160/27',
345 2
            '66.249.65.192/27',
346 2
            '66.249.65.224/27',
347 2
            '66.249.65.32/27',
348 2
            '66.249.65.64/27',
349 2
            '66.249.65.96/27',
350 2
            '66.249.66.0/27',
351 2
            '66.249.66.128/27',
352 2
            '66.249.66.160/27',
353 2
            '66.249.66.192/27',
354 2
            '66.249.66.224/27',
355 2
            '66.249.66.32/27',
356 2
            '66.249.66.64/27',
357 2
            '66.249.66.96/27',
358 2
            '66.249.67.0/27',
359 2
            '66.249.68.0/27',
360 2
            '66.249.68.128/27',
361 2
            '66.249.68.160/27',
362 2
            '66.249.68.32/27',
363 2
            '66.249.68.64/27',
364 2
            '66.249.68.96/27',
365 2
            '66.249.69.0/27',
366 2
            '66.249.69.128/27',
367 2
            '66.249.69.160/27',
368 2
            '66.249.69.192/27',
369 2
            '66.249.69.224/27',
370 2
            '66.249.69.32/27',
371 2
            '66.249.69.64/27',
372 2
            '66.249.69.96/27',
373 2
            '66.249.70.0/27',
374 2
            '66.249.70.128/27',
375 2
            '66.249.70.160/27',
376 2
            '66.249.70.192/27',
377 2
            '66.249.70.224/27',
378 2
            '66.249.70.32/27',
379 2
            '66.249.70.64/27',
380 2
            '66.249.70.96/27',
381 2
            '66.249.71.0/27',
382 2
            '66.249.71.128/27',
383 2
            '66.249.71.160/27',
384 2
            '66.249.71.192/27',
385 2
            '66.249.71.224/27',
386 2
            '66.249.71.32/27',
387 2
            '66.249.71.64/27',
388 2
            '66.249.71.96/27',
389 2
            '66.249.72.0/27',
390 2
            '66.249.72.128/27',
391 2
            '66.249.72.160/27',
392 2
            '66.249.72.192/27',
393 2
            '66.249.72.224/27',
394 2
            '66.249.72.32/27',
395 2
            '66.249.72.64/27',
396 2
            '66.249.72.96/27',
397 2
            '66.249.73.0/27',
398 2
            '66.249.73.128/27',
399 2
            '66.249.73.160/27',
400 2
            '66.249.73.192/27',
401 2
            '66.249.73.224/27',
402 2
            '66.249.73.32/27',
403 2
            '66.249.73.64/27',
404 2
            '66.249.73.96/27',
405 2
            '66.249.74.0/27',
406 2
            '66.249.74.128/27',
407 2
            '66.249.74.160/27',
408 2
            '66.249.74.192/27',
409 2
            '66.249.74.224/27',
410 2
            '66.249.74.32/27',
411 2
            '66.249.74.64/27',
412 2
            '66.249.74.96/27',
413 2
            '66.249.75.0/27',
414 2
            '66.249.75.128/27',
415 2
            '66.249.75.160/27',
416 2
            '66.249.75.192/27',
417 2
            '66.249.75.224/27',
418 2
            '66.249.75.32/27',
419 2
            '66.249.75.64/27',
420 2
            '66.249.75.96/27',
421 2
            '66.249.76.0/27',
422 2
            '66.249.76.128/27',
423 2
            '66.249.76.160/27',
424 2
            '66.249.76.192/27',
425 2
            '66.249.76.224/27',
426 2
            '66.249.76.32/27',
427 2
            '66.249.76.64/27',
428 2
            '66.249.76.96/27',
429 2
            '66.249.77.0/27',
430 2
            '66.249.77.128/27',
431 2
            '66.249.77.160/27',
432 2
            '66.249.77.192/27',
433 2
            '66.249.77.224/27',
434 2
            '66.249.77.32/27',
435 2
            '66.249.77.64/27',
436 2
            '66.249.77.96/27',
437 2
            '66.249.78.0/27',
438 2
            '66.249.78.32/27',
439 2
            '66.249.78.64/27',
440 2
            '66.249.78.96/27',
441 2
            '66.249.79.0/27',
442 2
            '66.249.79.128/27',
443 2
            '66.249.79.160/27',
444 2
            '66.249.79.192/27',
445 2
            '66.249.79.224/27',
446 2
            '66.249.79.32/27',
447 2
            '66.249.79.64/27',
448 2
            '66.249.79.96/27',
449 2
        ];
450
451 2
        return IPRange::matches($remoteAddress, $googleBotIps);
452
    }
453
454 3
    private function isBingBot(ServerRequestInterface $request): bool
455
    {
456 3
        $remoteAddress = $request->getServerParams()['REMOTE_ADDR'] ?? '';
457
458 3
        if (!$remoteAddress || !is_string($remoteAddress)) {
459 1
            return false;
460
        }
461
462
        // Source is https://www.bing.com/toolbox/bingbot.json
463
        // Use this one-line command to fetch new list:
464
        //
465
        // ```bash
466
        // php -r 'echo PHP_EOL . implode(PHP_EOL, array_map(fn (array $r) => chr(39) . ($r["ipv6Prefix"] ?? $r["ipv4Prefix"]) . chr(39) . ",", json_decode(file_get_contents("https://www.bing.com/toolbox/bingbot.json"), true)["prefixes"])) . PHP_EOL;'
467
        // ```
468 2
        $bingBotIps = [
469 2
            '157.55.39.0/24',
470 2
            '207.46.13.0/24',
471 2
            '40.77.167.0/24',
472 2
            '13.66.139.0/24',
473 2
            '13.66.144.0/24',
474 2
            '52.167.144.0/24',
475 2
            '13.67.10.16/28',
476 2
            '13.69.66.240/28',
477 2
            '13.71.172.224/28',
478 2
            '139.217.52.0/28',
479 2
            '191.233.204.224/28',
480 2
            '20.36.108.32/28',
481 2
            '20.43.120.16/28',
482 2
            '40.79.131.208/28',
483 2
            '40.79.186.176/28',
484 2
            '52.231.148.0/28',
485 2
            '20.79.107.240/28',
486 2
            '51.105.67.0/28',
487 2
            '20.125.163.80/28',
488 2
            '40.77.188.0/22',
489 2
            '65.55.210.0/24',
490 2
            '199.30.24.0/23',
491 2
            '40.77.202.0/24',
492 2
            '40.77.139.0/25',
493 2
            '20.74.197.0/28',
494 2
            '20.15.133.160/27',
495 2
            '40.77.177.0/24',
496 2
            '40.77.178.0/23',
497 2
        ];
498
499 2
        return IPRange::matches($remoteAddress, $bingBotIps);
500
    }
501
}
502