28 use UnexpectedValueException;
47 private $extraDefaultSrc = [];
49 private $extraScriptSrc = [];
51 private $extraStyleSrc = [];
68 HookContainer $hookContainer
70 $this->response = $response;
71 $this->mwConfig = $mwConfig;
72 $this->hookRunner =
new HookRunner( $hookContainer );
84 $policy = $this->makeCSPDirectives( $csp, $reportOnly );
85 $headerName = $this->getHeaderName( $reportOnly );
87 $this->response->header(
88 "$headerName: $policy"
107 $this->
sendCSPHeader( $cspConfigReportOnly, self::REPORT_ONLY_MODE );
123 private function getHeaderName( $reportOnly ) {
124 if ( $reportOnly === self::REPORT_ONLY_MODE ) {
125 return 'Content-Security-Policy-Report-Only';
128 if ( $reportOnly === self::FULL_MODE ) {
129 return 'Content-Security-Policy';
131 throw new UnexpectedValueException(
"Mode '$reportOnly' not recognised" );
142 private function makeCSPDirectives( $policyConfig, $mode ) {
143 if ( $policyConfig ===
false ) {
147 if ( $policyConfig ===
true ) {
151 $mwConfig = $this->mwConfig;
154 self::isNonceRequired( $mwConfig ) ||
155 self::isNonceRequiredArray( [ $policyConfig ] )
157 wfDeprecated(
'wgCSPHeader "useNonces" option',
'1.41' );
160 $additionalSelfUrls = $this->getAdditionalSelfUrls();
161 $additionalSelfUrlsScript = $this->getAdditionalSelfUrlsScript();
166 $defaultSrc = [
'*',
'data:',
'blob:' ];
169 $scriptSrc = [
"'unsafe-eval'",
"blob:",
"'self'" ];
171 $scriptSrc = array_merge( $scriptSrc, $additionalSelfUrlsScript );
172 if ( isset( $policyConfig[
'script-src'] )
173 && is_array( $policyConfig[
'script-src'] )
175 foreach ( $policyConfig[
'script-src'] as $src ) {
176 $scriptSrc[] = $this->escapeUrlForCSP( $src );
180 if ( $policyConfig[
'unsafeFallback'] ??
true ) {
184 $scriptSrc[] =
"'unsafe-inline'";
190 if ( isset( $policyConfig[
'default-src'] )
191 && $policyConfig[
'default-src'] !==
false
193 $defaultSrc = array_merge(
194 [
"'self'",
'data:',
'blob:' ],
197 if ( is_array( $policyConfig[
'default-src'] ) ) {
198 foreach ( $policyConfig[
'default-src'] as $src ) {
199 $defaultSrc[] = $this->escapeUrlForCSP( $src );
204 if ( $policyConfig[
'includeCORS'] ??
true ) {
205 $CORSUrls = $this->getCORSSources();
206 if ( !in_array(
'*', $defaultSrc ) ) {
207 $defaultSrc = array_merge( $defaultSrc, $CORSUrls );
211 if ( !in_array(
'*', $scriptSrc ) ) {
212 $scriptSrc = array_merge( $scriptSrc, $CORSUrls );
216 $defaultSrc = array_merge( $defaultSrc, $this->extraDefaultSrc );
217 $scriptSrc = array_merge( $scriptSrc, $this->extraScriptSrc );
219 $cssSrc = array_merge( $defaultSrc, $this->extraStyleSrc, [
"'unsafe-inline'" ] );
221 $this->hookRunner->onContentSecurityPolicyDefaultSource( $defaultSrc, $policyConfig, $mode );
222 $this->hookRunner->onContentSecurityPolicyScriptSource( $scriptSrc, $policyConfig, $mode );
224 if ( isset( $policyConfig[
'report-uri'] ) && $policyConfig[
'report-uri'] !==
true ) {
225 if ( $policyConfig[
'report-uri'] ===
false ) {
228 $reportUri = $this->escapeUrlForCSP( $policyConfig[
'report-uri'] );
231 $reportUri = $this->getReportUri( $mode );
235 if ( !is_array( $defaultSrc )
236 || !in_array(
'*', $defaultSrc )
237 || !in_array(
'data:', $defaultSrc )
238 || !in_array(
'blob:', $defaultSrc )
249 $imgSrc = [
'*',
'data:',
'blob:' ];
251 $whitelist =
wfMessage(
'external_image_whitelist' )
252 ->inContentLanguage()
254 if ( preg_match(
'/^\s*[^\s#]/m', $whitelist ) ) {
255 $imgSrc = [
'*',
'data:',
'blob:' ];
261 if ( !isset( $policyConfig[
'object-src'] ) || $policyConfig[
'object-src'] ===
true ) {
262 $objectSrc = [
"'none'" ];
264 $objectSrc = (array)( $policyConfig[
'object-src'] ?: [] );
266 $objectSrc = array_map( [ $this,
'escapeUrlForCSP' ], $objectSrc );
270 $directives[] =
'script-src ' . implode(
' ', array_unique( $scriptSrc ) );
273 $directives[] =
'default-src ' . implode(
' ', array_unique( $defaultSrc ) );
276 $directives[] =
'style-src ' . implode(
' ', array_unique( $cssSrc ) );
279 $directives[] =
'img-src ' . implode(
' ', array_unique( $imgSrc ) );
282 $directives[] =
'object-src ' . implode(
' ', $objectSrc );
285 $directives[] =
'report-uri ' . $reportUri;
288 $this->hookRunner->onContentSecurityPolicyDirectives( $directives, $policyConfig, $mode );
290 return implode(
'; ', $directives );
300 private function getReportUri( $mode ) {
302 'action' =>
'cspreport',
305 if ( $mode === self::REPORT_ONLY_MODE ) {
306 $apiArguments[
'reportonly'] =
'1';
311 $reportUri = $this->escapeUrlForCSP( $reportUri );
330 private function prepareUrlForCSP( $url ) {
332 if ( preg_match(
'/^[a-z][a-z0-9+.-]*:$/i', $url ) ) {
337 if ( !$bits && strpos( $url,
'/' ) ===
false ) {
343 if ( $bits && isset( $bits[
'host'] )
346 $result = $bits[
'host'];
347 if ( $bits[
'scheme'] !==
'' ) {
348 $result = $bits[
'scheme'] . $bits[
'delimiter'] . $result;
350 if ( isset( $bits[
'port'] ) ) {
351 $result .=
':' . $bits[
'port'];
353 $result = $this->escapeUrlForCSP( $result );
361 private function getAdditionalSelfUrlsScript() {
362 $additionalUrls = [];
370 foreach ( $pathVars as
$path ) {
371 $url = $this->mwConfig->get(
$path );
372 $preparedUrl = $this->prepareUrlForCSP( $url );
373 if ( $preparedUrl ) {
374 $additionalUrls[] = $preparedUrl;
378 foreach ( $RLSources as $sources ) {
379 foreach ( $sources as $value ) {
380 $url = $this->prepareUrlForCSP( $value );
382 $additionalUrls[] = $url;
387 return array_unique( $additionalUrls );
396 private function getAdditionalSelfUrls() {
402 $additionalSelfUrls = [];
409 $callback =
static function ( $repo, &$urls ) {
410 $urls[] = $repo->getZoneUrl(
'public' );
411 $urls[] = $repo->getZoneUrl(
'transcoded' );
412 $urls[] = $repo->getZoneUrl(
'thumb' );
413 $urls[] = $repo->getDescriptionStylesheetUrl();
416 $localRepo = $repoGroup->getRepo(
'local' );
417 $callback( $localRepo, $pathUrls );
418 $repoGroup->forEachForeignRepo( $callback, [ &$pathUrls ] );
427 foreach ( $pathGlobals as
$path ) {
428 $pathUrls[] = $this->mwConfig->get(
$path );
430 foreach ( $pathUrls as
$path ) {
431 $preparedUrl = $this->prepareUrlForCSP(
$path );
432 if ( $preparedUrl !==
false ) {
433 $additionalSelfUrls[] = $preparedUrl;
438 foreach ( $RLSources as $sources ) {
439 foreach ( $sources as $value ) {
440 $url = $this->prepareUrlForCSP( $value );
442 $additionalSelfUrls[] = $url;
447 return array_unique( $additionalSelfUrls );
462 private function getCORSSources() {
463 $additionalUrls = [];
465 foreach ( $CORSSources as
$source ) {
466 if ( strpos(
$source,
'?' ) !==
false ) {
470 $url = $this->prepareUrlForCSP(
$source );
472 $additionalUrls[] = $url;
475 return $additionalUrls;
485 private function escapeUrlForCSP( $url ) {
504 return (
bool)preg_match(
'!Firefox/4[0-2]\.!', $ua );
518 return self::isNonceRequiredArray( $configs );
527 private static function isNonceRequiredArray( array $configs ) {
528 foreach ( $configs as $headerConfig ) {
530 is_array( $headerConfig ) &&
531 isset( $headerConfig[
'useNonces'] ) &&
532 $headerConfig[
'useNonces']
563 $this->extraDefaultSrc[] = $this->prepareUrlForCSP(
$source );
575 $this->extraStyleSrc[] = $this->prepareUrlForCSP(
$source );
588 $this->extraScriptSrc[] = $this->prepareUrlForCSP(
$source );
595 class_alias( ContentSecurityPolicy::class,
'ContentSecurityPolicy' );
wfParseUrl( $url)
parse_url() work-alike, but non-broken.
wfAppendQuery( $url, $query)
Append a query string to an existing URL, which may or may not already have query string parameters a...
wfScript( $script='index')
Get the URL path to a MediaWiki entry point.
wfMessage( $key,... $params)
This is the function for getting translated interface messages.
wfDeprecated( $function, $version=false, $component=false, $callerOffset=2)
Logs a warning that a deprecated feature was used.
A class containing constants representing the names of configuration variables.
const ServerName
Name constant for the ServerName setting, for use with Config::get()
const StylePath
Name constant for the StylePath setting, for use with Config::get()
const ExtensionAssetsPath
Name constant for the ExtensionAssetsPath setting, for use with Config::get()
const ResourceBasePath
Name constant for the ResourceBasePath setting, for use with Config::get()
const ResourceLoaderSources
Name constant for the ResourceLoaderSources setting, for use with Config::get()
const CSPHeader
Name constant for the CSPHeader setting, for use with Config::get()
const AllowExternalImages
Name constant for the AllowExternalImages setting, for use with Config::get()
const EnableImageWhitelist
Name constant for the EnableImageWhitelist setting, for use with Config::get()
const AllowImageTag
Name constant for the AllowImageTag setting, for use with Config::get()
const LoadScript
Name constant for the LoadScript setting, for use with Config::get()
const CSPReportOnlyHeader
Name constant for the CSPReportOnlyHeader setting, for use with Config::get()
const AllowExternalImagesFrom
Name constant for the AllowExternalImagesFrom setting, for use with Config::get()
const CrossSiteAJAXdomains
Name constant for the CrossSiteAJAXdomains setting, for use with Config::get()
Handle sending Content-Security-Policy headers.
static falsePositiveBrowser( $ua)
Does this browser give false positive reports?
static isNonceRequired(Config $config)
Should we set nonce attribute.
addStyleSrc( $source)
So for example, if an extension added a special page that loaded external CSS it might call $this->ge...
getNonce()
Get the nonce if nonce is in use.
addDefaultSrc( $source)
If possible you should use a more specific source type then default.
sendHeaders()
Send CSP headers based on wiki config.
addScriptSrc( $source)
So for example, if an extension added a special page that loaded something it might call $this->getOu...
sendCSPHeader( $csp, $reportOnly)
Send a single CSP header based on a given policy config.
__construct(WebResponse $response, Config $mwConfig, HookContainer $hookContainer)