Ecodev /
felix
| 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