1 | <?php |
||
2 | |||
3 | declare(strict_types=1); |
||
4 | |||
5 | namespace Ecodev\Felix\Middleware; |
||
6 | |||
7 | use Cake\Chronos\Chronos; |
||
1 ignored issue
–
show
|
|||
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 |
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:For further information see https://scrutinizer-ci.com/docs/tools/php/php-scrutinizer/#list-dependency-paths