Skip to content

Commit 8d09cc3

Browse files
committed
Merge pull request #13989 from owncloud/enhancment/security/11857
Allow AppFramework applications to specify a custom CSP header
2 parents 84cc90a + a9d1a01 commit 8d09cc3

File tree

8 files changed

+529
-22
lines changed

8 files changed

+529
-22
lines changed

config/config.sample.php

Lines changed: 0 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -929,15 +929,6 @@
929929
'mssql'
930930
),
931931

932-
/**
933-
* Custom CSP policy, changing this will overwrite the standard policy
934-
*/
935-
'custom_csp_policy' =>
936-
"default-src 'self'; script-src 'self' 'unsafe-eval'; ".
937-
"style-src 'self' 'unsafe-inline'; frame-src *; img-src *; ".
938-
"font-src 'self' data:; media-src *; connect-src *",
939-
940-
941932
/**
942933
* All other config options
943934
*/

lib/private/response.php

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -189,7 +189,7 @@ static public function sendFile($filepath) {
189189
}
190190
}
191191

192-
/*
192+
/**
193193
* This function adds some security related headers to all requests served via base.php
194194
* The implementation of this function has to happen here to ensure that all third-party
195195
* components (e.g. SabreDAV) also benefit from this headers.
@@ -204,17 +204,20 @@ public static function addSecurityHeaders() {
204204
header('X-Frame-Options: Sameorigin'); // Disallow iFraming from other domains
205205
}
206206

207-
// Content Security Policy
208-
// If you change the standard policy, please also change it in config.sample.php
209-
$policy = OC_Config::getValue('custom_csp_policy',
210-
'default-src \'self\'; '
207+
/**
208+
* FIXME: Content Security Policy for legacy ownCloud components. This
209+
* can be removed once \OCP\AppFramework\Http\Response from the AppFramework
210+
* is used everywhere.
211+
* @see \OCP\AppFramework\Http\Response::getHeaders
212+
*/
213+
$policy = 'default-src \'self\'; '
211214
. 'script-src \'self\' \'unsafe-eval\'; '
212215
. 'style-src \'self\' \'unsafe-inline\'; '
213216
. 'frame-src *; '
214217
. 'img-src *; '
215218
. 'font-src \'self\' data:; '
216219
. 'media-src *; '
217-
. 'connect-src *');
220+
. 'connect-src *';
218221
header('Content-Security-Policy:' . $policy);
219222

