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';";
39 private $extraDefaultSrc = [];
41 private $extraScriptSrc = [];
43 private $extraStyleSrc = [];
62 $this->response = $response;
63 $this->mwConfig = $mwConfig;
64 $this->hookRunner =
new HookRunner( $hookContainer );
79 $cspPolicy = $this->makeCSPDirectives( $cspConfig, self::FULL_MODE );
80 $cspReportOnlyPolicy = $this->makeCSPDirectives( $cspConfigReportOnly, self::REPORT_ONLY_MODE );
82 return array_filter( [
83 $this->getHeaderName( self::FULL_MODE ) => $cspPolicy,
84 $this->getHeaderName( self::REPORT_ONLY_MODE ) => $cspReportOnlyPolicy,
99 foreach ( $directives as $headerName => $policy ) {
100 $this->response->header(
"$headerName: $policy" );
117 private function getHeaderName( $reportOnly ) {
118 if ( $reportOnly === self::REPORT_ONLY_MODE ) {
119 return 'Content-Security-Policy-Report-Only';
122 if ( $reportOnly === self::FULL_MODE ) {
123 return 'Content-Security-Policy';
125 throw new UnexpectedValueException(
"Mode '$reportOnly' not recognised" );
136 private function makeCSPDirectives( $policyConfig, $mode ) {
137 if ( $policyConfig ===
false ) {
141 if ( $policyConfig ===
true ) {
145 $mwConfig = $this->mwConfig;
148 self::isNonceRequired( $mwConfig ) ||
149 self::isNonceRequiredArray( [ $policyConfig ] )
151 wfDeprecated(
'wgCSPHeader "useNonces" option',
'1.41' );
154 $additionalSelfUrls = $this->getAdditionalSelfUrls();
155 $additionalSelfUrlsScript = $this->getAdditionalSelfUrlsScript();
160 $defaultSrc = [
'*',
'data:',
'blob:' ];
163 $scriptSrc = [
"'unsafe-eval'",
"blob:",
"'self'" ];
165 $scriptSrc = array_merge( $scriptSrc, $additionalSelfUrlsScript );
166 if ( isset( $policyConfig[
'script-src'] )
167 && is_array( $policyConfig[
'script-src'] )
169 foreach ( $policyConfig[
'script-src'] as $src ) {
170 $scriptSrc[] = $this->escapeUrlForCSP( $src );
174 if ( $policyConfig[
'unsafeFallback'] ??
true ) {
178 $scriptSrc[] =
"'unsafe-inline'";
184 if ( isset( $policyConfig[
'default-src'] )
185 && $policyConfig[
'default-src'] !==
false
187 $defaultSrc = [
"'self'",
'data:',
'blob:', ...$additionalSelfUrls ];
188 if ( is_array( $policyConfig[
'default-src'] ) ) {
189 foreach ( $policyConfig[
'default-src'] as $src ) {
190 $defaultSrc[] = $this->escapeUrlForCSP( $src );
195 if ( $policyConfig[
'includeCORS'] ??
true ) {
196 $CORSUrls = $this->getCORSSources();
197 if ( !in_array(
'*', $defaultSrc ) ) {
198 $defaultSrc = array_merge( $defaultSrc, $CORSUrls );
202 if ( !in_array(
'*', $scriptSrc ) ) {
203 $scriptSrc = array_merge( $scriptSrc, $CORSUrls );
207 $defaultSrc = array_merge( $defaultSrc, $this->extraDefaultSrc );
208 $scriptSrc = array_merge( $scriptSrc, $this->extraScriptSrc );
210 $cssSrc = array_merge( $defaultSrc, $this->extraStyleSrc, [
"'unsafe-inline'" ] );
212 $this->hookRunner->onContentSecurityPolicyDefaultSource( $defaultSrc, $policyConfig, $mode );
213 $this->hookRunner->onContentSecurityPolicyScriptSource( $scriptSrc, $policyConfig, $mode );
215 if ( isset( $policyConfig[
'report-uri'] ) && $policyConfig[
'report-uri'] !==
true ) {
216 if ( $policyConfig[
'report-uri'] ===
false ) {
219 $reportUri = $this->escapeUrlForCSP( $policyConfig[
'report-uri'] );
222 $reportUri = $this->getReportUri( $mode );
226 if ( !is_array( $defaultSrc )
227 || !in_array(
'*', $defaultSrc )
228 || !in_array(
'data:', $defaultSrc )
229 || !in_array(
'blob:', $defaultSrc )
239 $imgSrc = [
'*',
'data:',
'blob:' ];
241 $whitelist =
wfMessage(
'external_image_whitelist' )
242 ->inContentLanguage()
244 if ( preg_match(
'/^\s*[^\s#]/m', $whitelist ) ) {
245 $imgSrc = [
'*',
'data:',
'blob:' ];
251 if ( !isset( $policyConfig[
'object-src'] ) || $policyConfig[
'object-src'] ===
true ) {
252 $objectSrc = [
"'none'" ];
254 $objectSrc = (array)( $policyConfig[
'object-src'] ?: [] );
256 $objectSrc = array_map( $this->escapeUrlForCSP( ... ), $objectSrc );
260 $directives[] =
'script-src ' . implode(
' ', array_unique( $scriptSrc ) );
263 $directives[] =
'default-src ' . implode(
' ', array_unique( $defaultSrc ) );
266 $directives[] =
'style-src ' . implode(
' ', array_unique( $cssSrc ) );
269 $directives[] =
'img-src ' . implode(
' ', array_unique( $imgSrc ) );
272 $directives[] =
'object-src ' . implode(
' ', $objectSrc );
275 $directives[] =
'report-uri ' . $reportUri;
278 $this->hookRunner->onContentSecurityPolicyDirectives( $directives, $policyConfig, $mode );
280 return implode(
'; ', $directives );
290 private function getReportUri( $mode ) {
292 'action' =>
'cspreport',
295 if ( $mode === self::REPORT_ONLY_MODE ) {
296 $apiArguments[
'reportonly'] =
'1';
301 $reportUri = $this->escapeUrlForCSP( $reportUri );
320 private function prepareUrlForCSP(
$url ) {
322 if ( preg_match(
'/^[a-z][a-z0-9+.-]*:$/i',
$url ) ) {
327 if ( !$bits && !str_contains(
$url,
'/' ) ) {
333 if ( $bits && isset( $bits[
'host'] )
336 $result = $bits[
'host'];
337 if ( $bits[
'scheme'] !==
'' ) {
338 $result = $bits[
'scheme'] . $bits[
'delimiter'] . $result;
340 if ( isset( $bits[
'port'] ) ) {
341 $result .=
':' . $bits[
'port'];
343 $result = $this->escapeUrlForCSP( $result );
351 private function getAdditionalSelfUrlsScript() {
352 $additionalUrls = [];
360 foreach ( $pathVars as
$path ) {
362 $preparedUrl = $this->prepareUrlForCSP(
$url );
363 if ( $preparedUrl ) {
364 $additionalUrls[] = $preparedUrl;
368 foreach ( $RLSources as $sources ) {
369 foreach ( $sources as $value ) {
370 $url = $this->prepareUrlForCSP( $value );
372 $additionalUrls[] =
$url;
377 return array_unique( $additionalUrls );
386 private function getAdditionalSelfUrls() {
392 $additionalSelfUrls = [];
399 $callback =
static function ( $repo, &$urls ) {
400 $urls[] = $repo->getZoneUrl(
'public' );
401 $urls[] = $repo->getZoneUrl(
'transcoded' );
402 $urls[] = $repo->getZoneUrl(
'thumb' );
403 $urls[] = $repo->getDescriptionStylesheetUrl();
406 $localRepo = $repoGroup->getRepo(
'local' );
407 $callback( $localRepo, $pathUrls );
408 $repoGroup->forEachForeignRepo( $callback, [ &$pathUrls ] );
417 foreach ( $pathGlobals as
$path ) {
418 $pathUrls[] = $this->mwConfig->get(
$path );
420 foreach ( $pathUrls as
$path ) {
421 $preparedUrl = $this->prepareUrlForCSP(
$path );
422 if ( $preparedUrl !==
false ) {
423 $additionalSelfUrls[] = $preparedUrl;
428 foreach ( $RLSources as $sources ) {
429 foreach ( $sources as $value ) {
430 $url = $this->prepareUrlForCSP( $value );
432 $additionalSelfUrls[] =
$url;
437 return array_unique( $additionalSelfUrls );
452 private function getCORSSources() {
453 $additionalUrls = [];
455 foreach ( $CORSSources as
$source ) {
456 if ( str_contains(
$source,
'?' ) ) {
462 $additionalUrls[] =
$url;
465 return $additionalUrls;
475 private function escapeUrlForCSP(
$url ) {
494 return (
bool)preg_match(
'!Firefox/4[0-2]\.!', $ua );
508 return self::isNonceRequiredArray( $configs );
517 private static function isNonceRequiredArray( array $configs ) {
518 foreach ( $configs as $headerConfig ) {
520 is_array( $headerConfig ) &&
521 isset( $headerConfig[
'useNonces'] ) &&
522 $headerConfig[
'useNonces']
553 $this->extraDefaultSrc[] = $this->prepareUrlForCSP(
$source );
565 $this->extraStyleSrc[] = $this->prepareUrlForCSP(
$source );
578 $this->extraScriptSrc[] = $this->prepareUrlForCSP(
$source );
599 if ( strtolower( substr( $filename, -4 ) ) ===
'.pdf' ) {
600 return self::UPLOAD_CSP_PDF;
602 return self::UPLOAD_CSP;
621 header(
"Content-Security-Policy: default-src 'self'; script-src 'none'; object-src 'none'" );