28 private const UPLOAD_CSP =
"default-src 'none'; style-src 'unsafe-inline' data:;" .
29 "font-src data:; img-src data: 'self'; media-src data: 'self'; sandbox";
30 private const UPLOAD_CSP_PDF =
"default-src 'none'; style-src 'unsafe-inline' data:; object-src 'self';" .
31 "font-src data:; img-src data: 'self'; media-src data: 'self';";
34 private const REPORTING_ENDPOINTS_HEADER =
"Reporting-Endpoints";
37 private const REPORT_TO_NAME =
"csp-report-to-endpoint";
38 private const REPORT_TO_REPORT_ONLY_NAME =
"csp-report-to-report-only-endpoint";
46 private $extraDefaultSrc = [];
48 private $extraScriptSrc = [];
50 private $extraStyleSrc = [];
69 $this->response = $response;
70 $this->mwConfig = $mwConfig;
71 $this->hookRunner =
new HookRunner( $hookContainer );
86 $cspPolicy = $this->makeCSPDirectives( $cspConfig, self::FULL_MODE );
87 $cspReportOnlyPolicy = $this->makeCSPDirectives( $cspConfigReportOnly, self::REPORT_ONLY_MODE );
89 return array_filter( [
90 $this->getHeaderName( self::FULL_MODE ) => $cspPolicy,
91 $this->getHeaderName( self::REPORT_ONLY_MODE ) => $cspReportOnlyPolicy,
107 $reportingHeader = $this->getReportingEndpointsHeader();
108 if ( $reportingHeader !=
'' ) {
109 $this->response->header( $reportingHeader );
114 foreach ( $directives as $headerName => $policy ) {
115 $this->response->header(
"$headerName: $policy" );
132 private function getHeaderName( $reportOnly ) {
133 if ( $reportOnly === self::REPORT_ONLY_MODE ) {
134 return 'Content-Security-Policy-Report-Only';
137 if ( $reportOnly === self::FULL_MODE ) {
138 return 'Content-Security-Policy';
141 throw new UnexpectedValueException(
"Mode '$reportOnly' not recognised" );
147 private function getReportingEndpointsHeader() {
151 if ( !$cspConfig && !$cspConfigReportOnly ) {
155 $header = self::REPORTING_ENDPOINTS_HEADER .
": ";
157 $header .= self::REPORT_TO_NAME .
"='" . $this->getReportToURI(
false ) .
"'; ";
159 if ( $cspConfigReportOnly ) {
160 $header .= self::REPORT_TO_REPORT_ONLY_NAME .
"='" . $this->getReportToURI(
true ) .
"'; ";
174 private function makeCSPDirectives( $policyConfig, $mode ) {
175 if ( $policyConfig ===
false ) {
179 if ( $policyConfig ===
true ) {
183 $mwConfig = $this->mwConfig;
186 self::isNonceRequired( $mwConfig ) ||
187 self::isNonceRequiredArray( [ $policyConfig ] )
189 wfDeprecated(
'wgCSPHeader "useNonces" option',
'1.41' );
192 $additionalSelfUrls = $this->getAdditionalSelfUrls();
193 $additionalSelfUrlsScript = $this->getAdditionalSelfUrlsScript();
198 $defaultSrc = [
'*',
'data:',
'blob:' ];
201 $scriptSrc = [
"'unsafe-eval'",
"blob:",
"'self'" ];
203 $scriptSrc = array_merge( $scriptSrc, $additionalSelfUrlsScript );
204 if ( isset( $policyConfig[
'script-src'] )
205 && is_array( $policyConfig[
'script-src'] )
207 foreach ( $policyConfig[
'script-src'] as $src ) {
208 $scriptSrc[] = $this->escapeUrlForCSP( $src );
212 if ( $policyConfig[
'unsafeFallback'] ??
true ) {
216 $scriptSrc[] =
"'unsafe-inline'";
222 if ( isset( $policyConfig[
'default-src'] )
223 && $policyConfig[
'default-src'] !==
false
225 $defaultSrc = [
"'self'",
'data:',
'blob:', ...$additionalSelfUrls ];
226 if ( is_array( $policyConfig[
'default-src'] ) ) {
227 foreach ( $policyConfig[
'default-src'] as $src ) {
228 $defaultSrc[] = $this->escapeUrlForCSP( $src );
233 if ( $policyConfig[
'includeCORS'] ??
true ) {
234 $CORSUrls = $this->getCORSSources();
235 if ( !in_array(
'*', $defaultSrc ) ) {
236 $defaultSrc = array_merge( $defaultSrc, $CORSUrls );
240 if ( !in_array(
'*', $scriptSrc ) ) {
241 $scriptSrc = array_merge( $scriptSrc, $CORSUrls );
245 $defaultSrc = array_merge( $defaultSrc, $this->extraDefaultSrc );
246 $scriptSrc = array_merge( $scriptSrc, $this->extraScriptSrc );
248 $cssSrc = array_merge( $defaultSrc, $this->extraStyleSrc, [
"'unsafe-inline'" ] );
250 $this->hookRunner->onContentSecurityPolicyDefaultSource( $defaultSrc, $policyConfig, $mode );
251 $this->hookRunner->onContentSecurityPolicyScriptSource( $scriptSrc, $policyConfig, $mode );
256 if ( isset( $policyConfig[
'report-uri'] ) && $policyConfig[
'report-uri'] !==
true ) {
257 if ( $policyConfig[
'report-uri'] !==
false ) {
258 $reportUri = $this->escapeUrlForCSP( $policyConfig[
'report-uri'] );
261 $reportUri = $this->getReportUri( $mode );
265 if ( isset( $policyConfig[
'report-to'] ) && $policyConfig[
'report-to'] !==
true ) {
266 if ( $policyConfig[
'report-to'] ===
false ) {
267 $reportToName =
false;
269 $reportToName = $policyConfig[
'report-to'];
272 if ( $mode == self::REPORT_ONLY_MODE ) {
273 $reportToName = self::REPORT_TO_REPORT_ONLY_NAME;
275 $reportToName = self::REPORT_TO_NAME;
280 if ( !is_array( $defaultSrc )
281 || !in_array(
'*', $defaultSrc )
282 || !in_array(
'data:', $defaultSrc )
283 || !in_array(
'blob:', $defaultSrc )
293 $imgSrc = [
'*',
'data:',
'blob:' ];
295 $whitelist =
wfMessage(
'external_image_whitelist' )
296 ->inContentLanguage()
298 if ( preg_match(
'/^\s*[^\s#]/m', $whitelist ) ) {
299 $imgSrc = [
'*',
'data:',
'blob:' ];
305 if ( !isset( $policyConfig[
'object-src'] ) || $policyConfig[
'object-src'] ===
true ) {
306 $objectSrc = [
"'none'" ];
308 $objectSrc = (array)( $policyConfig[
'object-src'] ?: [] );
310 $objectSrc = array_map( $this->escapeUrlForCSP( ... ), $objectSrc );
314 $directives[] =
'script-src ' . implode(
' ', array_unique( $scriptSrc ) );
317 $directives[] =
'default-src ' . implode(
' ', array_unique( $defaultSrc ) );
320 $directives[] =
'style-src ' . implode(
' ', array_unique( $cssSrc ) );
323 $directives[] =
'img-src ' . implode(
' ', array_unique( $imgSrc ) );
326 $directives[] =
'object-src ' . implode(
' ', $objectSrc );
329 $directives[] =
'report-uri ' . $reportUri;
331 if ( $reportToName ) {
332 $directives[] =
'report-to ' . $reportToName;
335 $this->hookRunner->onContentSecurityPolicyDirectives( $directives, $policyConfig, $mode );
337 return implode(
'; ', $directives );
347 private function getReportUri( $mode ) {
349 'action' =>
'cspreport',
352 if ( $mode === self::REPORT_ONLY_MODE ) {
353 $apiArguments[
'reportonly'] =
'1';
358 $reportUri = $this->escapeUrlForCSP( $reportUri );
370 private function getReportToURI( $cspReportOnlyEnabled ) {
372 'action' =>
'cspreport',
376 if ( $cspReportOnlyEnabled ) {
377 $apiArguments[
'reportonly'] =
'1';
382 $reportToURI = $this->escapeUrlForCSP( $reportToURI );
401 private function prepareUrlForCSP(
$url ) {
404 if ( preg_match(
'/^[a-z][a-z0-9+.-]*:$/i',
$url ) ) {
408 $bits = $urlUtils->parse(
$url );
409 if ( !$bits && !str_contains(
$url,
'/' ) ) {
413 $bits = $urlUtils->parse(
$url );
415 if ( $bits && isset( $bits[
'host'] )
418 $result = $bits[
'host'];
419 if ( $bits[
'scheme'] !==
'' ) {
420 $result = $bits[
'scheme'] . $bits[
'delimiter'] . $result;
422 if ( isset( $bits[
'port'] ) ) {
423 $result .=
':' . $bits[
'port'];
425 $result = $this->escapeUrlForCSP( $result );
433 private function getAdditionalSelfUrlsScript() {
434 $additionalUrls = [];
442 foreach ( $pathVars as
$path ) {
444 $preparedUrl = $this->prepareUrlForCSP(
$url );
445 if ( $preparedUrl ) {
446 $additionalUrls[] = $preparedUrl;
450 foreach ( $RLSources as $sources ) {
451 foreach ( $sources as $value ) {
452 $url = $this->prepareUrlForCSP( $value );
454 $additionalUrls[] =
$url;
459 return array_unique( $additionalUrls );
468 private function getAdditionalSelfUrls() {
474 $additionalSelfUrls = [];
481 $callback =
static function ( $repo, &$urls ) {
482 $urls[] = $repo->getZoneUrl(
'public' );
483 $urls[] = $repo->getZoneUrl(
'transcoded' );
484 $urls[] = $repo->getZoneUrl(
'thumb' );
485 $urls[] = $repo->getDescriptionStylesheetUrl();
488 $localRepo = $repoGroup->getRepo(
'local' );
489 $callback( $localRepo, $pathUrls );
490 $repoGroup->forEachForeignRepo( $callback, [ &$pathUrls ] );
499 foreach ( $pathGlobals as
$path ) {
500 $pathUrls[] = $this->mwConfig->get(
$path );
502 foreach ( $pathUrls as
$path ) {
503 $preparedUrl = $this->prepareUrlForCSP(
$path );
504 if ( $preparedUrl !==
false ) {
505 $additionalSelfUrls[] = $preparedUrl;
510 foreach ( $RLSources as $sources ) {
511 foreach ( $sources as $value ) {
512 $url = $this->prepareUrlForCSP( $value );
514 $additionalSelfUrls[] =
$url;
519 return array_unique( $additionalSelfUrls );
534 private function getCORSSources() {
535 $additionalUrls = [];
537 foreach ( $CORSSources as
$source ) {
538 if ( str_contains(
$source,
'?' ) ) {
544 $additionalUrls[] =
$url;
547 return $additionalUrls;
557 private function escapeUrlForCSP(
$url ) {
576 return (
bool)preg_match(
'!Firefox/4[0-2]\.!', $ua );
590 return self::isNonceRequiredArray( $configs );
599 private static function isNonceRequiredArray( array $configs ) {
600 foreach ( $configs as $headerConfig ) {
602 is_array( $headerConfig ) &&
603 isset( $headerConfig[
'useNonces'] ) &&
604 $headerConfig[
'useNonces']
635 $this->extraDefaultSrc[] = $this->prepareUrlForCSP(
$source );
647 $this->extraStyleSrc[] = $this->prepareUrlForCSP(
$source );
660 $this->extraScriptSrc[] = $this->prepareUrlForCSP(
$source );
681 if ( strtolower( substr( $filename, -4 ) ) ===
'.pdf' ) {
682 return self::UPLOAD_CSP_PDF;
684 return self::UPLOAD_CSP;
703 header(
"Content-Security-Policy: default-src 'self'; script-src 'none'; object-src 'none'" );