50 private $extraDefaultSrc = [];
52 private $extraScriptSrc = [];
54 private $extraStyleSrc = [];
73 $this->response = $response;
74 $this->mwConfig = $mwConfig;
75 $this->hookRunner =
new HookRunner( $hookContainer );
87 $policy = $this->makeCSPDirectives( $csp, $reportOnly );
88 $headerName = $this->getHeaderName( $reportOnly );
90 $this->response->header(
91 "$headerName: $policy"
110 $this->
sendCSPHeader( $cspConfigReportOnly, self::REPORT_ONLY_MODE );
126 private function getHeaderName( $reportOnly ) {
127 if ( $reportOnly === self::REPORT_ONLY_MODE ) {
128 return 'Content-Security-Policy-Report-Only';
131 if ( $reportOnly === self::FULL_MODE ) {
132 return 'Content-Security-Policy';
134 throw new UnexpectedValueException(
"Mode '$reportOnly' not recognised" );
145 private function makeCSPDirectives( $policyConfig, $mode ) {
146 if ( $policyConfig ===
false ) {
150 if ( $policyConfig ===
true ) {
154 $mwConfig = $this->mwConfig;
157 !self::isNonceRequired( $mwConfig ) &&
158 self::isNonceRequiredArray( [ $policyConfig ] )
162 throw new LogicException(
"Nonce requirement mismatch" );
165 $additionalSelfUrls = $this->getAdditionalSelfUrls();
166 $additionalSelfUrlsScript = $this->getAdditionalSelfUrlsScript();
173 $defaultSrc = [
'*',
'data:',
'blob:' ];
176 $scriptSrc = [
"'unsafe-eval'",
"blob:",
"'self'" ];
177 if ( $policyConfig[
'useNonces'] ??
true ) {
178 $scriptSrc[] =
"'nonce-" . $this->
getNonce() .
"'";
181 $scriptSrc = array_merge( $scriptSrc, $additionalSelfUrlsScript );
182 if ( isset( $policyConfig[
'script-src'] )
183 && is_array( $policyConfig[
'script-src'] )
185 foreach ( $policyConfig[
'script-src'] as $src ) {
186 $scriptSrc[] = $this->escapeUrlForCSP( $src );
190 if ( $policyConfig[
'unsafeFallback'] ??
true ) {
196 $scriptSrc[] =
"'unsafe-inline'";
202 if ( isset( $policyConfig[
'default-src'] )
203 && $policyConfig[
'default-src'] !==
false
205 $defaultSrc = array_merge(
206 [
"'self'",
'data:',
'blob:' ],
209 if ( is_array( $policyConfig[
'default-src'] ) ) {
210 foreach ( $policyConfig[
'default-src'] as $src ) {
211 $defaultSrc[] = $this->escapeUrlForCSP( $src );
216 if ( $policyConfig[
'includeCORS'] ??
true ) {
217 $CORSUrls = $this->getCORSSources();
218 if ( !in_array(
'*', $defaultSrc ) ) {
219 $defaultSrc = array_merge( $defaultSrc, $CORSUrls );
223 if ( !in_array(
'*', $scriptSrc ) ) {
224 $scriptSrc = array_merge( $scriptSrc, $CORSUrls );
228 $defaultSrc = array_merge( $defaultSrc, $this->extraDefaultSrc );
229 $scriptSrc = array_merge( $scriptSrc, $this->extraScriptSrc );
231 $cssSrc = array_merge( $defaultSrc, $this->extraStyleSrc, [
"'unsafe-inline'" ] );
233 $this->hookRunner->onContentSecurityPolicyDefaultSource( $defaultSrc, $policyConfig, $mode );
234 $this->hookRunner->onContentSecurityPolicyScriptSource( $scriptSrc, $policyConfig, $mode );
236 if ( isset( $policyConfig[
'report-uri'] ) && $policyConfig[
'report-uri'] !==
true ) {
237 if ( $policyConfig[
'report-uri'] ===
false ) {
240 $reportUri = $this->escapeUrlForCSP( $policyConfig[
'report-uri'] );
243 $reportUri = $this->getReportUri( $mode );
247 if ( !is_array( $defaultSrc )
248 || !in_array(
'*', $defaultSrc )
249 || !in_array(
'data:', $defaultSrc )
250 || !in_array(
'blob:', $defaultSrc )
261 $imgSrc = [
'*',
'data:',
'blob:' ];
263 $whitelist =
wfMessage(
'external_image_whitelist' )
264 ->inContentLanguage()
266 if ( preg_match(
'/^\s*[^\s#]/m', $whitelist ) ) {
267 $imgSrc = [
'*',
'data:',
'blob:' ];
273 if ( !isset( $policyConfig[
'object-src'] ) || $policyConfig[
'object-src'] ===
true ) {
274 $objectSrc = [
"'none'" ];
276 $objectSrc = (array)( $policyConfig[
'object-src'] ?: [] );
278 $objectSrc = array_map( [ $this,
'escapeUrlForCSP' ], $objectSrc );
282 $directives[] =
'script-src ' . implode(
' ', array_unique( $scriptSrc ) );
285 $directives[] =
'default-src ' . implode(
' ', array_unique( $defaultSrc ) );
288 $directives[] =
'style-src ' . implode(
' ', array_unique( $cssSrc ) );
291 $directives[] =
'img-src ' . implode(
' ', array_unique( $imgSrc ) );
294 $directives[] =
'object-src ' . implode(
' ', $objectSrc );
297 $directives[] =
'report-uri ' . $reportUri;
300 $this->hookRunner->onContentSecurityPolicyDirectives( $directives, $policyConfig, $mode );
302 return implode(
'; ', $directives );
312 private function getReportUri( $mode ) {
314 'action' =>
'cspreport',
317 if ( $mode === self::REPORT_ONLY_MODE ) {
318 $apiArguments[
'reportonly'] =
'1';
323 $reportUri = $this->escapeUrlForCSP( $reportUri );
342 private function prepareUrlForCSP( $url ) {
344 if ( preg_match(
'/^[a-z][a-z0-9+.-]*:$/i', $url ) ) {
349 if ( !$bits && strpos( $url,
'/' ) ===
false ) {
355 if ( $bits && isset( $bits[
'host'] )
358 $result = $bits[
'host'];
359 if ( $bits[
'scheme'] !==
'' ) {
360 $result = $bits[
'scheme'] . $bits[
'delimiter'] . $result;
362 if ( isset( $bits[
'port'] ) ) {
363 $result .=
':' . $bits[
'port'];
365 $result = $this->escapeUrlForCSP( $result );
373 private function getAdditionalSelfUrlsScript() {
374 $additionalUrls = [];
382 foreach ( $pathVars as
$path ) {
383 $url = $this->mwConfig->get(
$path );
384 $preparedUrl = $this->prepareUrlForCSP( $url );
385 if ( $preparedUrl ) {
386 $additionalUrls[] = $preparedUrl;
390 foreach ( $RLSources as $sources ) {
391 foreach ( $sources as $value ) {
392 $url = $this->prepareUrlForCSP( $value );
394 $additionalUrls[] = $url;
399 return array_unique( $additionalUrls );
408 private function getAdditionalSelfUrls() {
414 $additionalSelfUrls = [];
421 $callback =
static function ( $repo, &$urls ) {
422 $urls[] = $repo->getZoneUrl(
'public' );
423 $urls[] = $repo->getZoneUrl(
'transcoded' );
424 $urls[] = $repo->getZoneUrl(
'thumb' );
425 $urls[] = $repo->getDescriptionStylesheetUrl();
428 $localRepo = $repoGroup->getRepo(
'local' );
429 $callback( $localRepo, $pathUrls );
430 $repoGroup->forEachForeignRepo( $callback, [ &$pathUrls ] );
439 foreach ( $pathGlobals as
$path ) {
440 $pathUrls[] = $this->mwConfig->get(
$path );
442 foreach ( $pathUrls as
$path ) {
443 $preparedUrl = $this->prepareUrlForCSP(
$path );
444 if ( $preparedUrl !==
false ) {
445 $additionalSelfUrls[] = $preparedUrl;
450 foreach ( $RLSources as $sources ) {
451 foreach ( $sources as $value ) {
452 $url = $this->prepareUrlForCSP( $value );
454 $additionalSelfUrls[] = $url;
459 return array_unique( $additionalSelfUrls );
474 private function getCORSSources() {
475 $additionalUrls = [];
477 foreach ( $CORSSources as
$source ) {
478 if ( strpos(
$source,
'?' ) !==
false ) {
482 $url = $this->prepareUrlForCSP(
$source );
484 $additionalUrls[] = $url;
487 return $additionalUrls;
497 private function escapeUrlForCSP( $url ) {
516 return (
bool)preg_match(
'!Firefox/4[0-2]\.!', $ua );
530 return self::isNonceRequiredArray( $configs );
539 private static function isNonceRequiredArray( array $configs ) {
540 foreach ( $configs as $headerConfig ) {
542 $headerConfig ===
true ||
543 ( is_array( $headerConfig ) &&
544 !isset( $headerConfig[
'useNonces'] ) ) ||
545 ( is_array( $headerConfig ) &&
546 isset( $headerConfig[
'useNonces'] ) &&
547 $headerConfig[
'useNonces'] )
562 if ( !self::isNonceRequired( $this->mwConfig ) ) {
565 $this->nonce ??= base64_encode( random_bytes( 15 ) );
581 $this->extraDefaultSrc[] = $this->prepareUrlForCSP(
$source );
593 $this->extraStyleSrc[] = $this->prepareUrlForCSP(
$source );
606 $this->extraScriptSrc[] = $this->prepareUrlForCSP(
$source );