Failed Conditions
Push — master ( e75200...fab761 )
by Adrien
02:43
created

SignedQueryMiddleware::__construct()   A

Complexity

Conditions 3
Paths 2

Size

Total Lines 7
Code Lines 2

Duplication

Lines 0
Ratio 0 %

Code Coverage

Tests 3
CRAP Score 3

Importance

Changes 1
Bugs 0 Features 0
Metric Value
eloc 2
c 1
b 0
f 0
dl 0
loc 7
ccs 3
cts 3
cp 1
rs 10
cc 3
nc 2
nop 3
crap 3
1
<?php
2
3
declare(strict_types=1);
4
5
namespace Ecodev\Felix\Middleware;
6
7
use Cake\Chronos\Chronos;
1 ignored issue
show
Bug introduced by
The type Cake\Chronos\Chronos was not found. Maybe you did not declare it correctly or list all dependencies?

The issue could also be caused by a filter entry in the build configuration. If the path has been excluded in your configuration, e.g. excluded_paths: ["lib/*"], you can move it to the dependency path list as follows:

filter:
    dependency_paths: ["lib/*"]

For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths

Loading history...
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 31
    public function __construct(
36
        private readonly array $keys,
37
        private readonly array $allowedIps,
38
        private readonly bool $required = true
39
    ) {
40 31
        if ($this->required && !$this->keys) {
41 1
            throw new Exception('Signed queries are required, but no keys are configured');
42
        }
43
    }
44
45 30
    public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
46
    {
47 30
        if ($this->required) {
48 15
            $request = $this->verify($request);
49
        }
50
51 23
        return $handler->handle($request);
52
    }
53
54 15
    private function verify(ServerRequestInterface $request): ServerRequestInterface
55
    {
56 15
        $signature = $request->getHeader('X-Signature')[0] ?? '';
57 15
        if (!$signature) {
58 2
            if ($this->isAllowedIp($request)) {
59 1
                return $request;
60
            }
61
62 1
            throw new Exception('Missing `X-Signature` HTTP header in signed query', 403);
63
        }
64
65 13
        if (preg_match('~^v1\.(?<timestamp>\d{10})\.(?<hash>[0-9a-f]{64})$~', $signature, $m)) {
66 12
            $timestamp = $m['timestamp'];
67 12
            $hash = $m['hash'];
68
69 12
            $this->verifyTimestamp($timestamp);
70
71 10
            return $this->verifyHash($request, $timestamp, $hash);
72
        }
73
74 1
        throw new Exception('Invalid `X-Signature` HTTP header in signed query', 403);
75
    }
76
77 12
    private function verifyTimestamp(string $timestamp): void
78
    {
79 12
        $now = Chronos::now()->timestamp;
80 12
        $leeway = 15 * 900; // 15 minutes
81 12
        $past = $now - $leeway;
82 12
        $future = $now + $leeway;
83 12
        if ($timestamp < $past || $timestamp > $future) {
84 2
            throw new Exception('Signed query is expired', 403);
85
        }
86
    }
87
88 10
    private function verifyHash(ServerRequestInterface $request, string $timestamp, string $hash): ServerRequestInterface
89
    {
90 10
        ['request' => $request, 'operations' => $operations] = $this->getOperations($request);
91 9
        $payload = $timestamp . $operations;
92
93 9
        foreach ($this->keys as $key) {
94 9
            $computedHash = hash_hmac('sha256', $payload, $key);
95 9
            if ($hash === $computedHash) {
96 7
                return $request;
97
            }
98
        }
99
100 2
        throw new Exception('Invalid signed query', 403);
101
    }
102
103
    /**
104
     * @return array{request: ServerRequestInterface, operations: string}
105
     */
106 10
    private function getOperations(ServerRequestInterface $request): array
107
    {
108 10
        $contents = $request->getBody()->getContents();
109
110 10
        if ($contents) {
111 7
            return [
112
                // Pseudo-rewind the request, even if non-rewindable, so the next
113
                // middleware still accesses the stream from the beginning
114 7
                'request' => $request->withBody(new CallbackStream(fn () => $contents)),
115 7
                'operations' => $contents,
116 7
            ];
117
        }
118
119 3
        $parsedBody = $request->getParsedBody();
120 3
        if (is_array($parsedBody)) {
121 2
            $operations = $parsedBody['operations'] ?? null;
122 2
            if ($operations) {
123 2
                return [
124 2
                    'request' => $request,
125 2
                    'operations' => $operations,
126 2
                ];
127
            }
128
        }
129
130 1
        throw new Exception('Could not find GraphQL operations in request', 403);
131
    }
132
133 2
    private function isAllowedIp(ServerRequestInterface $request): bool
134
    {
135 2
        $remoteAddress = $request->getServerParams()['REMOTE_ADDR'] ?? '';
136
137 2
        if (!$remoteAddress || !is_string($remoteAddress)) {
138 1
            return false;
139
        }
140
141 1
        return IPRange::matches($remoteAddress, $this->allowedIps);
142
    }
143
}
144