Skip to content

Commit 58edb37

Browse files
committed
Fixes issues with CSP nonces on Craft 4. Bump to 1.4.0
1 parent e310adf commit 58edb37

File tree

4 files changed

+65
-28
lines changed

4 files changed

+65
-28
lines changed

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
# ToolMate Changelog
22

3+
## 1.4.0 - 2022-05-14
4+
### Added
5+
- Craft domains (i.e. Craft ID and the plugin store API) are now automatically included in the `connect-src` directive for control panel requests
6+
- `unsafe-inline` directives are now added automatically for Yii error pages
7+
### Fixed
8+
- Fixes an issue where unhashed CSP nonces would not be included in the actual CSP header, on Craft 4.0
9+
### Changed
10+
- Refactored logic concerning how and when the CSP header is set
11+
312
## 1.3.1 - 2022-05-12
413
### Fixed
514
- Fixed an issue where ToolMate failed to include the `'unsafe-inline'` policy resource for the `style-src` CSP directive, for site requests where the Yii debug toolbar is enabled

composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "vaersaagod/toolmate",
33
"description": "Is that a tool in your pocket, or are you just happy to see me, mate?",
44
"type": "craft-plugin",
5-
"version": "1.3.1",
5+
"version": "1.4.0",
66
"keywords": [
77
"craft",
88
"cms",

src/ToolMate.php

Lines changed: 31 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,11 @@
44

55
use Craft;
66
use craft\base\Plugin;
7-
use craft\events\TemplateEvent;
87
use craft\helpers\App;
9-
use craft\web\Application;
8+
use craft\web\Response;
9+
use craft\web\TemplateResponseFormatter;
1010
use craft\web\twig\variables\CraftVariable;
1111

12-
use craft\web\View;
1312
use vaersaagod\toolmate\models\Settings;
1413
use vaersaagod\toolmate\services\CspService;
1514
use vaersaagod\toolmate\services\EmbedService;
@@ -99,8 +98,12 @@ protected function createSettingsModel(): Settings
9998
return new Settings();
10099
}
101100

