From b218964369880e7837bec5d89a6e73bbe3814273 Mon Sep 17 00:00:00 2001 From: Tim Kelty Date: Fri, 12 Jun 2026 11:29:25 -0400 Subject: [PATCH] Switch to request-time HTTP message signing --- README.md | 119 +++++++++- composer.json | 2 +- composer.lock | 265 ++++++++++++----------- src/Esi.php | 1 + src/Helper.php | 8 +- src/Module.php | 10 + src/UrlSigner.php | 67 ------ src/imagetransforms/ImageTransformer.php | 33 +-- src/signing/RequestSigner.php | 68 ++++++ src/signing/UrlSigner.php | 79 +++++++ tests/unit/EsiTest.php | 2 +- tests/unit/HelperTest.php | 25 ++- tests/unit/ImageTransformTest.php | 15 ++ tests/unit/RequestSignerTest.php | 63 ++++++ tests/unit/UrlSignerTest.php | 14 +- 15 files changed, 538 insertions(+), 233 deletions(-) delete mode 100644 src/UrlSigner.php create mode 100644 src/signing/RequestSigner.php create mode 100644 src/signing/UrlSigner.php create mode 100644 tests/unit/RequestSignerTest.php diff --git a/README.md b/README.md index 2916670..47f28e5 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,123 @@ composer update "craftcms/cms:^5" "craftcms/flysystem:^2.0" --with-all-dependenc ## Developer Features +### Signed HTTP Requests + +Use the module’s request signer to sign a PSR-7 request with Cloud’s signing key before sending it to any destination that can verify HTTP message signatures. + +```php +use craft\cloud\Module; +use GuzzleHttp\Psr7\Request; + +$signer = Module::getInstance()->getRequestSigner(); + +$signedRequest = $signer->sign(new Request('POST', 'https://example.test/webhook')); +``` + +For Guzzle clients, use the signer’s handler stack helper so each request is signed when it is sent. + +```php +use Craft; +use craft\cloud\Module; +use GuzzleHttp\RequestOptions; + +$signer = Module::getInstance()->getRequestSigner(); + +$client = Craft::createGuzzleClient([ + 'handler' => $signer->createHandlerStack(), +]); + +$client->post('https://example.test/webhook', [ + RequestOptions::JSON => [ + 'event' => 'asset.saved', + ], +]); +``` + +External systems can create compatible signatures without this PHP package. For example, install +[`http-message-sig`](https://www.npmjs.com/package/http-message-sig) in a Node-based build environment: + +```bash +npm install http-message-sig +``` + +Then a Vercel build script can sign a Craft GraphQL request: + +```js +import crypto from 'node:crypto'; +import { signatureHeadersSync } from 'http-message-sig'; + +const method = 'POST'; +const url = process.env.CRAFT_GRAPHQL_URL; +const signingKey = process.env.CRAFT_CLOUD_SIGNING_KEY; + +if (!url || !signingKey) { + throw new Error('CRAFT_GRAPHQL_URL and CRAFT_CLOUD_SIGNING_KEY are required.'); +} + +const body = JSON.stringify({ + query: ` + query BuildData { + entries(section: "news") { + title + url + } + } + `, +}); + +const headers = { + 'Content-Type': 'application/json', + ...(process.env.CRAFT_GRAPHQL_TOKEN + ? { Authorization: `Bearer ${process.env.CRAFT_GRAPHQL_TOKEN}` } + : {}), +}; + +const signer = { + keyid: 'hmac', + alg: 'hmac-sha256', + signSync(data) { + return crypto + .createHmac('sha256', signingKey) + .update(data) + .digest(); + }, +}; + +const created = new Date(); +const signatureHeaders = signatureHeadersSync( + { method, url, headers, body }, + { + key: 'sig', + signer, + components: ['@method', '@target-uri'], + created, + expires: new Date(created.getTime() + 300_000), + }, +); + +const response = await fetch(url, { + method, + headers: { + ...headers, + ...signatureHeaders, + }, + body, +}); + +if (!response.ok) { + throw new Error(`Craft GraphQL request failed: ${response.status}`); +} + +const responseBody = await response.json(); + +if (responseBody.errors) { + throw new Error(`Craft GraphQL returned errors: ${JSON.stringify(responseBody.errors)}`); +} +``` + +The `@target-uri` value must be the exact URL being requested, including any query string. + ### Template Helpers #### `cloud.artifactUrl()` @@ -109,7 +226,7 @@ Most configuration (to Craft and the extension itself) is handled directly by Cl | `accessSecret` | `string` | AWS access secret, used in conjunction with the `accessKey`. | | `accessToken` | `string` | AWS access token. | | `redisUrl` | `string` | Connection string for the environment’s Redis instance. | -| `signingKey` | `string` | A secret value used to protect transform URLs against abuse. | +| `signingKey` | `string` | A secret value used to protect transform URLs and sign HTTP requests. | | `useAssetBundleCdn` | `boolean` | Whether or not to enable the CDN for asset bundles. | | `previewDomain` | `string\|null` | Set when accessing an environment from its [preview domain](https://craftcms.com/knowledge-base/cloud-domains#preview-domains). | | `useQueue` | `boolean` | Whether or not to use Cloud’s SQS-backed queue driver. | diff --git a/composer.json b/composer.json index 4a8bc11..6ff2770 100644 --- a/composer.json +++ b/composer.json @@ -8,6 +8,7 @@ "bref/extra-php-extensions": "^3", "craftcms/cms": "^4.6 || ^5", "craftcms/flysystem": "^1.0.0 || ^2.0.0", + "craftcms/http-message-signatures": "^0.1", "guzzlehttp/guzzle": "^7.4.5", "league/flysystem-aws-s3-v3": "^3.15", "league/uri": "^7.6", @@ -15,7 +16,6 @@ "yiisoft/yii2-redis": "^2.0", "yiisoft/yii2-queue": "^2.3.7", "phlak/semver": "^4.1", - "99designs/http-signatures": "^4.0", "symfony/process": "^6", "aws/aws-sdk-php": "^3.342.6", "craftcms/yii2-cache-cascade": "^1.2.1" diff --git a/composer.lock b/composer.lock index 960e769..5d1c777 100644 --- a/composer.lock +++ b/composer.lock @@ -4,66 +4,8 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "dcd08efcfa79b08fba1ef27eeef85643", + "content-hash": "4b1eea6a66f4ad9940cdb80b82d7eaeb", "packages": [ - { - "name": "99designs/http-signatures", - "version": "4.0.0", - "source": { - "type": "git", - "url": "https://github.com/99designs/http-signatures-php.git", - "reference": "acb9d2e4f4661de9445fa5930b49a259bfd6175b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/99designs/http-signatures-php/zipball/acb9d2e4f4661de9445fa5930b49a259bfd6175b", - "reference": "acb9d2e4f4661de9445fa5930b49a259bfd6175b", - "shasum": "" - }, - "require": { - "paragonie/random_compat": "^1.0|^2.0", - "php": ">=5.5", - "psr/http-message": "^1.0" - }, - "require-dev": { - "friendsofphp/php-cs-fixer": "^1.11", - "guzzlehttp/psr7": "^1.2", - "phpunit/phpunit": "~4.8", - "symfony/http-foundation": "~2.8|~3.0", - "symfony/psr-http-message-bridge": "^1.0", - "zendframework/zend-diactoros": "^1.1" - }, - "type": "library", - "autoload": { - "psr-4": { - "HttpSignatures\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Paul Annesley", - "email": "paul@99designs.com" - } - ], - "description": "Sign and verify HTTP messages", - "keywords": [ - "hmac", - "http", - "https", - "signature", - "signed", - "signing" - ], - "support": { - "issues": "https://github.com/99designs/http-signatures-php/issues", - "source": "https://github.com/99designs/http-signatures-php/tree/master" - }, - "time": "2017-05-04T01:36:17+00:00" - }, { "name": "aws/aws-crt-php", "version": "v1.2.7", @@ -269,6 +211,88 @@ }, "time": "2022-12-07T17:46:57+00:00" }, + { + "name": "bakame/http-structured-fields", + "version": "2.0.0", + "source": { + "type": "git", + "url": "https://github.com/bakame-php/http-structured-fields.git", + "reference": "d0fc193e5b173a4e90f2fa589d5b97b2b653b323" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/bakame-php/http-structured-fields/zipball/d0fc193e5b173a4e90f2fa589d5b97b2b653b323", + "reference": "d0fc193e5b173a4e90f2fa589d5b97b2b653b323", + "shasum": "" + }, + "require": { + "php": "^8.1" + }, + "require-dev": { + "bakame/aide-base32": "dev-main", + "friendsofphp/php-cs-fixer": "^3.65.0", + "httpwg/structured-field-tests": "*@dev", + "phpbench/phpbench": "^1.3.1", + "phpstan/phpstan": "^2.0.3", + "phpstan/phpstan-deprecation-rules": "^2.0.1", + "phpstan/phpstan-phpunit": "^2.0.1", + "phpstan/phpstan-strict-rules": "^2.0", + "phpunit/phpunit": "^10.5.38 || ^11.5.0", + "symfony/var-dumper": "^6.4.15 || ^v7.2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-develop": "1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Bakame\\Http\\StructuredFields\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Ignace Nyamagana Butera", + "email": "nyamsprod@gmail.com", + "homepage": "https://github.com/nyamsprod/", + "role": "Developer" + } + ], + "description": "A PHP library that parses, validates and serializes HTTP structured fields according to RFC9561 and RFC8941", + "keywords": [ + "headers", + "http", + "http headers", + "http trailers", + "parser", + "rfc8941", + "rfc9651", + "serializer", + "structured fields", + "structured headers", + "structured trailers", + "structured values", + "trailers", + "validation" + ], + "support": { + "docs": "https://github.com/bakame-php/http-structured-fields", + "issues": "https://github.com/bakame-php/http-structured-fields/issues", + "source": "https://github.com/bakame-php/http-structured-fields" + }, + "funding": [ + { + "url": "https://github.com/sponsors/nyamsprod", + "type": "github" + } + ], + "time": "2024-12-12T08:25:42+00:00" + }, { "name": "bref/bref", "version": "3.0.1", @@ -1004,6 +1028,59 @@ }, "time": "2024-03-22T18:41:28+00:00" }, + { + "name": "craftcms/http-message-signatures", + "version": "0.1.0", + "source": { + "type": "git", + "url": "https://github.com/craftcms/http-message-signatures-php.git", + "reference": "ccb73a34946feb5003086026f4645fa81c9a5553" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/craftcms/http-message-signatures-php/zipball/ccb73a34946feb5003086026f4645fa81c9a5553", + "reference": "ccb73a34946feb5003086026f4645fa81c9a5553", + "shasum": "" + }, + "require": { + "bakame/http-structured-fields": "^2.0", + "league/uri-components": "^7.0", + "php": "^8.1", + "psr/http-factory": "^1.0", + "psr/http-message": "^2.0" + }, + "require-dev": { + "carthage-software/mago": "^1.13", + "guzzlehttp/psr7": "^2.0", + "http-interop/http-factory-guzzle": "^1.0", + "phpstan/extension-installer": "^1.3", + "phpstan/phpstan": "^2.0", + "phpunit/phpunit": "^10.5" + }, + "type": "library", + "autoload": { + "psr-4": { + "HttpMessageSignatures\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "PSR-7 compliant PHP 8.1+ implementation of HTTP Message Signatures (RFC 9421)", + "keywords": [ + "Authentication", + "http", + "rfc9421", + "security", + "signature" + ], + "support": { + "issues": "https://github.com/craftcms/http-message-signatures-php/issues", + "source": "https://github.com/craftcms/http-message-signatures-php/tree/0.1.0" + }, + "time": "2026-06-12T14:48:03+00:00" + }, { "name": "craftcms/plugin-installer", "version": "1.6.0", @@ -3776,60 +3853,6 @@ }, "time": "2025-09-24T15:06:41+00:00" }, - { - "name": "paragonie/random_compat", - "version": "v2.0.21", - "source": { - "type": "git", - "url": "https://github.com/paragonie/random_compat.git", - "reference": "96c132c7f2f7bc3230723b66e89f8f150b29d5ae" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/paragonie/random_compat/zipball/96c132c7f2f7bc3230723b66e89f8f150b29d5ae", - "reference": "96c132c7f2f7bc3230723b66e89f8f150b29d5ae", - "shasum": "" - }, - "require": { - "php": ">=5.2.0" - }, - "require-dev": { - "phpunit/phpunit": "*" - }, - "suggest": { - "ext-libsodium": "Provides a modern crypto API that can be used to generate random bytes." - }, - "type": "library", - "autoload": { - "files": [ - "lib/random.php" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Paragon Initiative Enterprises", - "email": "security@paragonie.com", - "homepage": "https://paragonie.com" - } - ], - "description": "PHP 5.x polyfill for random_bytes() and random_int() from PHP 7", - "keywords": [ - "csprng", - "polyfill", - "pseudorandom", - "random" - ], - "support": { - "email": "info@paragonie.com", - "issues": "https://github.com/paragonie/random_compat/issues", - "source": "https://github.com/paragonie/random_compat" - }, - "time": "2022-02-16T17:07:03+00:00" - }, { "name": "phlak/semver", "version": "4.1.0", @@ -4791,16 +4814,16 @@ }, { "name": "psr/http-message", - "version": "1.1", + "version": "2.0", "source": { "type": "git", "url": "https://github.com/php-fig/http-message.git", - "reference": "cb6ce4845ce34a8ad9e68117c10ee90a29919eba" + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/php-fig/http-message/zipball/cb6ce4845ce34a8ad9e68117c10ee90a29919eba", - "reference": "cb6ce4845ce34a8ad9e68117c10ee90a29919eba", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71", + "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71", "shasum": "" }, "require": { @@ -4809,7 +4832,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-master": "1.1.x-dev" + "dev-master": "2.0.x-dev" } }, "autoload": { @@ -4824,7 +4847,7 @@ "authors": [ { "name": "PHP-FIG", - "homepage": "http://www.php-fig.org/" + "homepage": "https://www.php-fig.org/" } ], "description": "Common interface for HTTP messages", @@ -4838,9 +4861,9 @@ "response" ], "support": { - "source": "https://github.com/php-fig/http-message/tree/1.1" + "source": "https://github.com/php-fig/http-message/tree/2.0" }, - "time": "2023-04-04T09:50:52+00:00" + "time": "2023-04-04T09:54:51+00:00" }, { "name": "psr/http-server-handler", diff --git a/src/Esi.php b/src/Esi.php index b28f69c..be52996 100644 --- a/src/Esi.php +++ b/src/Esi.php @@ -3,6 +3,7 @@ namespace craft\cloud; use Craft; +use craft\cloud\signing\UrlSigner; use craft\helpers\UrlHelper; use InvalidArgumentException; diff --git a/src/Helper.php b/src/Helper.php index 08c758a..030ce50 100644 --- a/src/Helper.php +++ b/src/Helper.php @@ -72,7 +72,8 @@ public static function makeGatewayApiRequest(iterable $headers): ResponseInterfa public static function createGatewayApiClient(): Client { - $config = Module::getInstance()->getConfig(); + $module = Module::getInstance(); + $config = $module->getConfig(); if (!$config->environmentId) { throw new Exception('Gateway API requests require an environment ID.'); @@ -82,9 +83,7 @@ public static function createGatewayApiClient(): Client throw new Exception('Gateway API requests require a signing key.'); } - $headers = [ - 'X-Gateway-Authorization' => "Bearer {$config->signingKey}", - ]; + $headers = []; if ($config->getDevMode()) { $headers[HeaderEnum::DEV_MODE->value] = '1'; @@ -96,6 +95,7 @@ public static function createGatewayApiClient(): Client rtrim($config->gatewayBaseUrl, '/'), rawurlencode($config->environmentId), ), + 'handler' => $module->getRequestSigner()->createHandlerStack(), RequestOptions::HEADERS => $headers, ]); } diff --git a/src/Module.php b/src/Module.php index 8e00e79..11df8bd 100644 --- a/src/Module.php +++ b/src/Module.php @@ -8,6 +8,8 @@ use craft\cloud\fs\AssetsFs; use craft\cloud\imagetransforms\ImageTransformBehavior; use craft\cloud\imagetransforms\ImageTransformer; +use craft\cloud\signing\RequestSigner; +use craft\cloud\signing\UrlSigner; use craft\cloud\twig\TwigExtension; use craft\cloud\web\assets\uploader\UploaderAsset; use craft\cloud\web\ResponseEventHandler; @@ -77,6 +79,9 @@ public function bootstrap($app): void $this->setComponents([ 'staticCache' => StaticCache::class, + 'requestSigner' => fn() => new RequestSigner( + signingKey: $this->getConfig()->signingKey ?? '', + ), 'urlSigner' => fn() => new UrlSigner( signingKey: $this->getConfig()->signingKey ?? '', ), @@ -249,6 +254,11 @@ public function getStaticCache(): StaticCache return $this->get('staticCache'); } + public function getRequestSigner(): RequestSigner + { + return $this->get('requestSigner'); + } + public function getUrlSigner(): UrlSigner { return $this->get('urlSigner'); diff --git a/src/UrlSigner.php b/src/UrlSigner.php deleted file mode 100644 index a630ad1..0000000 --- a/src/UrlSigner.php +++ /dev/null @@ -1,67 +0,0 @@ -getSigningData($url); - $signature = hash_hmac('sha256', $data, $this->signingKey); - - return Modifier::wrap($url)->appendQueryParameters([ - $this->signatureParameter => $signature, - ]); - } - - private function getSigningData(string $url): string - { - return (string) Modifier::wrap($url) - ->removeQueryParameters($this->signatureParameter) - ->sortQuery() - ->removeEmptyQueryPairs(); - } - - public function verify(string $url): bool - { - $providedSignature = Query::fromUri($url)->get($this->signatureParameter); - - if (!$providedSignature) { - Craft::info([ - 'message' => 'Missing signature', - 'url' => $url, - 'signatureParameter' => $this->signatureParameter, - ], __METHOD__); - - return false; - } - - $data = $this->getSigningData($url); - - $verified = hash_equals( - hash_hmac('sha256', $data, $this->signingKey), - $providedSignature, - ); - - if (!$verified) { - Craft::info([ - 'message' => 'Invalid signature', - 'providedSignature' => $providedSignature, - 'data' => $data, - 'url' => $url, - ], __METHOD__); - } - - return $verified; - } -} diff --git a/src/imagetransforms/ImageTransformer.php b/src/imagetransforms/ImageTransformer.php index f898fe9..52d3b0b 100644 --- a/src/imagetransforms/ImageTransformer.php +++ b/src/imagetransforms/ImageTransformer.php @@ -11,7 +11,6 @@ use craft\helpers\Html; use craft\models\ImageTransform; use League\Uri\Components\Query; -use League\Uri\Contracts\UriInterface; use League\Uri\Modifier; use League\Uri\Uri; use yii\base\NotSupportedException; @@ -24,8 +23,6 @@ class ImageTransformer extends Component implements ImageTransformerInterface // Source asset extensions Cloudflare Images can accept for transformations. public const SUPPORTED_IMAGE_FORMATS = ['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'avif', 'heic']; - private const SIGNING_PARAM = 's'; - public function getTransformUrl(Asset $asset, ImageTransform $imageTransform, bool $immediately): string { if (version_compare(Craft::$app->version, '5.0', '>=')) { @@ -61,7 +58,7 @@ public function getTransformUrl(Asset $asset, ImageTransform $imageTransform, bo ->mergeQuery($query) ->unwrap(); - return (string) $this->sign($uri); + return Module::getInstance()->getUrlSigner()->sign((string) $uri); } public function invalidateAssetTransforms(Asset $asset): void @@ -77,32 +74,4 @@ protected function applyAssetFocalPointGravity(Asset $asset, ImageTransform $ima return $asset->getFocalPoint(); } - - private function sign(UriInterface $uri): UriInterface - { - $data = "{$uri->getPath()}#?{$uri->getQuery()}"; - - Craft::info("Signing transform: `{$data}`", __METHOD__); - - // https://developers.cloudflare.com/workers/examples/signing-requests - $hash = hash_hmac( - 'sha256', - $data, - Module::getInstance()->getConfig()->signingKey, - true, - ); - - $signature = $this->base64UrlEncode($hash); - - return Modifier::wrap($uri) - ->mergeQueryParameters([self::SIGNING_PARAM => $signature]) - ->unwrap(); - } - - private function base64UrlEncode(string $data): string - { - $base64Url = strtr(base64_encode($data), '+/', '-_'); - - return rtrim($base64Url, '='); - } } diff --git a/src/signing/RequestSigner.php b/src/signing/RequestSigner.php new file mode 100644 index 0000000..684a49d --- /dev/null +++ b/src/signing/RequestSigner.php @@ -0,0 +1,68 @@ +createSigner()->sign( + $request, + $this->components, + [ + 'signatureId' => $this->signatureId, + 'keyid' => $this->keyId, + 'created' => $created, + 'expires' => $created + $this->expiresAfter, + ], + ); + + if (!$signedRequest instanceof RequestInterface) { + throw new Exception('Request signatures must produce a signed request.'); + } + + return $signedRequest; + } + + public function createMiddleware(): callable + { + return Middleware::mapRequest( + fn(RequestInterface $request): RequestInterface => $this->sign($request), + ); + } + + public function createHandlerStack(?HandlerStack $handlerStack = null): HandlerStack + { + $handlerStack ??= HandlerStack::create(); + $handlerStack->push($this->createMiddleware(), 'craft_cloud_request_signing'); + + return $handlerStack; + } + + private function createSigner(): Signer + { + return new Signer(new HmacSha256($this->signingKey)); + } +} diff --git a/src/signing/UrlSigner.php b/src/signing/UrlSigner.php new file mode 100644 index 0000000..f0d4f5e --- /dev/null +++ b/src/signing/UrlSigner.php @@ -0,0 +1,79 @@ +createSigner()->sign($url); + } + + public function verify(string $url): bool + { + try { + return $this->createVerifier()->verify($url); + } catch (VerificationException $e) { + Craft::info([ + 'message' => 'Invalid URL signature', + 'reason' => $e->getMessage(), + 'url' => $url, + 'signatureParameter' => $this->signatureParameter, + ], __METHOD__); + + return false; + } + } + + private function createSigner(): HttpUrlSigner + { + return new HttpUrlSigner( + algorithm: $this->createAlgorithm(), + requestFactory: $this->createRequestFactory(), + config: $this->createConfig(), + ); + } + + private function createVerifier(): HttpUrlVerifier + { + return new HttpUrlVerifier( + algorithm: $this->createAlgorithm(), + requestFactory: $this->createRequestFactory(), + config: $this->createConfig(), + ); + } + + private function createAlgorithm(): HmacSha256 + { + return new HmacSha256($this->signingKey); + } + + private function createRequestFactory(): HttpFactory + { + return new HttpFactory(); + } + + private function createConfig(): UrlSigningConfig + { + return new UrlSigningConfig( + components: self::SIGNATURE_COMPONENTS, + signatureParam: $this->signatureParameter, + ); + } +} diff --git a/tests/unit/EsiTest.php b/tests/unit/EsiTest.php index b555cf8..ab1cf02 100644 --- a/tests/unit/EsiTest.php +++ b/tests/unit/EsiTest.php @@ -4,7 +4,7 @@ use Codeception\Test\Unit; use craft\cloud\Esi; -use craft\cloud\UrlSigner; +use craft\cloud\signing\UrlSigner; use craft\web\twig\TemplateLoaderException; use InvalidArgumentException; diff --git a/tests/unit/HelperTest.php b/tests/unit/HelperTest.php index c8110d1..47519f8 100644 --- a/tests/unit/HelperTest.php +++ b/tests/unit/HelperTest.php @@ -3,9 +3,11 @@ namespace craft\cloud\tests\unit; use Codeception\Test\Unit; -use craft\cloud\Helper; use craft\cloud\HeaderEnum; +use craft\cloud\Helper; use craft\cloud\Module; +use craft\cloud\signing\RequestSigner; +use GuzzleHttp\HandlerStack; use GuzzleHttp\RequestOptions; use ReflectionMethod; @@ -20,13 +22,18 @@ protected function _before(): void $this->craftCloudDevMode = getenv('CRAFT_CLOUD_DEV_MODE'); $this->previousModule = Module::getInstance(); - Module::setInstance(new Module('cloud')); + $module = new Module('cloud'); + Module::setInstance($module); - $config = Module::getInstance()->getConfig(); + $config = $module->getConfig(); $config->environmentId = '123-environment-id'; $config->gatewayBaseUrl = 'https://gateway.craft.cloud'; $config->signingKey = 'test-signing-key'; + + $module->set('requestSigner', fn() => new RequestSigner( + signingKey: $module->getConfig()->signingKey ?? '', + )); } protected function _after(): void @@ -60,12 +67,20 @@ public function testGatewayApiClientsUseConfiguredGatewayBaseUrl(): void ); } - public function testGatewayApiClientsAddAuthenticationHeaders(): void + public function testGatewayApiClientsDoNotAddStaticAuthenticationHeaders(): void { $client = Helper::createGatewayApiClient(); $headers = $client->getConfig(RequestOptions::HEADERS); - $this->assertSame('Bearer test-signing-key', $headers['X-Gateway-Authorization'] ?? null); + $this->assertArrayNotHasKey('X-Gateway-Authorization', $headers); + } + + public function testGatewayApiRequestsAreSignedAtRequestTime(): void + { + $handlerStack = Helper::createGatewayApiClient()->getConfig('handler'); + + $this->assertInstanceOf(HandlerStack::class, $handlerStack); + $this->assertStringContainsString('craft_cloud_request_signing', (string) $handlerStack); } public function testGatewayApiClientsAddDevModeHeader(): void diff --git a/tests/unit/ImageTransformTest.php b/tests/unit/ImageTransformTest.php index 6a86308..953528a 100644 --- a/tests/unit/ImageTransformTest.php +++ b/tests/unit/ImageTransformTest.php @@ -5,6 +5,7 @@ use Codeception\Test\Unit; use Craft; use craft\cloud\Module as CloudModule; +use craft\cloud\signing\UrlSigner; use craft\cloud\fs\AssetsFs; use craft\cloud\imagetransforms\ImageTransformBehavior; use craft\cloud\imagetransforms\ImageTransformer; @@ -24,6 +25,8 @@ protected function _before(): void $module = new CloudModule('cloud'); $module->bootstrap(Craft::$app); } + + CloudModule::getInstance()->set('urlSigner', fn() => new UrlSigner('test-signing-key')); } public function testCropModeWithExplicitGravityPreservesItInOptions(): void @@ -127,6 +130,18 @@ public function testGetTransformUrlDoesNotLeakGravityBetweenAssets(): void $this->assertStringNotContainsString('gravity%5Bx%5D=0.57', $secondUrl); } + public function testTransformUrlSigningUsesSharedUrlSigner(): void + { + $transformer = new UrlTestImageTransformer(); + $asset = $this->makeUrlAssetStub(1, 'test.jpg', 100, 100, ['x' => 0.5, 'y' => 0.5]); + $transform = new ImageTransform(['width' => 100, 'height' => 100]); + + $signedUrl = $transformer->getTransformUrl($asset, $transform, true); + + $this->assertStringContainsString('&s=', $signedUrl); + $this->assertTrue(CloudModule::getInstance()->getUrlSigner()->verify($signedUrl)); + } + public function testEditImageActionUsesNativeTransforms(): void { $module = new TestCloudModule('cloud-test'); diff --git a/tests/unit/RequestSignerTest.php b/tests/unit/RequestSignerTest.php new file mode 100644 index 0000000..a7a83d6 --- /dev/null +++ b/tests/unit/RequestSignerTest.php @@ -0,0 +1,63 @@ +sign($request); + + $this->assertSame('', $request->getHeaderLine('Signature')); + $this->assertSignedRequest($signedRequest); + } + + public function testCreatesHandlerStackForSignedRequests(): void + { + $capturedRequest = null; + $handlerStack = new HandlerStack(function(RequestInterface $request) use (&$capturedRequest) { + $capturedRequest = $request; + + return Create::promiseFor(new Response(204)); + }); + + $handler = (new RequestSigner('test-signing-key')) + ->createHandlerStack($handlerStack) + ->resolve(); + + $response = $handler(new Request('GET', 'https://consumer.example.test/status'), [])->wait(); + + $this->assertSame(204, $response->getStatusCode()); + $this->assertInstanceOf(RequestInterface::class, $capturedRequest); + $this->assertSignedRequest($capturedRequest); + } + + private function assertSignedRequest(RequestInterface $request): void + { + $this->assertNotSame('', $request->getHeaderLine('Signature')); + $this->assertTrue((new Verifier(new HmacSha256('test-signing-key')))->verify($request)); + + $signatureInput = $request->getHeaderLine('Signature-Input'); + + $this->assertStringContainsString('"@method"', $signatureInput); + $this->assertStringContainsString('"@target-uri"', $signatureInput); + $this->assertStringContainsString('alg="hmac-sha256"', $signatureInput); + $this->assertStringContainsString('keyid="hmac"', $signatureInput); + + $matches = []; + $this->assertSame(1, preg_match('/created=(\d+);expires=(\d+)/', $signatureInput, $matches)); + $this->assertSame(RequestSigner::DEFAULT_EXPIRES_AFTER, (int) $matches[2] - (int) $matches[1]); + } +} diff --git a/tests/unit/UrlSignerTest.php b/tests/unit/UrlSignerTest.php index c14ac27..c4cc9d2 100644 --- a/tests/unit/UrlSignerTest.php +++ b/tests/unit/UrlSignerTest.php @@ -3,7 +3,8 @@ namespace craft\cloud\tests\unit; use Codeception\Test\Unit; -use craft\cloud\UrlSigner; +use craft\cloud\signing\UrlSigner; +use League\Uri\Components\Query; class UrlSignerTest extends Unit { @@ -64,6 +65,17 @@ public function testSignWithExistingQueryParameters() $this->tester->assertTrue($this->urlSigner->verify($signedUrl)); } + public function testSignReplacesExistingSignatureParameter() + { + $signedUrl = $this->urlSigner->sign('https://example.com/test?s=old&foo=bar'); + + $parameters = Query::fromUri($signedUrl)->parameters(); + + $this->tester->assertArrayHasKey('s', $parameters); + $this->tester->assertNotSame('old', $parameters['s']); + $this->tester->assertTrue($this->urlSigner->verify($signedUrl)); + } + public function testCustomSignatureParameter() { $customSigner = new UrlSigner('test-key', 'signature');