-
Notifications
You must be signed in to change notification settings - Fork 160
Document Cloud request signing #776
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
timkelty
wants to merge
9
commits into
main
Choose a base branch
from
timkelty/add-signed-request-docs
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+129
−0
Open
Changes from all commits
Commits
Show all changes
9 commits
Select commit
Hold shift + click to select a range
51b7f64
Add Craft Cloud request signing docs
timkelty 8ffc90a
Clarify Cloud request signing docs
timkelty a407bd1
Fix request signing docs lint
timkelty c2367be
Clarify external request signing docs
timkelty 986926e
Clarify request signature verification policy
timkelty 22dccda
Move request verification guidance into examples
timkelty 786d9be
Tighten gateway signature expiration note
timkelty 7729524
Rename external request signing heading
timkelty 4451fea
Fix request signing intro wording
timkelty File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,128 @@ | ||
| --- | ||
| description: Sign trusted programmatic requests to avoid bot rate limiting. | ||
| --- | ||
|
|
||
| # Request Signing | ||
|
|
||
| Request signing lets trusted systems make programmatic requests to Craft Cloud without being treated like unsanctioned bot traffic. | ||
|
|
||
| This is useful for automated systems like headless build processes or CI/CD pipelines, which can correctly look like bots and be rate-limited more aggressively than browsers. | ||
|
|
||
| When Cloud verifies a request signature, it treats the request as project-approved and bypasses bot-specific rate limiting. | ||
|
|
||
| Signatures use the environment’s `$CRAFT_CLOUD_SIGNING_KEY` to generate signatures. Treat this as a secret! | ||
|
|
||
| For more details on RFC 9421 HTTP Message Signatures, see [httpsig.org](https://httpsig.org/). | ||
|
|
||
| ## Signing Requests from Craft | ||
|
|
||
| The `craftcms/cloud` package can sign any PSR-7 request: | ||
|
|
||
| ```php | ||
| use craft\cloud\Module; | ||
| use GuzzleHttp\Client; | ||
| use GuzzleHttp\Psr7\Request; | ||
|
|
||
| $signer = Module::getInstance()->getRequestSigner(); | ||
|
|
||
| $request = new Request( | ||
| 'POST', | ||
| 'https://api.example.test/webhook', | ||
| ['Content-Type' => 'application/json'], | ||
| json_encode([ | ||
| 'event' => 'order.paid', | ||
| ], JSON_THROW_ON_ERROR), | ||
| ); | ||
|
|
||
| $signedRequest = $signer->sign($request); | ||
|
|
||
| $response = (new Client())->send($signedRequest); | ||
| ``` | ||
|
|
||
| To verify a signed PSR-7 request in Craft, use the same signing key: | ||
|
|
||
| ```php | ||
| use craft\helpers\App; | ||
| use HttpMessageSignatures\Algorithm\HmacSha256; | ||
| use HttpMessageSignatures\Verifier; | ||
|
|
||
| $isValid = (new Verifier(new HmacSha256(App::env('CRAFT_CLOUD_SIGNING_KEY')))) | ||
| ->verify($request); | ||
| ``` | ||
|
|
||
| ## Signing Requests Externally | ||
|
|
||
| External systems can generate valid signatures for a Craft Cloud environment, given the corresponding `$CRAFT_CLOUD_SIGNING_KEY`. | ||
|
|
||
| Signatures expire after 5 minutes when verified by the Craft Cloud gateway. Set `expires` about 5 minutes after `created`. | ||
|
|
||
| ### Node.js | ||
|
|
||
| This example signs a request with [`http-message-sig`](https://www.npmjs.com/package/http-message-sig): | ||
|
|
||
| ```bash | ||
| npm install http-message-sig | ||
| ``` | ||
|
|
||
| Then sign the request before sending it to Craft: | ||
|
|
||
| ```js | ||
| import crypto from 'node:crypto'; | ||
| import { signatureHeadersSync } from 'http-message-sig'; | ||
|
|
||
| const method = 'POST'; | ||
| const url = process.env.CRAFT_GRAPHQL_URL; | ||
|
|
||
| const body = JSON.stringify({ | ||
| query: ` | ||
| { | ||
| entries(section: "blog") { | ||
| title | ||
| url | ||
| } | ||
| } | ||
| `, | ||
| }); | ||
|
|
||
| const headers = { | ||
| 'Content-Type': 'application/json', | ||
| Authorization: `Bearer ${process.env.CRAFT_GRAPHQL_TOKEN}`, | ||
| }; | ||
|
|
||
| const created = new Date(); | ||
|
|
||
| const signer = { | ||
| keyid: 'hmac', | ||
| alg: 'hmac-sha256', | ||
| signSync(data) { | ||
| return crypto | ||
| .createHmac('sha256', process.env.CRAFT_CLOUD_SIGNING_KEY) | ||
| .update(data) | ||
| .digest(); | ||
| }, | ||
| }; | ||
|
|
||
| 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, | ||
| }); | ||
|
|
||
| const result = await response.json(); | ||
| ``` | ||
|
|
||
| Store the signing key in the external system’s secret manager. The `@target-uri` value must match the requested URL exactly, including any query string. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Just so I'm clear… our example satisfies this |
||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Duplicate sentence?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is the recommendation then to just avoid reusing/retrying any signed URLs, in case they were consumed by the gateway and/or have expired?
Is a URL valid for five minutes from signing, or does that timer count down from its first use (as far as the gateway is aware)?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In general, yes. They are expected to be signed and used at request time.
From the
createdtimestamp of the signature. Nothing special we're doing here, just standard http sig: https://www.rfc-editor.org/rfc/rfc9421.html#section-3.2.1-3.2There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Should just lose the "Set
expiresabout 5 minutes aftercreated.".