101+
/**
102+
* @return void
103+
*/
102104
protected function maybeSendCspHeader(): void
103105
{
106+
104107
$cspConfig = self::getInstance()?->getSettings()->csp;
105108

106109
if (!$cspConfig->enabled) {
@@ -111,31 +114,40 @@ protected function maybeSendCspHeader(): void
111114
return;
112115
}
113116

114-
// Replace hashed CSP nonces (this gets us around the template cache)
117+
// Replace hashed nonces and set the CSP header for HTML responses
115118
Event::on(
116-
View::class,
117-
View::EVENT_AFTER_RENDER_PAGE_TEMPLATE,
118-
static function(TemplateEvent $event) {
119-
\preg_match_all('/nonce="([^"]*)"/', $event->output, $matches);
119+
Response::class,
120+
\yii\web\Response::EVENT_AFTER_PREPARE,
121+
static function (Event $event) {
122+
$response = $event->sender;
123+
if (!$response instanceof Response || empty($response?->content)) {
124+
return;
125+
}
126+
$validFormats = [
127+
\yii\web\Response::FORMAT_RAW,
128+
\yii\web\Response::FORMAT_HTML,
129+
];
130+
if (class_exists('craft\\web\\TemplateResponseFormatter')) {
131+
$validFormats[] = TemplateResponseFormatter::FORMAT;
132+
}
133+
if (!in_array($response?->format, $validFormats)) {
134+
return;
135+
}
136+
// Replace hashed CSP nonces (this gets us around the template cache)
137+
\preg_match_all('/nonce="([^"]*)"/', $response->content, $matches);
120138
$hashedNonces = $matches[1] ?? [];
139+
$cspService = ToolMate::getInstance()->csp;
121140
for ($i = 0, $iMax = \count($hashedNonces); $i < $iMax; ++$i) {
122141
if (!$unhashedNonce = Craft::$app->getSecurity()->validateData($hashedNonces[$i])) {
123142
continue;
124143
}
125144
[0 => $directive, 1 => $nonce] = \explode(':', $unhashedNonce);
126-
if (!ToolMate::getInstance()->csp->hasNonce($directive, $nonce)) {
127-
$nonce = ToolMate::getInstance()->csp->createNonce($directive);
145+
if (!$cspService->hasNonce($directive, $nonce)) {
146+
$nonce = $cspService->createNonce($directive);
128147
}
129-
$event->output = \str_replace($matches[0][$i], "nonce=\"$nonce\"", $event->output);
148+
$response->content = \str_replace($matches[0][$i], "nonce=\"$nonce\"", $response->content);
130149
}
131-
}
132-
);
133-
134-
Event::on(
135-
Application::class,
136-
\yii\base\Application::EVENT_AFTER_REQUEST,
137-
static function(Event $event) {
138-
ToolMate::getInstance()->csp->setHeader();
150+
ToolMate::getInstance()->csp->setHeader($response);
139151
}
140152
);
141153
}

src/services/CspService.php

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77

88
use craft\elements\User;
99
use craft\helpers\StringHelper;
10+
use craft\web\Response;
11+
1012
use vaersaagod\toolmate\ToolMate;
1113

1214
/**
@@ -44,32 +46,46 @@ public function hasNonce(string $directive, string $nonce): bool
4446
}
4547

4648
/**
49+
* @param Response $response
4750
* @return void
4851
*/
49-
public function setHeader(): void
52+
public function setHeader(Response $response): void
5053
{
54+
5155
$config = ToolMate::getInstance()?->getSettings()->csp;
56+
$request = Craft::$app->getRequest();
5257

5358
// Get directives
5459
$directivesConfig = $config->getDirectives();
5560

56-
if (Craft::$app->getRequest()->getIsSiteRequest()) {
61+
if ($request->getIsSiteRequest()) {
5762
// If the Yii debug toolbar is visible on the front end, we unfortunately need to set the `unsafe-inline` policy for the script-src and style-src directive
63+
// Also include them if the Yii error page is returned (i.e. it's an error response and dev mode is enabled)
5864
$currentUser = Craft::$app->getUser()->getIdentity();
59-
if ($currentUser instanceof User && $currentUser->getPreference('enableDebugToolbarForSite')) {
65+
if (($currentUser instanceof User && $currentUser->getPreference('enableDebugToolbarForSite')) || ($response->getStatusCode() >= 400 && Craft::$app->getConfig()->getGeneral()->devMode)) {
6066
$directivesConfig->scriptSrc[] = "'unsafe-inline' 'unsafe-eval'";
6167
$directivesConfig->styleSrc[] = "'unsafe-inline'";
6268
}
63-
} elseif (Craft::$app->getRequest()->getIsCpRequest()) {
69+
} elseif ($request->getIsCpRequest()) {
6470
// If this is a CP request, make sure some needed policies are included
6571
$directivesConfig->frameAncestors[] = "'self'";
6672
$directivesConfig->scriptSrc[] = "'unsafe-inline' 'unsafe-eval'";
6773
$directivesConfig->styleSrc[] = "'unsafe-inline'";
74+
$directivesConfig->fontSrc[] = 'data:';
75+
// Stripe
76+
$directivesConfig->scriptSrc[] = 'https://js.stripe.com';
77+
$directivesConfig->frameSrc[] = 'https://js.stripe.com';
78+
// Make sure Craft domains are supported
79+
$pluginStoreService = Craft::$app->getPluginStore();
80+
$directivesConfig->connectSrc[] = 'https://' . parse_url($pluginStoreService->craftApiEndpoint, PHP_URL_HOST);
81+
$directivesConfig->connectSrc[] = 'https://' . parse_url($pluginStoreService->craftIdEndpoint, PHP_URL_HOST);
82+
$directivesConfig->connectSrc[] = 'https://' . parse_url($pluginStoreService->craftOauthEndpoint, PHP_URL_HOST);
83+
$directivesConfig->imgSrc[] = 'https://*.craft-cdn.com';
6884
}
6985

7086
// Convert directive names to kebab-case, remove duplicates, etc
7187
$directivesArray = $config->getDirectives()->toArray();
72-
$directives = \array_reduce(\array_keys($directivesArray), static function(array $carry, string $field) use ($directivesArray) {
88+
$directives = \array_reduce(\array_keys($directivesArray), static function (array $carry, string $field) use ($directivesArray) {
7389
$policy = \array_filter(\explode(' ', \implode(' ', $directivesArray[$field])));
7490
if (empty($policy)) {
7591
return $carry;
@@ -90,7 +106,7 @@ public function setHeader(): void
90106
}
91107
}
92108

93-
// Clear nonces
109+
// Clear memoized nonces
94110
$this->nonces = [];
95111

96112
$cspValues = [];
@@ -101,10 +117,10 @@ public function setHeader(): void
101117
$csp = \implode('; ', $cspValues) . ';';
102118

103119
if ($config->reportOnly) {
104-
Craft::$app->getResponse()->getHeaders()->set('Content-Security-Policy-Report-Only', $csp);
120+
$response->getHeaders()->set('Content-Security-Policy-Report-Only', $csp);
105121
return;
106122
}
107123

108-
Craft::$app->getResponse()->getHeaders()->set('Content-Security-Policy', $csp);
124+
$response->getHeaders()->set('Content-Security-Policy', $csp);
109125
}
110126
}

0 commit comments

Comments
 (0)