220223
// https://developers.google.com/webmasters/control-crawl-index/docs/robots_meta_tag
Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,241 @@
1+
<?php
2+
/**
3+
* Copyright (c) 2015 Lukas Reschke [email protected]
4+
* This file is licensed under the Affero General Public License version 3 or
5+
* later.
6+
* See the COPYING-README file.
7+
*/
8+
9+
namespace OCP\AppFramework\Http;
10+
11+
use OCP\AppFramework\Http;
12+
13+
/**
14+
* Class ContentSecurityPolicy is a simple helper which allows applications to
15+
* modify the Content-Security-Policy sent by ownCloud. Per default only JavaScript,
16+
* stylesheets, images, fonts, media and connections from the same domain
17+
* ('self') are allowed.
18+
*
19+
* Even if a value gets modified above defaults will still get appended. Please
20+
* notice that ownCloud ships already with sensible defaults and those policies
21+
* should require no modification at all for most use-cases.
22+
*
23+
* @package OCP\AppFramework\Http
24+
*/
25+
class ContentSecurityPolicy {
26+
/** @var bool Whether inline JS snippets are allowed */
27+
private $inlineScriptAllowed = false;
28+
/**
29+
* @var bool Whether eval in JS scripts is allowed
30+
* TODO: Disallow per default
31+
* @link https://github.com/owncloud/core/issues/11925
32+
*/
33+
private $evalScriptAllowed = true;
34+
/** @var array Domains from which scripts can get loaded */
35+
private $allowedScriptDomains = [
36+
'\'self\'',
37+
];
38+
/**
39+
* @var bool Whether inline CSS is allowed
40+
* TODO: Disallow per default
41+
* @link https://github.com/owncloud/core/issues/13458
42+
*/
43+
private $inlineStyleAllowed = true;
44+
/** @var array Domains from which CSS can get loaded */
45+
private $allowedStyleDomains = [
46+
'\'self\'',
47+
];
48+
/** @var array Domains from which images can get loaded */
49+
private $allowedImageDomains = [
50+
'\'self\'',
51+
];
52+
/** @var array Domains to which connections can be done */
53+
private $allowedConnectDomains = [
54+
'\'self\'',
55+
];
56+
/** @var array Domains from which media elements can be loaded */
57+
private $allowedMediaDomains = [
58+
'\'self\'',
59+
];
60+
/** @var array Domains from which object elements can be loaded */
61+
private $allowedObjectDomains = [];
62+
/** @var array Domains from which iframes can be loaded */
63+
private $allowedFrameDomains = [];
64+
/** @var array Domains from which fonts can be loaded */
65+
private $allowedFontDomains = [
66+
'\'self\'',
67+
];
68+
69+
/**
70+
* Whether inline JavaScript snippets are allowed or forbidden
71+
* @param bool $state
72+
* @return $this
73+
*/
74+
public function allowInlineScript($state = false) {
75+
$this->inlineScriptAllowed = $state;
76+
return $this;
77+
}
78+
79+
/**
80+
* Whether eval in JavaScript is allowed or forbidden
81+
* @param bool $state
82+
* @return $this
83+
*/
84+
public function allowEvalScript($state = true) {
85+
$this->evalScriptAllowed= $state;
86+
return $this;
87+
}
88+
89+
/**
90+
* Allows to execute JavaScript files from a specific domain. Use * to
91+
* allow JavaScript from all domains.
92+
* @param string $domain Domain to whitelist. Any passed value needs to be properly sanitized.
93+
* @return $this
94+
*/
95+
public function addAllowedScriptDomain($domain) {
96+
$this->allowedScriptDomains[] = $domain;
97+
return $this;
98+
}
99+
100+
/**
101+
* Whether inline CSS snippets are allowed or forbidden
102+
* @param bool $state
103+
* @return $this
104+
*/
105+
public function allowInlineStyle($state = true) {
106+
$this->inlineStyleAllowed = $state;
107+
return $this;
108+
}
109+
110+
/**
111+
* Allows to execute CSS files from a specific domain. Use * to allow
112+
* CSS from all domains.
113+
* @param string $domain Domain to whitelist. Any passed value needs to be properly sanitized.
114+
* @return $this
115+
*/
116+
public function addAllowedStyleDomain($domain) {
117+
$this->allowedStyleDomains[] = $domain;
118+
return $this;
119+
}
120+
121+
/**
122+
* Allows using fonts from a specific domain. Use * to allow
123+
* fonts from all domains.
124+
* @param string $domain Domain to whitelist. Any passed value needs to be properly sanitized.
125+
* @return $this
126+
*/
127+
public function addAllowedFontDomain($domain) {
128+
$this->allowedFontDomains[] = $domain;
129+
return $this;
130+
}
131+
132+
/**
133+
* Allows embedding images from a specific domain. Use * to allow
134+
* images from all domains.
135+
* @param string $domain Domain to whitelist. Any passed value needs to be properly sanitized.
136+
* @return $this
137+
*/
138+
public function addAllowedImageDomain($domain) {
139+
$this->allowedImageDomains[] = $domain;
140+
return $this;
141+
}
142+
143+
/**
144+
* To which remote domains the JS connect to.
145+
* @param string $domain Domain to whitelist. Any passed value needs to be properly sanitized.
146+
* @return $this
147+
*/
148+
public function addAllowedConnectDomain($domain) {
149+
$this->allowedConnectDomains[] = $domain;
150+
return $this;
151+
}
152+
153+
/**
154+
* From whoch domains media elements can be embedded.
155+
* @param string $domain Domain to whitelist. Any passed value needs to be properly sanitized.
156+
* @return $this
157+
*/
158+
public function addAllowedMediaDomain($domain) {
159+
$this->allowedMediaDomains[] = $domain;
160+
return $this;
161+
}
162+
163+
/**
164+
* From which domains objects such as <object>, <embed> or <applet> are executed
165+
* @param string $domain Domain to whitelist. Any passed value needs to be properly sanitized.
166+
* @return $this
167+
*/
168+
public function addAllowedObjectDomain($domain) {
169+
$this->allowedObjectDomains[] = $domain;
170+
return $this;
171+
}
172+
173+
/**
174+
* Which domains can be embedded in an iframe
175+
* @param string $domain Domain to whitelist. Any passed value needs to be properly sanitized.
176+
* @return $this
177+
*/
178+
public function addAllowedFrameDomain($domain) {
179+
$this->allowedFrameDomains[] = $domain;
180+
return $this;
181+
}
182+
183+
/**
184+
* Get the generated Content-Security-Policy as a string
185+
* @return string
186+
*/
187+
public function buildPolicy() {
188+
$policy = "default-src 'none';";
189+
190+
if(!empty($this->allowedScriptDomains)) {
191+
$policy .= 'script-src ' . implode(' ', $this->allowedScriptDomains);
192+
if($this->inlineScriptAllowed) {
193+
$policy .= ' \'unsafe-inline\'';
194+
}
195+
if($this->evalScriptAllowed) {
196+
$policy .= ' \'unsafe-eval\'';
197+
}
198+
$policy .= ';';
199+
}
200+
201+
if(!empty($this->allowedStyleDomains)) {
202+
$policy .= 'style-src ' . implode(' ', $this->allowedStyleDomains);
203+
if($this->inlineStyleAllowed) {
204+
$policy .= ' \'unsafe-inline\'';
205+
}
206+
$policy .= ';';
207+
}
208+
209+
if(!empty($this->allowedImageDomains)) {
210+
$policy .= 'img-src ' . implode(' ', $this->allowedImageDomains);
211+
$policy .= ';';
212+
}
213+
214+
if(!empty($this->allowedFontDomains)) {
215+
$policy .= 'font-src ' . implode(' ', $this->allowedFontDomains);
216+
$policy .= ';';
217+
}
218+
219+
if(!empty($this->allowedConnectDomains)) {
220+
$policy .= 'connect-src ' . implode(' ', $this->allowedConnectDomains);
221+
$policy .= ';';
222+
}
223+
224+
if(!empty($this->allowedMediaDomains)) {
225+
$policy .= 'media-src ' . implode(' ', $this->allowedMediaDomains);
226+
$policy .= ';';
227+
}
228+
229+
if(!empty($this->allowedObjectDomains)) {
230+
$policy .= 'object-src ' . implode(' ', $this->allowedObjectDomains);
231+
$policy .= ';';
232+
}
233+
234+
if(!empty($this->allowedFrameDomains)) {
235+
$policy .= 'frame-src ' . implode(' ', $this->allowedFrameDomains);
236+
$policy .= ';';
237+
}
238+
239+
return rtrim($policy, ';');
240+
}
241+
}

