45 private $extraDefaultSrc = [];
47 private $extraScriptSrc = [];
49 private $extraStyleSrc = [];
66 $this->response = $response;
67 $this->mwConfig = $mwConfig;
68 $this->hookRunner =
new HookRunner( $hookContainer );
80 $policy = $this->makeCSPDirectives( $csp, $reportOnly );
81 $headerName = $this->getHeaderName( $reportOnly );
83 $this->response->header(
84 "$headerName: $policy"
99 $cspConfig = $this->mwConfig->get( MainConfigNames::CSPHeader );
100 $cspConfigReportOnly = $this->mwConfig->get( MainConfigNames::CSPReportOnlyHeader );
103 $this->
sendCSPHeader( $cspConfigReportOnly, self::REPORT_ONLY_MODE );
119 private function getHeaderName( $reportOnly ) {
120 if ( $reportOnly === self::REPORT_ONLY_MODE ) {
121 return 'Content-Security-Policy-Report-Only';
124 if ( $reportOnly === self::FULL_MODE ) {
125 return 'Content-Security-Policy';
127 throw new UnexpectedValueException(
"Mode '$reportOnly' not recognised" );
138 private function makeCSPDirectives( $policyConfig, $mode ) {
139 if ( $policyConfig ===
false ) {
143 if ( $policyConfig ===
true ) {
147 $mwConfig = $this->mwConfig;
150 !self::isNonceRequired( $mwConfig ) &&
151 self::isNonceRequiredArray( [ $policyConfig ] )
155 throw new LogicException(
"Nonce requirement mismatch" );
158 $additionalSelfUrls = $this->getAdditionalSelfUrls();
159 $additionalSelfUrlsScript = $this->getAdditionalSelfUrlsScript();
166 $defaultSrc = [
'*',
'data:',
'blob:' ];
169 $scriptSrc = [
"'unsafe-eval'",
"blob:",
"'self'" ];
170 if ( $policyConfig[
'useNonces'] ??
true ) {
171 $scriptSrc[] =
"'nonce-" . $this->
getNonce() .
"'";
174 $scriptSrc = array_merge( $scriptSrc, $additionalSelfUrlsScript );
175 if ( isset( $policyConfig[
'script-src'] )
176 && is_array( $policyConfig[
'script-src'] )
178 foreach ( $policyConfig[
'script-src'] as $src ) {
179 $scriptSrc[] = $this->escapeUrlForCSP( $src );
183 if ( $policyConfig[
'unsafeFallback'] ??
true ) {
189 $scriptSrc[] =
"'unsafe-inline'";
195 if ( isset( $policyConfig[
'default-src'] )
196 && $policyConfig[
'default-src'] !==
false
198 $defaultSrc = array_merge(
199 [
"'self'",
'data:',
'blob:' ],
202 if ( is_array( $policyConfig[
'default-src'] ) ) {
203 foreach ( $policyConfig[
'default-src'] as $src ) {
204 $defaultSrc[] = $this->escapeUrlForCSP( $src );
209 if ( $policyConfig[
'includeCORS'] ??
true ) {
210 $CORSUrls = $this->getCORSSources();
211 if ( !in_array(
'*', $defaultSrc ) ) {
212 $defaultSrc = array_merge( $defaultSrc, $CORSUrls );
216 if ( !in_array(
'*', $scriptSrc ) ) {
217 $scriptSrc = array_merge( $scriptSrc, $CORSUrls );
221 $defaultSrc = array_merge( $defaultSrc, $this->extraDefaultSrc );
222 $scriptSrc = array_merge( $scriptSrc, $this->extraScriptSrc );
224 $cssSrc = array_merge( $defaultSrc, $this->extraStyleSrc, [
"'unsafe-inline'" ] );
226 $this->hookRunner->onContentSecurityPolicyDefaultSource( $defaultSrc, $policyConfig, $mode );
227 $this->hookRunner->onContentSecurityPolicyScriptSource( $scriptSrc, $policyConfig, $mode );
229 if ( isset( $policyConfig[
'report-uri'] ) && $policyConfig[
'report-uri'] !==
true ) {
230 if ( $policyConfig[
'report-uri'] ===
false ) {
233 $reportUri = $this->escapeUrlForCSP( $policyConfig[
'report-uri'] );
236 $reportUri = $this->getReportUri( $mode );
240 if ( !is_array( $defaultSrc )
241 || !in_array(
'*', $defaultSrc )
242 || !in_array(
'data:', $defaultSrc )
243 || !in_array(
'blob:', $defaultSrc )
250 if ( $mwConfig->
get( MainConfigNames::AllowExternalImages )
251 || $mwConfig->
get( MainConfigNames::AllowExternalImagesFrom )
252 || $mwConfig->
get( MainConfigNames::AllowImageTag )
254 $imgSrc = [
'*',
'data:',
'blob:' ];
255 } elseif ( $mwConfig->
get( MainConfigNames::EnableImageWhitelist ) ) {
256 $whitelist =
wfMessage(
'external_image_whitelist' )
257 ->inContentLanguage()
259 if ( preg_match(
'/^\s*[^\s#]/m', $whitelist ) ) {
260 $imgSrc = [
'*',
'data:',
'blob:' ];
266 if ( !isset( $policyConfig[
'object-src'] ) || $policyConfig[
'object-src'] ===
true ) {
267 $objectSrc = [
"'none'" ];
269 $objectSrc = (array)( $policyConfig[
'object-src'] ?: [] );
271 $objectSrc = array_map( [ $this,
'escapeUrlForCSP' ], $objectSrc );
275 $directives[] =
'script-src ' . implode(
' ', array_unique( $scriptSrc ) );
278 $directives[] =
'default-src ' . implode(
' ', array_unique( $defaultSrc ) );
281 $directives[] =
'style-src ' . implode(
' ', array_unique( $cssSrc ) );
284 $directives[] =
'img-src ' . implode(
' ', array_unique( $imgSrc ) );
287 $directives[] =
'object-src ' . implode(
' ', $objectSrc );
290 $directives[] =
'report-uri ' . $reportUri;
293 $this->hookRunner->onContentSecurityPolicyDirectives( $directives, $policyConfig, $mode );
295 return implode(
'; ', $directives );
305 private function getReportUri( $mode ) {
307 'action' =>
'cspreport',
310 if ( $mode === self::REPORT_ONLY_MODE ) {
311 $apiArguments[
'reportonly'] =
'1';
316 $reportUri = $this->escapeUrlForCSP( $reportUri );
335 private function prepareUrlForCSP( $url ) {
337 if ( preg_match(
'/^[a-z][a-z0-9+.-]*:$/i', $url ) ) {
342 if ( !$bits && strpos( $url,
'/' ) ===
false ) {
348 if ( $bits && isset( $bits[
'host'] )
349 && $bits[
'host'] !== $this->mwConfig->get( MainConfigNames::ServerName )
351 $result = $bits[
'host'];
352 if ( $bits[
'scheme'] !==
'' ) {
353 $result = $bits[
'scheme'] . $bits[
'delimiter'] . $result;
355 if ( isset( $bits[
'port'] ) ) {
356 $result .=
':' . $bits[
'port'];
358 $result = $this->escapeUrlForCSP( $result );
366 private function getAdditionalSelfUrlsScript() {
367 $additionalUrls = [];
369 $pathVars = [ MainConfigNames::LoadScript, MainConfigNames::ExtensionAssetsPath,
370 MainConfigNames::ResourceBasePath ];
372 foreach ( $pathVars as
$path ) {
373 $url = $this->mwConfig->get(
$path );
374 $preparedUrl = $this->prepareUrlForCSP( $url );
375 if ( $preparedUrl ) {
376 $additionalUrls[] = $preparedUrl;
379 $RLSources = $this->mwConfig->get( MainConfigNames::ResourceLoaderSources );
380 foreach ( $RLSources as $wiki => $sources ) {
381 foreach ( $sources as $id => $value ) {
382 $url = $this->prepareUrlForCSP( $value );
384 $additionalUrls[] = $url;
389 return array_unique( $additionalUrls );
398 private function getAdditionalSelfUrls() {
404 $additionalSelfUrls = [];
411 $callback =
static function ( $repo, &$urls ) {
412 $urls[] = $repo->getZoneUrl(
'public' );
413 $urls[] = $repo->getZoneUrl(
'transcoded' );
414 $urls[] = $repo->getZoneUrl(
'thumb' );
415 $urls[] = $repo->getDescriptionStylesheetUrl();
417 $repoGroup = MediaWikiServices::getInstance()->getRepoGroup();
418 $localRepo = $repoGroup->getRepo(
'local' );
419 $callback( $localRepo, $pathUrls );
420 $repoGroup->forEachForeignRepo( $callback, [ &$pathUrls ] );
423 $pathGlobals = [ MainConfigNames::LoadScript, MainConfigNames::ExtensionAssetsPath,
424 MainConfigNames::StylePath, MainConfigNames::ResourceBasePath ];
425 foreach ( $pathGlobals as
$path ) {
426 $pathUrls[] = $this->mwConfig->get(
$path );
428 foreach ( $pathUrls as
$path ) {
429 $preparedUrl = $this->prepareUrlForCSP(
$path );
430 if ( $preparedUrl !==
false ) {
431 $additionalSelfUrls[] = $preparedUrl;
434 $RLSources = $this->mwConfig->get( MainConfigNames::ResourceLoaderSources );
436 foreach ( $RLSources as $wiki => $sources ) {
437 foreach ( $sources as $id => $value ) {
438 $url = $this->prepareUrlForCSP( $value );
440 $additionalSelfUrls[] = $url;
445 return array_unique( $additionalSelfUrls );
460 private function getCORSSources() {
461 $additionalUrls = [];
462 $CORSSources = $this->mwConfig->get( MainConfigNames::CrossSiteAJAXdomains );
463 foreach ( $CORSSources as
$source ) {
464 if ( strpos(
$source,
'?' ) !==
false ) {
468 $url = $this->prepareUrlForCSP(
$source );
470 $additionalUrls[] = $url;
473 return $additionalUrls;
483 private function escapeUrlForCSP( $url ) {
502 return (
bool)preg_match(
'!Firefox/4[0-2]\.!', $ua );
513 $config->
get( MainConfigNames::CSPHeader ),
514 $config->
get( MainConfigNames::CSPReportOnlyHeader )
516 return self::isNonceRequiredArray( $configs );
525 private static function isNonceRequiredArray( array $configs ) {
526 foreach ( $configs as $headerConfig ) {
528 $headerConfig ===
true ||
529 ( is_array( $headerConfig ) &&
530 !isset( $headerConfig[
'useNonces'] ) ) ||
531 ( is_array( $headerConfig ) &&
532 isset( $headerConfig[
'useNonces'] ) &&
533 $headerConfig[
'useNonces'] )
548 if ( !self::isNonceRequired( $this->mwConfig ) ) {
551 if ( $this->nonce ===
null ) {
552 $rand = random_bytes( 15 );
553 $this->nonce = base64_encode( $rand );
570 $this->extraDefaultSrc[] = $this->prepareUrlForCSP(
$source );
582 $this->extraStyleSrc[] = $this->prepareUrlForCSP(
$source );
595 $this->extraScriptSrc[] = $this->prepareUrlForCSP(
$source );