47 private $extraDefaultSrc = [];
49 private $extraScriptSrc = [];
51 private $extraStyleSrc = [];
70 $this->response = $response;
71 $this->mwConfig = $mwConfig;
72 $this->hookRunner =
new HookRunner( $hookContainer );
87 $cspPolicy = $this->makeCSPDirectives( $cspConfig, self::FULL_MODE );
88 $cspReportOnlyPolicy = $this->makeCSPDirectives( $cspConfigReportOnly, self::REPORT_ONLY_MODE );
90 return array_filter( [
91 $this->getHeaderName( self::FULL_MODE ) => $cspPolicy,
92 $this->getHeaderName( self::REPORT_ONLY_MODE ) => $cspReportOnlyPolicy,
107 foreach ( $directives as $headerName => $policy ) {
108 $this->response->header(
"$headerName: $policy" );
125 private function getHeaderName( $reportOnly ) {
126 if ( $reportOnly === self::REPORT_ONLY_MODE ) {
127 return 'Content-Security-Policy-Report-Only';
130 if ( $reportOnly === self::FULL_MODE ) {
131 return 'Content-Security-Policy';
133 throw new UnexpectedValueException(
"Mode '$reportOnly' not recognised" );
144 private function makeCSPDirectives( $policyConfig, $mode ) {
145 if ( $policyConfig ===
false ) {
149 if ( $policyConfig ===
true ) {
153 $mwConfig = $this->mwConfig;
156 self::isNonceRequired( $mwConfig ) ||
157 self::isNonceRequiredArray( [ $policyConfig ] )
159 wfDeprecated(
'wgCSPHeader "useNonces" option',
'1.41' );
162 $additionalSelfUrls = $this->getAdditionalSelfUrls();
163 $additionalSelfUrlsScript = $this->getAdditionalSelfUrlsScript();
168 $defaultSrc = [
'*',
'data:',
'blob:' ];
171 $scriptSrc = [
"'unsafe-eval'",
"blob:",
"'self'" ];
173 $scriptSrc = array_merge( $scriptSrc, $additionalSelfUrlsScript );
174 if ( isset( $policyConfig[
'script-src'] )
175 && is_array( $policyConfig[
'script-src'] )
177 foreach ( $policyConfig[
'script-src'] as $src ) {
178 $scriptSrc[] = $this->escapeUrlForCSP( $src );
182 if ( $policyConfig[
'unsafeFallback'] ??
true ) {
186 $scriptSrc[] =
"'unsafe-inline'";
192 if ( isset( $policyConfig[
'default-src'] )
193 && $policyConfig[
'default-src'] !==
false
195 $defaultSrc = array_merge(
196 [
"'self'",
'data:',
'blob:' ],
199 if ( is_array( $policyConfig[
'default-src'] ) ) {
200 foreach ( $policyConfig[
'default-src'] as $src ) {
201 $defaultSrc[] = $this->escapeUrlForCSP( $src );
206 if ( $policyConfig[
'includeCORS'] ??
true ) {
207 $CORSUrls = $this->getCORSSources();
208 if ( !in_array(
'*', $defaultSrc ) ) {
209 $defaultSrc = array_merge( $defaultSrc, $CORSUrls );
213 if ( !in_array(
'*', $scriptSrc ) ) {
214 $scriptSrc = array_merge( $scriptSrc, $CORSUrls );
218 $defaultSrc = array_merge( $defaultSrc, $this->extraDefaultSrc );
219 $scriptSrc = array_merge( $scriptSrc, $this->extraScriptSrc );
221 $cssSrc = array_merge( $defaultSrc, $this->extraStyleSrc, [
"'unsafe-inline'" ] );
223 $this->hookRunner->onContentSecurityPolicyDefaultSource( $defaultSrc, $policyConfig, $mode );
224 $this->hookRunner->onContentSecurityPolicyScriptSource( $scriptSrc, $policyConfig, $mode );
226 if ( isset( $policyConfig[
'report-uri'] ) && $policyConfig[
'report-uri'] !==
true ) {
227 if ( $policyConfig[
'report-uri'] ===
false ) {
230 $reportUri = $this->escapeUrlForCSP( $policyConfig[
'report-uri'] );
233 $reportUri = $this->getReportUri( $mode );
237 if ( !is_array( $defaultSrc )
238 || !in_array(
'*', $defaultSrc )
239 || !in_array(
'data:', $defaultSrc )
240 || !in_array(
'blob:', $defaultSrc )
250 $imgSrc = [
'*',
'data:',
'blob:' ];
252 $whitelist =
wfMessage(
'external_image_whitelist' )
253 ->inContentLanguage()
255 if ( preg_match(
'/^\s*[^\s#]/m', $whitelist ) ) {
256 $imgSrc = [
'*',
'data:',
'blob:' ];
262 if ( !isset( $policyConfig[
'object-src'] ) || $policyConfig[
'object-src'] ===
true ) {
263 $objectSrc = [
"'none'" ];
265 $objectSrc = (array)( $policyConfig[
'object-src'] ?: [] );
267 $objectSrc = array_map( [ $this,
'escapeUrlForCSP' ], $objectSrc );
271 $directives[] =
'script-src ' . implode(
' ', array_unique( $scriptSrc ) );
274 $directives[] =
'default-src ' . implode(
' ', array_unique( $defaultSrc ) );
277 $directives[] =
'style-src ' . implode(
' ', array_unique( $cssSrc ) );
280 $directives[] =
'img-src ' . implode(
' ', array_unique( $imgSrc ) );
283 $directives[] =
'object-src ' . implode(
' ', $objectSrc );
286 $directives[] =
'report-uri ' . $reportUri;
289 $this->hookRunner->onContentSecurityPolicyDirectives( $directives, $policyConfig, $mode );
291 return implode(
'; ', $directives );
301 private function getReportUri( $mode ) {
303 'action' =>
'cspreport',
306 if ( $mode === self::REPORT_ONLY_MODE ) {
307 $apiArguments[
'reportonly'] =
'1';
312 $reportUri = $this->escapeUrlForCSP( $reportUri );
331 private function prepareUrlForCSP(
$url ) {
333 if ( preg_match(
'/^[a-z][a-z0-9+.-]*:$/i',
$url ) ) {
338 if ( !$bits && strpos(
$url,
'/' ) ===
false ) {
344 if ( $bits && isset( $bits[
'host'] )
347 $result = $bits[
'host'];
348 if ( $bits[
'scheme'] !==
'' ) {
349 $result = $bits[
'scheme'] . $bits[
'delimiter'] . $result;
351 if ( isset( $bits[
'port'] ) ) {
352 $result .=
':' . $bits[
'port'];
354 $result = $this->escapeUrlForCSP( $result );
362 private function getAdditionalSelfUrlsScript() {
363 $additionalUrls = [];
371 foreach ( $pathVars as
$path ) {
373 $preparedUrl = $this->prepareUrlForCSP(
$url );
374 if ( $preparedUrl ) {
375 $additionalUrls[] = $preparedUrl;
379 foreach ( $RLSources as $sources ) {
380 foreach ( $sources as $value ) {
381 $url = $this->prepareUrlForCSP( $value );
383 $additionalUrls[] =
$url;
388 return array_unique( $additionalUrls );
397 private function getAdditionalSelfUrls() {
403 $additionalSelfUrls = [];
410 $callback =
static function ( $repo, &$urls ) {
411 $urls[] = $repo->getZoneUrl(
'public' );
412 $urls[] = $repo->getZoneUrl(
'transcoded' );
413 $urls[] = $repo->getZoneUrl(
'thumb' );
414 $urls[] = $repo->getDescriptionStylesheetUrl();
417 $localRepo = $repoGroup->getRepo(
'local' );
418 $callback( $localRepo, $pathUrls );
419 $repoGroup->forEachForeignRepo( $callback, [ &$pathUrls ] );
428 foreach ( $pathGlobals as
$path ) {
429 $pathUrls[] = $this->mwConfig->get(
$path );
431 foreach ( $pathUrls as
$path ) {
432 $preparedUrl = $this->prepareUrlForCSP(
$path );
433 if ( $preparedUrl !==
false ) {
434 $additionalSelfUrls[] = $preparedUrl;
439 foreach ( $RLSources as $sources ) {
440 foreach ( $sources as $value ) {
441 $url = $this->prepareUrlForCSP( $value );
443 $additionalSelfUrls[] =
$url;
448 return array_unique( $additionalSelfUrls );
463 private function getCORSSources() {
464 $additionalUrls = [];
466 foreach ( $CORSSources as
$source ) {
467 if ( strpos(
$source,
'?' ) !==
false ) {
473 $additionalUrls[] =
$url;
476 return $additionalUrls;
486 private function escapeUrlForCSP(
$url ) {
505 return (
bool)preg_match(
'!Firefox/4[0-2]\.!', $ua );
519 return self::isNonceRequiredArray( $configs );
528 private static function isNonceRequiredArray( array $configs ) {
529 foreach ( $configs as $headerConfig ) {
531 is_array( $headerConfig ) &&
532 isset( $headerConfig[
'useNonces'] ) &&
533 $headerConfig[
'useNonces']
564 $this->extraDefaultSrc[] = $this->prepareUrlForCSP(
$source );
576 $this->extraStyleSrc[] = $this->prepareUrlForCSP(
$source );
589 $this->extraScriptSrc[] = $this->prepareUrlForCSP(
$source );