lib/public/appframework/http/response.php

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,9 @@ class Response {
7272
*/
7373
private $ETag;
7474

75+
/** @var ContentSecurityPolicy|null Used Content-Security-Policy */
76+
private $contentSecurityPolicy = null;
77+
7578

7679
/**
7780
* Caches the response
@@ -186,13 +189,19 @@ public function setHeaders(array $headers) {
186189
* @return array the headers
187190
*/
188191
public function getHeaders() {
189-
$mergeWith = array();
192+
$mergeWith = [];
190193

191194
if($this->lastModified) {
192195
$mergeWith['Last-Modified'] =
193196
$this->lastModified->format(\DateTime::RFC2822);
194197
}
195198

199+
// Build Content-Security-Policy and use default if none has been specified
200+
if(is_null($this->contentSecurityPolicy)) {
201+
$this->setContentSecurityPolicy(new ContentSecurityPolicy());
202+
}
203+
$this->headers['Content-Security-Policy'] = $this->contentSecurityPolicy->buildPolicy();
204+
196205
if($this->ETag) {
197206
$mergeWith['ETag'] = '"' . $this->ETag . '"';
198207
}
@@ -221,6 +230,25 @@ public function setStatus($status) {
221230
return $this;
222231
}
223232

233+
/**
234+
* Set a Content-Security-Policy
235+
* @param ContentSecurityPolicy $csp Policy to set for the response object
236+
* @return $this
237+
*/
238+
public function setContentSecurityPolicy(ContentSecurityPolicy $csp) {
239+
$this->contentSecurityPolicy = $csp;
240+
return $this;
241+
}
242+
243+
/**
244+
* Get the currently used Content-Security-Policy
245+
* @return ContentSecurityPolicy|null Used Content-Security-Policy or null if
246+
* none specified.
247+
*/
248+
public function getContentSecurityPolicy() {
249+
return $this->contentSecurityPolicy;
250+
}
251+
224252

225253
/**
226254
* Get response status

tests/lib/appframework/controller/ControllerTest.php

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -173,11 +173,12 @@ public function testFormat() {
173173

174174

175175
public function testFormatDataResponseJSON() {
176-
$expectedHeaders = array(
176+
$expectedHeaders = [
177177
'test' => 'something',
178178
'Cache-Control' => 'no-cache, must-revalidate',
179-
'Content-Type' => 'application/json; charset=utf-8'
180-
);
179+
'Content-Type' => 'application/json; charset=utf-8',
180+
'Content-Security-Policy' => "default-src 'none';script-src 'self' 'unsafe-eval';style-src 'self' 'unsafe-inline';img-src 'self';font-src 'self';connect-src 'self';media-src 'self'",
181+
];
181182

182183
$response = $this->controller->customDataResponse(array('hi'));
183184
$response = $this->controller->buildResponse($response, 'json');

0 commit comments

Comments
 (0)