Skip to content

Commit 5d6f80f

Browse files
committed
Make security headers configuration consistent for frontend and assets
1 parent d553da3 commit 5d6f80f

5 files changed

Lines changed: 52 additions & 41 deletions

File tree

ChangeLog.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ Web frontends change log
33

44
## ?.?.? / ????-??-??
55

6+
* Made it possible to use `web.frontend.Security` instances for assets
7+
(@thekid)
68
* Implemented default CSP in `web.frontend.AssetsFrom` to prevent XSS
79
with SVG files. See #49 and https://stackoverflow.com/q/10557137
810
(@thekid)

README.md

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -315,20 +315,31 @@ To configure framing, referrer and content security policies, use the *security(
315315
```php
316316
use web\frontend\{Frontend, Security};
317317

318-
$frontend= (new Frontend($delegates, $templates))
319-
->enacting((new Security())
320-
->framing('SAMEORIGIN')
321-
->referrers('strict-origin')
322-
->csp([
323-
'default-src' => '"none"',
324-
'script-src' => ['"self"', '"nonce-{{nonce}}"', 'https://example.com'],
325-
// etcetera
326-
])
327-
)
318+
$policy= (new Security())
319+
->framing('SAMEORIGIN')
320+
->referrers('strict-origin')
321+
->csp([
322+
'default-src' => '"none"',
323+
'script-src' => ['"self"', '"nonce-{{nonce}}"', 'https://example.com'],
324+
// etcetera
325+
])
326+
;
327+
$frontend= (new Frontend($delegates, $templates))->enacting($policy);
328328
;
329329
```
330330

331-
Read more about hardening response headers at https://scotthelme.co.uk/hardening-your-http-response-headers/ or watch this talk: https://www.youtube.com/watch?v=mr230uotw-Y
331+
For static assets, the same policy can be used:
332+
333+
```php
334+
use web\frontend\{AssetsFrom, Security};
335+
336+
$policy= /* see above */
337+
$assets= (new AssetsFrom($path))->enacting($policy);
338+
```
339+
340+
The default configuration is to set `script-src 'none'; object-src 'none'`, see https://stackoverflow.com/q/10557137
341+
342+
*Read more about hardening response headers at https://scotthelme.co.uk/hardening-your-http-response-headers/ or watch this talk: https://www.youtube.com/watch?v=mr230uotw-Y*
332343

333344
## Performance
334345

src/main/php/web/frontend/AssetsFrom.class.php

Lines changed: 9 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
*/
1717
class AssetsFrom extends FilesFrom {
1818
const PREFERENCE= ['br', 'bzip2', 'gzip', 'deflate'];
19-
const POLICY= 'script-src none; object-src none';
19+
const POLICY= ['script-src' => "'none'", 'object-src' => "'none'"];
2020
const ENCODINGS= [
2121
'br' => '.br',
2222
'bzip2' => '.bz2',
@@ -27,27 +27,22 @@ class AssetsFrom extends FilesFrom {
2727
];
2828

2929
private $sources= [];
30-
private $csp= self::POLICY;
30+
private $security;
3131
private $preference;
3232

3333
/** @param io.Path|io.Folder|string|io.Path[]|io.Folder[]|string[] $sources */
3434
public function __construct($sources) {
35+
$this->security= (new Security())->csp(self::POLICY);
3536
$this->preferring(self::PREFERENCE);
3637
foreach (is_array($sources) ? $sources : [$sources] as $source) {
3738
$this->sources[]= $source instanceof Path ? $source : new Path($source);
3839
}
3940
parent::__construct($this->sources[0] ?? '.');
4041
}
4142

42-
/**
43-
* Change content security policy
44-
*
45-
* @see https://github.com/xp-forge/frontend/issues/49
46-
* @param string|string[] $csp
47-
* @return self
48-
*/
49-
public function policy($csp) {
50-
$this->csp= is_array($csp) ? implode('; ', $csp) : (string)$csp;
43+
/** Overwrites security */
44+
public function enacting(Security $security): self {
45+
$this->security= $security;
5146
return $this;
5247
}
5348

@@ -135,8 +130,10 @@ public function handle($request, $response) {
135130
$target= new Path($source, $path.(self::ENCODINGS[$encoding] ?? '*'));
136131
if ($target->exists() && $target->isFile()) {
137132
$response->header('Vary', 'Accept-Encoding');
138-
$response->header('Content-Security-Policy', $this->csp);
139133
'*' === $encoding || $response->header('Content-Encoding', $encoding);
134+
foreach ($this->security->headers() as $name => $value) {
135+
$response->header($name, $value);
136+
}
140137

141138
return $this->serve($request, $response, $target->asFile(), $this->mime($path));
142139
}

src/main/php/web/frontend/Security.class.php

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ class Security {
1717
'Referrer-Policy' => 'no-referrer-when-downgrade',
1818
];
1919

20+
/** @return [:string] */
21+
public function headers() { return $this->headers; }
22+
2023
/** Sets frame options */
2124
public function framing(string $value): self {
2225
$this->headers['X-Frame-Options']= $value;
@@ -32,18 +35,24 @@ public function referrers(string $value): self {
3235
/**
3336
* Sets content security policy
3437
*
35-
* @param [:string|string[]] $sources
38+
* @param string|[:string|string[]] $policy
3639
* @param bool $reportOnly whether to report only (true) or to enforce (false)
3740
* @return self
3841
* @see https://content-security-policy.com/
3942
*/
40-
public function csp(array $sources, bool $reportOnly= false): self {
43+
public function csp($policy, bool $reportOnly= false): self {
4144
$name= $reportOnly ? 'Content-Security-Policy-Report-Only' : 'Content-Security-Policy';
42-
$header= '';
43-
foreach ($sources as $source => $value) {
44-
$header.= '; '.$source.' '.strtr(is_array($value) ? implode(' ', $value) : $value, '"', "'");
45+
46+
if (is_array($policy)) {
47+
$header= '';
48+
foreach ($policy as $source => $value) {
49+
$header.= '; '.$source.' '.strtr(is_array($value) ? implode(' ', $value) : $value, '"', "'");
50+
}
51+
$this->headers[$name]= substr($header, 2);
52+
} else {
53+
$this->headers[$name]= $policy;
4554
}
46-
$this->headers[$name]= substr($header, 2);
55+
4756
return $this;
4857
}
4958

src/test/php/web/frontend/unittest/AssetsFromTest.class.php

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
use io\{File, Files, Folder};
44
use lang\Environment;
55
use test\{After, Assert, Test, Values};
6-
use web\frontend\AssetsFrom;
6+
use web\frontend\{AssetsFrom, Security};
77
use web\io\{TestInput, TestOutput};
88
use web\{Request, Response};
99

@@ -161,25 +161,17 @@ public function includes_csp_header_by_default() {
161161
$assets= new AssetsFrom($this->folderWith(['fixture.css' => '...']));
162162
$res= $this->serve($assets, '/fixture.css');
163163

164-
Assert::equals(AssetsFrom::POLICY, $res->headers()['Content-Security-Policy']);
164+
Assert::equals("script-src 'none'; object-src 'none'", $res->headers()['Content-Security-Policy']);
165165
}
166166

167167
#[Test]
168-
public function using_string_csp() {
168+
public function enacting_security() {
169169
$assets= new AssetsFrom($this->folderWith(['fixture.css' => '...']));
170-
$res= $this->serve($assets->policy('script-src none'), '/fixture.css');
170+
$res= $this->serve($assets->enacting((new Security())->csp('script-src none')), '/fixture.css');
171171

172172
Assert::equals('script-src none', $res->headers()['Content-Security-Policy']);
173173
}
174174

175-
#[Test]
176-
public function using_array_csp() {
177-
$assets= new AssetsFrom($this->folderWith(['fixture.css' => '...']));
178-
$res= $this->serve($assets->policy(['script-src none', 'object-src none']), '/fixture.css');
179-
180-
Assert::equals('script-src none; object-src none', $res->headers()['Content-Security-Policy']);
181-
}
182-
183175
#[Test, Values([[['fixture.css' => self::CONTENTS]], [['fixture.css.gz' => self::COMPRESSED]]])]
184176
public function handles_conditional_requests($files) {
185177
$res= $this->serve(new AssetsFrom($this->folderWith($files)), '/fixture.css', [

0 commit comments

Comments
 (0)