Skip to content

Commit b170653

Browse files
committed
Adds CSP stuff. Bump to 1.2.0
1 parent 6d67d64 commit b170653

File tree

10 files changed

+559
-27
lines changed

10 files changed

+559
-27
lines changed

CHANGELOG.md

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

3+
## 1.2.0 - 2022-02-13
4+
### Added
5+
- Added `Settings::csp` for configuring the Content-Security-Policy header
6+
- Added the `cspNonce()` Twig function for outputting a CSP nonce attribute or value
7+
38
## 1.1.0 - 2021-10-06
49

510
### Added

README.md

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,92 @@ If set to `0`, error response caches will be stored indefinitely.
6060

6161
See [`craft\helpers\ConfigHelper::durationInSeconds()`](https://docs.craftcms.com/api/v3/craft-helpers-confighelper.html#method-durationinseconds) for a list of supported value types.
6262

63+
### csp [array|null]
64+
*Default `null`*
65+
66+
Configure the Content-Security-Policy header set by Toolmate. Some useful tips:
67+
68+
* Avoid using `unsafe-inline` and `unsafe-eval` policies, especially for the `script-src` directive. CSP nonces should ideally be used for inline script or style tags, see the `cspNonce()` Twig function.
69+
* Nonces and `unsafe-inline` cannot be combined. Toolmate works around this to avoid CSP errors, but TLDR; is that you don't need to set nonces if you're also using `unsafe-inline`.
70+
* CSP nonces generated by `cspNonce()` are safe to put inside `{% cache %}` tags
71+
* To enable data-URLs, add a `data:` policy to the relevant directives
72+
* For CP requests, Toolmate will always add the necessary `unsafe-inline` and `unsafe-eval` policies, because the CP isn't possible to use without.
73+
74+
#### csp[enabled] [bool]
75+
*Default `false`*
76+
77+
If set to `false`, the CSP header will not be sent for any requests.
78+
79+
#### csp[enabledForCp] [bool]
80+
*Default `false`*
81+
82+
If set to `false`, the CSP header will only be sent for site requests.
83+
84+
#### csp[reportOnly] [bool]
85+
*Default `false`*
86+
87+
If set to `true`, the CSP header will be sent, but not enforced (i.e. dry-run mode). Useful for testing policies.
88+
89+
#### csp[directives] [array]
90+
91+
See https://content-security-policy.com/, and the example config below.
92+
93+
### Example CSP configuration:
94+
95+
```php
96+
'csp' => [
97+
'enabled' => true,
98+
'enabledForCp' => false,
99+
'reportOnly' => false,
100+
'directives' => [
101+
'defaultSrc' => [
102+
"'self'",
103+
],
104+
'scriptSrc' => [
105+
"'self'",
106+
"'unsafe-inline'",
107+
],
108+
// Sources for stylesheets
109+
'styleSrc' => [
110+
"'self'",
111+
"'unsafe-inline'",
112+
],
113+
// Sources for images
114+
'imgSrc' => [
115+
"'self'",
116+
'https://some-project.imgix.net',
117+
'data:'
118+
],
119+
// Sources for iframes
120+
'frameSrc' => [
121+
"'self'",
122+
'https://www.youtube.com https://player.vimeo.com https://www.facebook.com https://www.googletagmanager.com https://bid.g.doubleclick.net',
123+
],
124+
// Domains that are allowed to iframe this site
125+
'frameAncestors' => [
126+
"'self'",
127+
],
128+
'baseUri' => [
129+
"'none'",
130+
],
131+
'connectSrc' => [],
132+
'fontSrc' => [
133+
//"'self'",
134+
],
135+
'objectSrc' => [],
136+
'mediaSrc' => [],
137+
'sandbox' => [],
138+
'reportUri' => [],
139+
'childSrc' => [],
140+
'formAction' => [],
141+
'reportTo' => [],
142+
'workerSrc' => [],
143+
'manifestSrc' => [],
144+
'navigateTo' => [],
145+
],
146+
],
147+
```
148+
63149
---
64150

65151
## Template variables
@@ -192,6 +278,25 @@ See `craft.toolmate.getCookie`.
192278

193279
See `craft.toolmate.getCookie`.
194280

281+
### cspNonce(directive, [, asAttribute = false, hash = true])
282+
283+
Output a CSP nonce. Example:
284+
285+
```twig
286+
<script nonce="{{ cspNonce('script-src') }}">
287+
console.log('Hello world');
288+
</script>
289+
```
290+
291+
```twig
292+
<style nonce="{{ cspNonce('style-src') }}">
293+
body { ... }
294+
</style>
295+
```
296+
297+
```twig
298+
{% js 'foo.js' with { nonce: cspNonce('script-src') } %}
299+
```
195300

196301
---
197302

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.1.0",
5+
"version": "1.2.0",
66
"keywords": [
77
"craft",
88
"cms",

src/ToolMate.php

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,12 +4,17 @@
44

55
use Craft;
66
use craft\base\Plugin;
7+
use craft\events\TemplateEvent;
78
use craft\log\FileTarget;
9+
use craft\web\Application;
810
use craft\web\twig\variables\CraftVariable;
911

12+
use craft\web\View;
13+
use vaersaagod\toolmate\services\CspService;
1014
use vaersaagod\toolmate\services\EmbedService;
1115
use vaersaagod\toolmate\services\MinifyService;
1216
use vaersaagod\toolmate\services\ToolService;
17+
use vaersaagod\toolmate\twigextensions\CspTwigExtension;
1318
use vaersaagod\toolmate\variables\ToolMateVariable;
1419
use vaersaagod\toolmate\twigextensions\ToolMateTwigExtension;
1520
use vaersaagod\toolmate\models\Settings;
@@ -21,10 +26,11 @@
2126
* @package PluginMate
2227
* @since 1.0.0
2328
*
24-
* @property EmbedService $embed
25-
* @property ToolService $tool
26-
* @property Settings $settings
27-
* @method Settings getSettings()
29+
* @property CspService $csp
30+
* @property EmbedService $embed
31+
* @property ToolService $tool
32+
* @property Settings $settings
33+
* @method Settings getSettings()
2834
*/
2935
class ToolMate extends Plugin
3036
{
@@ -55,6 +61,7 @@ public function init()
5561

5662
// Register services
5763
$this->setComponents([
64+
'csp' => CspService::class,
5865
'embed' => EmbedService::class,
5966
'tool' => ToolService::class,
6067
'minify' => MinifyService::class,
@@ -74,12 +81,19 @@ static function (Event $event) {
7481

7582
// Add in our Twig extensions
7683
Craft::$app->view->registerTwigExtension(new ToolMateTwigExtension());
84+
Craft::$app->view->registerTwigExtension(new CspTwigExtension());
7785

7886
// Lets use our own log file
7987
Craft::getLogger()->dispatcher->targets[] = new FileTarget([
8088
'logFile' => '@storage/logs/toolmate.log',
8189
'categories' => ['vaersaagod\toolmate\*'],
8290
]);
91+
92+
/**
93+
* Maybe send a Content-Security-Policy header
94+
*/
95+
$this->maybeSendCspHeader();
96+
8397
}
8498

8599
// Protected Methods
@@ -92,4 +106,47 @@ protected function createSettingsModel(): Settings
92106
{
93107
return new Settings();
94108
}
109+
110+
protected function maybeSendCspHeader()
111+
{
112+
113+
$cspConfig = ToolMate::getInstance()->getSettings()->csp;
114+
115+
if (!$cspConfig->enabled) {
116+
return;
117+
}
118+
119+
if (Craft::$app->getRequest()->getIsCpRequest() && !$cspConfig->enabledForCp) {
120+
return;
121+
}
122+
123+
// Replace hashed CSP nonces (this gets us around the template cache)
124+
Event::on(
125+
View::class,
126+
View::EVENT_AFTER_RENDER_PAGE_TEMPLATE,
127+
static function (TemplateEvent $event) {
128+
\preg_match_all('/nonce="([^"]*)"/', $event->output, $matches);
129+
$hashedNonces = $matches[1] ?? [];
130+
for ($i = 0; $i < \count($hashedNonces); $i += 1) {
131+
if (!$unhashedNonce = Craft::$app->getSecurity()->validateData($hashedNonces[$i])) {
132+
continue;
133+
}
134+
[0 => $directive, 1 => $nonce] = \explode(':', $unhashedNonce);
135+
if (!ToolMate::getInstance()->csp->hasNonce($directive, $nonce)) {
136+
$nonce = ToolMate::getInstance()->csp->createNonce($directive);
137+
}
138+
$event->output = \str_replace($matches[0][$i], "nonce=\"$nonce\"", $event->output);
139+
}
140+
}
141+
);
142+
143+
Event::on(
144+
Application::class,
145+
\yii\base\Application::EVENT_AFTER_REQUEST,
146+
static function (Event $event) {
147+
ToolMate::getInstance()->csp->setHeader();
148+
}
149+
);
150+
151+
}
95152
}

src/config.php

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,45 @@
11
<?php
22

33
return [
4+
45
'publicRoot' => '@webroot',
56
'enableMinify' => true,
6-
'embedCacheDuration' => null,
7+
'embedCacheDuration' => null, // null means use Craft's default `cacheDuration` setting
78
'embedCacheDurationOnError' => 'PT5M',
9+
10+
/*
11+
* HTTP Content-Security-Policy header config
12+
* https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP
13+
*/
14+
'csp' => [
15+
'enabled' => false,
16+
'enabledForCp' => false,
17+
'reportOnly' => false,
18+
'directives' => [
19+
// https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/default-src
20+
'defaultSrc' => [],
21+
'scriptSrc' => [],
22+
// Sources for stylesheets
23+
'styleSrc' => [],
24+
// Sources for images
25+
'imgSrc' => [],
26+
// Sources for iframes
27+
'frameSrc' => [],
28+
// Domains that are allowed to iframe this site
29+
'frameAncestors' => [],
30+
'baseUri' => [],
31+
'connectSrc' => [],
32+
'fontSrc' => [],
33+
'objectSrc' => [],
34+
'mediaSrc' => [],
35+
'sandbox' => [],
36+
'reportUri' => [],
37+
'childSrc' => [],
38+
'formAction' => [],
39+
'reportTo' => [],
40+
'workerSrc' => [],
41+
'manifestSrc' => [],
42+
'navigateTo' => [],
43+
],
44+
],
845
];

src/models/CspConfig.php

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
namespace vaersaagod\toolmate\models;
4+
5+
use craft\base\Model;
6+
7+
/**
8+
* ToolMate CspConfig Model
9+
*
10+
* @author Værsågod
11+
* @package ToolMate
12+
* @since 1.2.0
13+
*/
14+
class CspConfig extends Model
15+
{
16+
17+
/** @var bool */
18+
public $enabled = false;
19+
20+
/** @var bool */
21+
public $enabledForCp = false;
22+
23+
/** @var bool */
24+
public $reportOnly = false;
25+
26+
/**
27+
* @var CspDirectives|null
28+
* @see getDirectives()
29+
* @see setDirectives()
30+
*/
31+
private $_directives;
32+
33+
/** @inheritdoc */
34+
public function setAttributes($values, $safeOnly = true)
35+
{
36+
$this->setDirectives($values['directives'] ?? []);
37+
unset($values['directives']);
38+
parent::setAttributes($values, $safeOnly);
39+
}
40+
41+
/**
42+
* @param array $directives
43+
* @return void
44+
*/
45+
public function setDirectives(array $config = [])
46+
{
47+
$this->_directives = new CspDirectives($config);
48+
}
49+
50+
public function getDirectives(): CspDirectives
51+
{
52+
if (!$this->_directives) {
53+
$this->_directives = new CspDirectives();
54+
}
55+
return $this->_directives;
56+
}
57+
58+
}

0 commit comments

Comments
 (0)