Passed
Push — master ( e3c58b...79fe9e )
by Adrien
13:35
created

SignedQueryMiddleware::isGoogleBot()   B

Complexity

Conditions 3
Paths 2

Size

Total Lines 246
Code Lines 238

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 240
CRAP Score 3

Importance

Changes 0
Metric Value
eloc 238
c 0
b 0
f 0
dl 0
loc 246
ccs 240
cts 240
cp 1
rs 8
cc 3
nc 2
nop 1
crap 3

How to fix   Long Method   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

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 37
    public function __construct(
36
        private readonly array $keys,
37
        private readonly array $allowedIps,
38
        private readonly bool $required = true
39
    ) {
40 37
        if ($this->required && !$this->keys) {
41 1
            throw new Exception('Signed queries are required, but no keys are configured');
42
        }
43
    }
44
45 36
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
46
    {
47 36
        if ($this->required) {
48 18
            $request = $this->verify($request);
49
        }
50
51 27
        return $handler->handle($request);
52
    }
53
54 18
    private function verify(ServerRequestInterface $request): ServerRequestInterface
55
    {
56 18
        $signature = $request->getHeader('X-Signature')[0] ?? '';
57 18
        if (!$signature) {
58 3
            if ($this->isAllowedIp($request)) {
59 1
                return $request;
60
            }
61
62 2
            throw new Exception('Missing `X-Signature` HTTP header in signed query', 403);
63
        }
64
65 15
        if (preg_match('~^v1\.(?<timestamp>\d{10})\.(?<hash>[0-9a-f]{64})$~', $signature, $m)) {
66 13
            $timestamp = $m['timestamp'];
67 13
            $hash = $m['hash'];
68
69 13
            $this->verifyTimestamp($request, $timestamp);
70
71 11
            return $this->verifyHash($request, $timestamp, $hash);
72
        }
73
74 2
        throw new Exception('Invalid `X-Signature` HTTP header in signed query', 403);
75
    }
76
77 13
    private function verifyTimestamp(ServerRequestInterface $request, string $timestamp): void
78
    {
79 13
        $now = Chronos::now()->timestamp;
80 13
        $leeway = 15 * 900; // 15 minutes
81 13
        $past = $now - $leeway;
82 13
        $future = $now + $leeway;
83 13
        $isExpired = $timestamp < $past || $timestamp > $future;
84 13
        if ($isExpired && !$this->isGoogleBot($request)) {
85 2
            throw new Exception('Signed query is expired', 403);
86
        }
87
    }
88
89 11
    private function verifyHash(ServerRequestInterface $request, string $timestamp, string $hash): ServerRequestInterface
90
    {
91 11
        ['request' => $request, 'operations' => $operations] = $this->getOperations($request);
92 10
        $payload = $timestamp . $operations;
93
94 10
        foreach ($this->keys as $key) {
95 10
            $computedHash = hash_hmac('sha256', $payload, $key);
96 10
            if ($hash === $computedHash) {
97 8
                return $request;
98
            }
99
        }
100
101 2
        throw new Exception('Invalid signed query', 403);
102
    }
103
104
    /**
105
     * @return array{request: ServerRequestInterface, operations: string}
106
     */
107 11
    private function getOperations(ServerRequestInterface $request): array
108
    {
109 11
        $contents = $request->getBody()->getContents();
110
111 11
        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 3
        $parsedBody = $request->getParsedBody();
121 3
        if (is_array($parsedBody)) {
122 2
            $operations = $parsedBody['operations'] ?? null;
123 2
            if ($operations) {
124 2
                return [
125 2
                    'request' => $request,
126 2
                    'operations' => $operations,
127 2
                ];
128
            }
129
        }
130
131 1
        throw new Exception('Could not find GraphQL operations in request', 403);
132
    }
133
134 3
    private function isAllowedIp(ServerRequestInterface $request): bool
