Skip to content

Commit 3ee6ed5

Browse files
committed
Merge branch 'release/2.3.0'
2 parents 340dd1a + 96332eb commit 3ee6ed5

16 files changed

+381
-47
lines changed

CHANGELOG.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,19 @@
11
# Release Notes for Webhooks for Craft CMS
22

3+
## 2.3.0 - 2020-07-18
4+
5+
### Added
6+
- Added a settings page in the control panel.
7+
- Webhooks have a new “Debounce Key Format” setting, which can be used to debouncing similar webhook requests. ([#27](https://github.com/craftcms/webhooks/issues/27))
8+
- Webhook URLs can now be set to environment variables or Twig code. ([#18](https://github.com/craftcms/webhooks/issues/18))
9+
- Webhooks can now send PUT requests. ([#21](https://github.com/craftcms/webhooks/issues/21))
10+
- The Activity page now has a “Clear” button, which will clear out all request activity for completed requests. ([#32](https://github.com/craftcms/webhooks/issues/32))
11+
- Added a new `guzzleConfig` plugin setting, which can be set from `config/webhooks.php` to customize the Guzzle config for webhook requests. ([#31](https://github.com/craftcms/webhooks/issues/31))
12+
13+
### Fixed
14+
- Fixed a bug where webhooks would lose their custom payload template when enabled or disabled from the Manage Webhooks page. ([#22](https://github.com/craftcms/webhooks/issues/22))
15+
- Fixed a bug where the Edit Webhook page wouldn’t immediately show available filters if the webhook didn’t have any preselected filters.
16+
317
## 2.2.0 - 2019-07-29
418

519
### Added

README.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,15 +42,15 @@ To configure Webhooks, create a `config/webhooks.php` file, which returns an arr
4242
return [
4343
'maxDepth' => 10,
4444
'maxAttempts' => 3,
45-
'attemptDelay' => 120,
45+
'retryDelay' => 120,
4646
];
4747
```
4848

4949
The array can define the following keys:
5050

5151
- `maxDepth` – The maximum depth that the plugin should go into objects/arrays when converting them to arrays for event payloads. (Default is `5`.)
5252
- `maxAttempts` – The maximum number of attempts each webhook should have before giving up, if the requests are coming back with non 2xx responses. (Default is `1`.)
53-
- `attemptDelay` – The delay (in seconds) between webhook attempts. (Default is `60`.)
53+
- `retryDelay` – The delay (in seconds) between webhook attempts. (Default is `60`.)
5454

5555
## Managing Webhooks
5656

@@ -70,6 +70,8 @@ Webhooks listen to [events](https://www.yiiframework.com/doc/guide/2.0/en/concep
7070

7171
The Sender Class can be a subclass of the class that triggers the event. For example, all elements fire an [afterSave](https://docs.craftcms.com/api/v3/craft-base-element.html#event-after-save) event after they’ve been saved, courtesy of their base class, [craft\base\Element](https://docs.craftcms.com/api/v3/craft-base-element.html). However if you’re only interested in sending a webhook when an _entry_ gets saved, you can set the Sender Class to [craft\elements\Entry](https://docs.craftcms.com/api/v3/craft-elements-entry.html).
7272

73+
Webhook URLs can be set to an environment variable (`$VARIABLE_NAME`) or Twig code. If you set it to Twig code, you can reference the triggered event via an `event` variable.
74+
7375
See [Integrating with Task Automation Tools](#integrating-with-task-automation-tools) for examples on how to get a Webhook URL from various task automation tools.
7476

7577
![Screenshot of the Edit Webhook page](./images/edit-webhook.png)
@@ -90,6 +92,8 @@ Some events can have filters applied to them, which prevent webhooks from being
9092

9193
<img src="./images/event-filters.png" width="414" height="381" alt="Screenshot of the Event Filters setting">
9294

95+
Ignored filters (``) will not have any impact. Positive filters (``) will be required for a webhook to execute, and a negative filter (`×`) will prevent it.
96+
9397
Only element class events and certain `craft\services\Elements` events have any filters out of the box, but modules and plugins can register additional filters using the `craft\webhooks\Plugin::EVENT_REGISTER_FILTER_TYPES` event.
9498

9599
```php
@@ -132,6 +136,14 @@ class ArticleFilter implements FilterInterface
132136
}
133137
```
134138

139+
#### Debouncing Webhooks
140+
141+
You can prevent multiple similar webhooks from being sent by setting a “Debounce Key Format” on your webhook. This is a Twig template that defines a “debounce key” for the webhook. If two webhooks generate the same debounce key, only the second one will actually be sent.
142+
143+
An `event` variable will be available to it that references the event that was triggered.
144+
145+
For example, if your webhook is for an entry (`craft\elements\Entry`), then you could set the Debounce Key Format to `{{ event.sender.id }}` to prevent multiple webhook requests from being queued up at the same time.
146+
135147
#### Sending Custom Headers
136148

137149
You can send custom headers along with webhook requests using the Custom Headers setting.

SECURITY.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
# Security Policy
2+
3+
## Reporting a Vulnerability
4+
5+
If you discover a security vulnerability, please review these guidelines before submitting a report. We take security seriously and do our best to resolve security issues as quickly as possible.
6+
7+
## Guidelines
8+
9+
While working to identify potential security vulnerabilities, we ask that you:
10+
11+
- Share any issues you discover with us via [Github](https://github.com/craftcms/cms/security/advisories) or [our website](https://craftcms.com/contact) as soon as possible.
12+
- Give us a reasonable amount of time to address any reported issues before publicizing them.
13+
- Only report issues that are [in scope](#scope).
14+
- Provide a quality report with precise explanations and concrete attack scenarios.
15+
- Make sure you’re aware of the versions of Craft and Commerce that are actively [receiving security fixes](https://craftcms.com/knowledge-base/supported-versions).
16+
17+
## Scope
18+
19+
We are only interested in vulnerabilities that affect Craft or [first party Craft plugins](https://github.com/craftcms), tested against **your own local installation of the software**. You can install a local copy of Craft by following these [installation instructions](https://craftcms.com/docs/installing). Do **not** test against any Craft installation that you don’t own, including [craftcms.com](https://craftcms.com) or [demo.craftcms.com](https://demo.craftcms.com).
20+
21+
### Qualifying Vulnerabilities
22+
23+
- [Cross-Site Scripting (XSS)](https://en.wikipedia.org/wiki/Cross-site_scripting)
24+
- [Cross-Site Request Forgery (CSRF)](https://en.wikipedia.org/wiki/Cross-site_request_forgery)
25+
- [Arbitrary Code Execution](https://en.wikipedia.org/wiki/Arbitrary_code_execution)
26+
- [Privilege Escalation](https://en.wikipedia.org/wiki/Privilege_escalation)
27+
- [SQL Injection](https://en.wikipedia.org/wiki/SQL_injection)
28+
- [Session Hijacking](https://en.wikipedia.org/wiki/Session_hijacking)
29+
30+
### Non-Qualifying Vulnerabilities
31+
32+
- Reports from automated tools or scanners
33+
- Theoretical attacks without proof of exploitability
34+
- Attacks that can be guarded against by following our [security recommendations](https://craftcms.com/guides/securing-craft).
35+
- Server configuration issues outside of Craft’s control
36+
- [Denial of Service](https://en.wikipedia.org/wiki/Denial-of-service_attack) attacks
37+
- [Brute force attacks](https://en.wikipedia.org/wiki/Brute-force_attack) (e.g. on password or token hashes)
38+
- Username or email address enumeration
39+
- Social engineering of Pixel & Tonic staff or users of Craft installations
40+
- Physical attacks against Craft installations
41+
- Attacks involving physical access to a user’s device, or involving a device or network that’s already seriously compromised (e.g. [man-in-the-middle attacks](https://en.wikipedia.org/wiki/Man-in-the-middle_attack))
42+
- Attacks that are the result of a third party Craft plugin should be reported to the plugin’s author
43+
- Attacks that are the result of a third party library should be reported to the library maintainers
44+
- Bugs that rely on an unlikely user interaction (i.e. the user effectively attacking themselves)
45+
- Disclosure of tools or libraries used by Craft and/or their versions
46+
- Issues that are the result of a user clearly ignoring common security best practices (like sharing their password publicly)
47+
- Missing security headers which do not lead directly to a vulnerability via proof of concept
48+
- Vulnerabilities affecting users of outdated/unsupported browsers or platforms
49+
- Vulnerabilities affecting outdated versions of Craft
50+
- Any behavior that is clearly documented
51+
- Issues discovered while scanning a site you don’t own without permission
52+
- Missing CSRF tokens on forms (unless you have a proof of concept, many forms either don’t need CSRF or are mitigated in other ways) and “logout” CSRF attacks
53+
- [Open redirects](https://www.owasp.org/index.php/open_redirect)
54+
55+
## Bounties
56+
57+
To show our appreciation for the work it can take to find and report a vulnerability, we’re happy to offer researchers a monetary reward.
58+
59+
Reward amounts vary depending upon the severity. Our minimum reward for a qualifying vulnerability report is $50 USD and we expect to pay $500+ USD for major vulnerabilities.
60+
61+
A report will qualify for a bounty if:
62+
63+
- Our [Guidelines](#guidelines) have been followed in full.
64+
- The vulnerability was previously unknown to us, or your report provides more information or shows the vulnerability to be more extensive than we originally thought.
65+
- The vulnerability is non-trivial.

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
{
22
"name": "craftcms/webhooks",
33
"description": "Post webhooks when events are triggered in Craft CMS.",
4-
"version": "2.2.0",
4+
"version": "2.3.0",
55
"type": "craft-plugin",
66
"keywords": [
77
"html",

src/Plugin.php

Lines changed: 104 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020
use craft\webhooks\filters\RevisionFilter;
2121
use GuzzleHttp\Exception\RequestException;
2222
use GuzzleHttp\RequestOptions;
23+
use yii\base\Application;
2324
use yii\base\Arrayable;
2425
use yii\base\Event;
2526
use yii\base\Exception;
@@ -38,9 +39,6 @@
3839
*/
3940
class Plugin extends \craft\base\Plugin
4041
{
41-
// Constants
42-
// =========================================================================
43-
4442
const STATUS_PENDING = 'pending';
4543
const STATUS_REQUESTED = 'requested';
4644
const STATUS_DONE = 'done';
@@ -68,8 +66,10 @@ class Plugin extends \craft\base\Plugin
6866
*/
6967
const EVENT_REGISTER_FILTER_TYPES = 'registerFilterTypes';
7068

71-
// Properties
72-
// =========================================================================
69+
/**
70+
* @inheritdoc
71+
*/
72+
public $hasCpSettings = true;
7373

7474
/**
7575
* @inheritdoc
@@ -79,10 +79,14 @@ class Plugin extends \craft\base\Plugin
7979
/**
8080
* @inheritdoc
8181
*/
82-
public $schemaVersion = '2.2.0';
82+
public $schemaVersion = '2.3.0';
8383

84-
// Public Methods
85-
// =========================================================================
84+
/**
85+
* @var SendRequestJob[] The request jobs that should be queued up at the end of the web request.
86+
* @see request()
87+
* @since 2.3.0
88+
*/
89+
private $_pendingJobs = [];
8690

8791
/**
8892
* @inheritdoc
@@ -120,7 +124,7 @@ public function init()
120124

121125
$view = Craft::$app->getView();
122126

123-
if ($webhook->method === 'post') {
127+
if (in_array($webhook->method, ['post', 'put'], true)) {
124128
// Build out the body data
125129
if ($webhook->payloadTemplate) {
126130
$json = $view->renderString($webhook->payloadTemplate, [
@@ -154,11 +158,9 @@ public function init()
154158

155159
foreach ($webhook->headers as $header) {
156160
$header['value'] = Craft::parseEnv($header['value']);
157-
if (strpos($header['value'], '{') !== false) {
158-
$header['value'] = $view->renderString($header['value'], [
159-
'event' => $e,
160-
]);
161-
}
161+
$header['value'] = $view->renderString($header['value'], [
162+
'event' => $e,
163+
]);
162164
// Get the trimmed lines
163165
$lines = array_filter(array_map('trim', preg_split('/[\r\n]+/', $header['value'])));
164166
// Add to the header array one-by-one, ensuring that we don't overwrite existing values
@@ -182,7 +184,19 @@ public function init()
182184
}
183185

184186
// Queue the send request up
185-
$this->request($webhook->method, $webhook->url, $headers, $body, $webhook->id);
187+
$url = Craft::parseEnv($webhook->url);
188+
$url = $view->renderString($url, [
189+
'event' => $e,
190+
]);
191+
192+
if ($webhook->debounceKeyFormat) {
193+
$debounceKey = Craft::parseEnv($webhook->debounceKeyFormat);
194+
$debounceKey = $webhook->id . ':' . $view->renderString($debounceKey, [
195+
'event' => $e,
196+
]);
197+
}
198+
199+
$this->request($webhook->method, $url, $headers, $body, $webhook->id, $debounceKey ?? null);
186200
});
187201
}
188202

@@ -196,6 +210,16 @@ public function init()
196210
});
197211
}
198212

213+
/**
214+
* @inheritdoc
215+
*/
216+
protected function settingsHtml()
217+
{
218+
return Craft::$app->getView()->renderTemplate('webhooks/_settings', [
219+
'settings' => $this->getSettings(),
220+
]);
221+
}
222+
199223
/**
200224
* @inheritdoc
201225
*/
@@ -217,14 +241,45 @@ public function getCpNavItem()
217241
* @param array|null $headers
218242
* @param string|null $body
219243
* @param int|null $webhookId
244+
* @param string|null $debounceKey
220245
* @throws \yii\db\Exception
221246
*/
222-
public function request(string $method, string $url, array $headers = null, string $body = null, int $webhookId = null)
247+
public function request(string $method, string $url, array $headers = null, string $body = null, int $webhookId = null, string $debounceKey = null)
223248
{
224249
$db = Craft::$app->getDb();
250+
251+
if ($debounceKey !== null) {
252+
// See if there is an existing pending request with the same key
253+
$requestId = (new Query())
254+
->select(['id'])
255+
->from(['{{%webhookrequests}}'])
256+
->where([
257+
'debounceKey' => $debounceKey,
258+
'status' => self::STATUS_PENDING,
259+
])
260+
->scalar();
261+
262+
// If so and we get a lock on it, update that request instead of creating a new one
263+
if ($requestId && $this->_lockRequest($requestId)) {
264+
$db->createCommand()
265+
->update('{{%webhookrequests}}', [
266+
'method' => $method,
267+
'url' => $url,
268+
'requestHeaders' => $headers ? Json::encode($headers) : null,
269+
'requestBody' => $body,
270+
'dateCreated' => Db::prepareDateForDb(new \DateTime()),
271+
], [
272+
'id' => $requestId,
273+
], [], false);
274+
$this->_unlockRequest($requestId);
275+
return;
276+
}
277+
}
278+
225279
$db->createCommand()
226280
->insert('{{%webhookrequests}}', [
227281
'webhookId' => $webhookId,
282+
'debounceKey' => $debounceKey,
228283
'status' => self::STATUS_PENDING,
229284
'method' => $method,
230285
'url' => $url,
@@ -235,10 +290,27 @@ public function request(string $method, string $url, array $headers = null, stri
235290
], false)
236291
->execute();
237292

238-
Craft::$app->getQueue()->push(new SendRequestJob([
293+
$this->_pendingJobs[] = new SendRequestJob([
239294
'requestId' => $db->getLastInsertID('{{%webhookrequests}}'),
240295
'webhookId' => $webhookId,
241-
]));
296+
]);
297+
298+
if (count($this->_pendingJobs) === 1) {
299+
Craft::$app->on(Application::EVENT_AFTER_REQUEST, [$this, 'pushPendingJobs'], null, false);
300+
}
301+
}
302+
303+
/**
304+
* Pushes any pending jobs to the queue.
305+
*
306+
* @since 2.3.0
307+
*/
308+
public function pushPendingJobs()
309+
{
310+
$queue = Craft::$app->getQueue();
311+
while ($job = array_shift($this->_pendingJobs)) {
312+
$queue->push($job);
313+
}
242314
}
243315

244316
/**
@@ -279,9 +351,7 @@ public function getRequestData(int $requestId): array
279351
public function sendRequest(int $requestId): bool
280352
{
281353
// Acquire a lock on the request
282-
$lockName = 'webhook.' . $requestId;
283-
$mutex = Craft::$app->getMutex();
284-
if (!$mutex->acquire($lockName)) {
354+
if (!$this->_lockRequest($requestId, 1)) {
285355
throw new Exception('Could not acquire a lock for the webhook request ' . $requestId);
286356
}
287357

@@ -306,7 +376,8 @@ public function sendRequest(int $requestId): bool
306376

307377
$startTime = microtime(true);
308378
try {
309-
$response = Craft::createGuzzleClient()->request($data['method'], $data['url'], $options);
379+
$response = Craft::createGuzzleClient($this->getSettings()->guzzleConfig)
380+
->request($data['method'], $data['url'], $options);
310381
$success = true;
311382
} catch (RequestException $e) {
312383
$response = $e->getResponse();
@@ -330,7 +401,7 @@ public function sendRequest(int $requestId): bool
330401
->execute();
331402

332403
// Release the lock
333-
$mutex->release($lockName);
404+
$this->_unlockRequest($requestId);
334405

335406
return $success;
336407
}
@@ -418,4 +489,14 @@ public function getAllFilters(): array
418489

419490
return $event->types;
420491
}
492+
493+
private function _lockRequest(int $requestId, int $timeout = 0): bool
494+
{
495+
return Craft::$app->getMutex()->acquire("webhook:$requestId", $timeout);
496+
}
497+
498+
private function _unlockRequest(int $requestId): bool
499+
{
500+
return Craft::$app->getMutex()->release("webhook:$requestId");
501+
}
421502
}

src/SendRequestJob.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ public function execute($queue)
6262
$settings = Plugin::getInstance()->getSettings();
6363
if ($attempts < $settings->maxAttempts) {
6464
Craft::$app->getQueue()
65-
->delay($settings->attemptDelay)
65+
->delay($settings->retryDelay)
6666
->push(new self([
6767
'requestId' => $this->requestId,
6868
]));

0 commit comments

Comments
 (0)