135
    {
136 3
        $remoteAddress = $request->getServerParams()['REMOTE_ADDR'] ?? '';
137
138 3
        if (!$remoteAddress || !is_string($remoteAddress)) {
139 1
            return false;
140
        }
141
142 2
        return IPRange::matches($remoteAddress, $this->allowedIps);
143
    }
144
145 3
    private function isGoogleBot(ServerRequestInterface $request): bool
146
    {
147 3
        $remoteAddress = $request->getServerParams()['REMOTE_ADDR'] ?? '';
148
149 3
        if (!$remoteAddress || !is_string($remoteAddress)) {
150 2
            return false;
151
        }
152
153
        // Source is https://developers.google.com/search/apis/ipranges/googlebot.json
154 1
        $googleBotIps = [
155 1
            '2001:4860:4801:10::/64',
156 1
            '2001:4860:4801:11::/64',
157 1
            '2001:4860:4801:12::/64',
158 1
            '2001:4860:4801:13::/64',
159 1
            '2001:4860:4801:14::/64',
160 1
            '2001:4860:4801:15::/64',
161 1
            '2001:4860:4801:16::/64',
162 1
            '2001:4860:4801:17::/64',
163 1
            '2001:4860:4801:18::/64',
164 1
            '2001:4860:4801:19::/64',
165 1
            '2001:4860:4801:1a::/64',
166 1
            '2001:4860:4801:1b::/64',
167 1
            '2001:4860:4801:1c::/64',
168 1
            '2001:4860:4801:1d::/64',
169 1
            '2001:4860:4801:1e::/64',
170 1
            '2001:4860:4801:20::/64',
171 1
            '2001:4860:4801:21::/64',
172 1
            '2001:4860:4801:22::/64',
173 1
            '2001:4860:4801:23::/64',
174 1
            '2001:4860:4801:24::/64',
175 1
            '2001:4860:4801:25::/64',
176 1
            '2001:4860:4801:26::/64',
177 1
            '2001:4860:4801:27::/64',
178 1
            '2001:4860:4801:28::/64',
179 1
            '2001:4860:4801:29::/64',
180 1
            '2001:4860:4801:2::/64',
181 1
            '2001:4860:4801:2a::/64',
182 1
            '2001:4860:4801:2b::/64',
183 1
            '2001:4860:4801:2c::/64',
184 1
            '2001:4860:4801:2d::/64',
185 1
            '2001:4860:4801:2e::/64',
186 1
            '2001:4860:4801:2f::/64',
187 1
            '2001:4860:4801:30::/64',
188 1
            '2001:4860:4801:31::/64',
189 1
            '2001:4860:4801:32::/64',
190 1
            '2001:4860:4801:33::/64',
191 1
            '2001:4860:4801:34::/64',
192 1
            '2001:4860:4801:35::/64',
193 1
            '2001:4860:4801:36::/64',
194 1
            '2001:4860:4801:37::/64',
195 1
            '2001:4860:4801:38::/64',
196 1
            '2001:4860:4801:39::/64',
197 1
            '2001:4860:4801:3::/64',
198 1
            '2001:4860:4801:3a::/64',
199 1
            '2001:4860:4801:3b::/64',
200 1
            '2001:4860:4801:3c::/64',
201 1
            '2001:4860:4801:3d::/64',
202 1
            '2001:4860:4801:3e::/64',
203 1
            '2001:4860:4801:40::/64',
204 1
            '2001:4860:4801:41::/64',
205 1
            '2001:4860:4801:42::/64',
206 1
            '2001:4860:4801:43::/64',
207 1
            '2001:4860:4801:44::/64',
208 1
            '2001:4860:4801:45::/64',
209 1
            '2001:4860:4801:46::/64',
210 1
            '2001:4860:4801:47::/64',
211 1
            '2001:4860:4801:48::/64',
212 1
            '2001:4860:4801:49::/64',
213 1
            '2001:4860:4801:4a::/64',
214 1
            '2001:4860:4801:50::/64',
215 1
            '2001:4860:4801:51::/64',
216 1
            '2001:4860:4801:53::/64',
217 1
            '2001:4860:4801:54::/64',
218 1
            '2001:4860:4801:55::/64',
219 1
            '2001:4860:4801:60::/64',
220 1
            '2001:4860:4801:61::/64',
221 1
            '2001:4860:4801:62::/64',
222 1
            '2001:4860:4801:63::/64',
223 1
            '2001:4860:4801:64::/64',
224 1
            '2001:4860:4801:65::/64',
225 1
            '2001:4860:4801:66::/64',
226 1
            '2001:4860:4801:67::/64',
227 1
            '2001:4860:4801:68::/64',
228 1
            '2001:4860:4801:69::/64',
229 1
            '2001:4860:4801:6a::/64',
230 1
            '2001:4860:4801:6b::/64',
231 1
            '2001:4860:4801:6c::/64',
232 1
            '2001:4860:4801:6d::/64',
233 1
            '2001:4860:4801:6e::/64',
234 1
            '2001:4860:4801:6f::/64',
235 1
            '2001:4860:4801:70::/64',
236 1
            '2001:4860:4801:71::/64',
237 1
            '2001:4860:4801:72::/64',
238 1
            '2001:4860:4801:73::/64',
239 1
            '2001:4860:4801:74::/64',
240 1
            '2001:4860:4801:75::/64',
241 1
            '2001:4860:4801:76::/64',
242 1
            '2001:4860:4801:77::/64',
243 1
            '2001:4860:4801:78::/64',
244 1
            '2001:4860:4801:79::/64',
245 1
            '2001:4860:4801:80::/64',
246 1
            '2001:4860:4801:81::/64',
247 1
            '2001:4860:4801:82::/64',
248 1
            '2001:4860:4801:83::/64',
249 1
            '2001:4860:4801:84::/64',
250 1
            '2001:4860:4801:85::/64',
251 1
            '2001:4860:4801:86::/64',
252 1
            '2001:4860:4801:87::/64',
253 1
            '2001:4860:4801:88::/64',
254 1
            '2001:4860:4801:90::/64',
255 1
            '2001:4860:4801:91::/64',
256 1
            '2001:4860:4801:92::/64',
257 1
            '2001:4860:4801:93::/64',
258 1
            '2001:4860:4801:c::/64',
259 1
            '2001:4860:4801:f::/64',
260 1
            '192.178.5.0/27',
261 1
            '34.100.182.96/28',
262 1
            '34.101.50.144/28',
263 1
            '34.118.254.0/28',
264 1
            '34.118.66.0/28',
265 1
            '34.126.178.96/28',
266 1
            '34.146.150.144/28',
267 1
            '34.147.110.144/28',
268 1
            '34.151.74.144/28',
269 1
            '34.152.50.64/28',
270 1
            '34.154.114.144/28',
271 1
            '34.155.98.32/28',
272 1
            '34.165.18.176/28',
273 1
            '34.175.160.64/28',
274 1
            '34.176.130.16/28',
275 1
            '34.22.85.0/27',
276 1
            '34.64.82.64/28',
277 1
            '34.65.242.112/28',
278 1
            '34.80.50.80/28',
279 1
            '34.88.194.0/28',
280 1
            '34.89.10.80/28',
281 1
            '34.89.198.80/28',
282 1
            '34.96.162.48/28',
283 1
            '35.247.243.240/28',
284 1
            '66.249.64.0/27',
285 1
            '66.249.64.128/27',
286 1
            '66.249.64.160/27',
287 1
            '66.249.64.192/27',
288 1
            '66.249.64.224/27',
289 1
            '66.249.64.32/27',
290 1
            '66.249.64.64/27',
291 1
            '66.249.64.96/27',
292 1
            '66.249.65.0/27',
293 1
            '66.249.65.160/27',
294 1
            '66.249.65.192/27',
295 1
            '66.249.65.224/27',
296 1
            '66.249.65.32/27',
297 1
            '66.249.65.64/27',
298 1
            '66.249.65.96/27',
299 1
            '66.249.66.0/27',
300 1
            '66.249.66.128/27',
301 1
            '66.249.66.160/27',
302 1
            '66.249.66.192/27',
303 1
            '66.249.66.32/27',
304 1
            '66.249.66.64/27',
305 1
            '66.249.66.96/27',
306 1
            '66.249.68.0/27',
307 1
            '66.249.68.32/27',
308 1
            '66.249.68.64/27',
309 1
            '66.249.69.0/27',
310 1
            '66.249.69.128/27',
311 1
            '66.249.69.160/27',
312 1
            '66.249.69.192/27',
313 1
            '66.249.69.224/27',
314 1
            '66.249.69.32/27',
315 1
            '66.249.69.64/27',
316 1
            '66.249.69.96/27',
317 1
            '66.249.70.0/27',
318 1
            '66.249.70.128/27',
319 1
            '66.249.70.160/27',
320 1
            '66.249.70.192/27',
321 1
            '66.249.70.224/27',
322 1
            '66.249.70.32/27',
323 1
            '66.249.70.64/27',
324 1
            '66.249.70.96/27',
325 1
            '66.249.71.0/27',
326 1
            '66.249.71.128/27',
327 1
            '66.249.71.160/27',
328 1
            '66.249.71.192/27',
329 1
            '66.249.71.224/27',
330 1
            '66.249.71.32/27',
331 1
            '66.249.71.64/27',
332 1
            '66.249.71.96/27',
333 1
            '66.249.72.0/27',
334 1
            '66.249.72.128/27',
335 1
            '66.249.72.160/27',
336 1
            '66.249.72.192/27',
337 1
            '66.249.72.224/27',
338 1
            '66.249.72.32/27',
339 1
            '66.249.72.64/27',
340 1
            '66.249.72.96/27',
341 1
            '66.249.73.0/27',
342 1
            '66.249.73.128/27',
343 1
            '66.249.73.160/27',
344 1
            '66.249.73.192/27',
345 1
            '66.249.73.224/27',
346 1
            '66.249.73.32/27',
347 1
            '66.249.73.64/27',
348 1
            '66.249.73.96/27',
349 1
            '66.249.74.0/27',
350 1
            '66.249.74.128/27',
351 1
            '66.249.74.32/27',
352 1
            '66.249.74.64/27',
353 1
            '66.249.74.96/27',
354 1
            '66.249.75.0/27',
355 1
            '66.249.75.128/27',
356 1
            '66.249.75.160/27',
357 1
            '66.249.75.192/27',
358 1
            '66.249.75.224/27',
359 1
            '66.249.75.32/27',
360 1
            '66.249.75.64/27',
361 1
            '66.249.75.96/27',
362 1
            '66.249.76.0/27',
363 1
            '66.249.76.128/27',
364 1
            '66.249.76.160/27',
365 1
            '66.249.76.192/27',
366 1
            '66.249.76.224/27',
367 1
            '66.249.76.32/27',
368 1
            '66.249.76.64/27',
369 1
            '66.249.76.96/27',
370 1
            '66.249.77.0/27',
371 1
            '66.249.77.128/27',
372 1
            '66.249.77.160/27',
373 1
            '66.249.77.192/27',
374 1
            '66.249.77.224/27',
375 1
            '66.249.77.32/27',
376 1
            '66.249.77.64/27',
377 1
            '66.249.77.96/27',
378 1
            '66.249.78.0/27',
379 1
            '66.249.78.32/27',
380 1
            '66.249.79.0/27',
381 1
            '66.249.79.128/27',
382 1
            '66.249.79.160/27',
383 1
            '66.249.79.192/27',
384 1
            '66.249.79.224/27',
385 1
            '66.249.79.32/27',
386 1
            '66.249.79.64/27',
387 1
            '66.249.79.96/27',
388 1
        ];
389
390 1
        return IPRange::matches($remoteAddress, $googleBotIps);
391
    }
392
}
393