ContentSecurityPolicy.php 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500
  1. <?php
  2. /**
  3. * Handle sending Content-Security-Policy headers
  4. *
  5. * @see https://www.w3.org/TR/CSP2/
  6. *
  7. * Copyright © 2015–2018 Brian Wolff
  8. *
  9. * This program is free software; you can redistribute it and/or modify
  10. * it under the terms of the GNU General Public License as published by
  11. * the Free Software Foundation; either version 2 of the License, or
  12. * (at your option) any later version.
  13. *
  14. * This program is distributed in the hope that it will be useful,
  15. * but WITHOUT ANY WARRANTY; without even the implied warranty of
  16. * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  17. * GNU General Public License for more details.
  18. *
  19. * You should have received a copy of the GNU General Public License along
  20. * with this program; if not, write to the Free Software Foundation, Inc.,
  21. * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
  22. * http://www.gnu.org/copyleft/gpl.html
  23. *
  24. * @since 1.32
  25. * @file
  26. */
  27. class ContentSecurityPolicy {
  28. const REPORT_ONLY_MODE = 1;
  29. const FULL_MODE = 2;
  30. /** @var string The nonce to use for inline scripts (from OutputPage) */
  31. private $nonce;
  32. /** @var Config The site configuration object */
  33. private $mwConfig;
  34. /** @var WebResponse */
  35. private $response;
  36. /**
  37. * @param string $nonce
  38. * @param WebResponse $response
  39. * @param Config $mwConfig
  40. */
  41. public function __construct( $nonce, WebResponse $response, Config $mwConfig ) {
  42. $this->nonce = $nonce;
  43. $this->response = $response;
  44. $this->mwConfig = $mwConfig;
  45. }
  46. /**
  47. * Send a single CSP header based on a given policy config.
  48. *
  49. * @note Most callers will probably want ContentSecurityPolicy::sendHeaders() instead.
  50. * @param array $csp ContentSecurityPolicy configuration
  51. * @param int $reportOnly self::*_MODE constant
  52. */
  53. public function sendCSPHeader( $csp, $reportOnly ) {
  54. $policy = $this->makeCSPDirectives( $csp, $reportOnly );
  55. $headerName = $this->getHeaderName( $reportOnly );
  56. if ( $policy ) {
  57. $this->response->header(
  58. "$headerName: $policy"
  59. );
  60. }
  61. }
  62. /**
  63. * Send CSP headers based on wiki config
  64. *
  65. * Main method that callers are expected to use
  66. * @param IContextSource $context A context object, the associated OutputPage
  67. * object must be the one that the page in question was generated with.
  68. */
  69. public static function sendHeaders( IContextSource $context ) {
  70. $out = $context->getOutput();
  71. $csp = new ContentSecurityPolicy(
  72. $out->getCSPNonce(),
  73. $context->getRequest()->response(),
  74. $context->getConfig()
  75. );
  76. $cspConfig = $context->getConfig()->get( 'CSPHeader' );
  77. $cspConfigReportOnly = $context->getConfig()->get( 'CSPReportOnlyHeader' );
  78. $csp->sendCSPHeader( $cspConfig, self::FULL_MODE );
  79. $csp->sendCSPHeader( $cspConfigReportOnly, self::REPORT_ONLY_MODE );
  80. // This used to insert a <meta> tag here, per advice at
  81. // https://blogs.dropbox.com/tech/2015/09/unsafe-inline-and-nonce-deployment/
  82. // The goal was to prevent nonce from working after the page hit onready,
  83. // This would help in old browsers that didn't support nonces, and
  84. // also assist for varnish-cached pages which repeat nonces.
  85. // However, this is incompatible with how resource loader storage works
  86. // via mw.domEval() so it was removed.
  87. }
  88. /**
  89. * Get the name of the HTTP header to use.
  90. *
  91. * @param int $reportOnly Either self::REPORT_ONLY_MODE or self::FULL_MODE
  92. * @return string Name of http header
  93. * @throws UnexpectedValueException
  94. */
  95. private function getHeaderName( $reportOnly ) {
  96. if ( $reportOnly === self::REPORT_ONLY_MODE ) {
  97. return 'Content-Security-Policy-Report-Only';
  98. }
  99. if ( $reportOnly === self::FULL_MODE ) {
  100. return 'Content-Security-Policy';
  101. }
  102. throw new UnexpectedValueException( $reportOnly );
  103. }
  104. /**
  105. * Determine what CSP policies to set for this page
  106. *
  107. * @param array|bool $policyConfig Policy configuration
  108. * (Either $wgCSPHeader or $wgCSPReportOnlyHeader)
  109. * @param int $mode self::REPORT_ONLY_MODE, self::FULL_MODE
  110. * @return string Policy directives, or empty string for no policy.
  111. */
  112. private function makeCSPDirectives( $policyConfig, $mode ) {
  113. if ( $policyConfig === false ) {
  114. // CSP is disabled
  115. return '';
  116. }
  117. if ( $policyConfig === true ) {
  118. $policyConfig = [];
  119. }
  120. $mwConfig = $this->mwConfig;
  121. $additionalSelfUrls = $this->getAdditionalSelfUrls();
  122. $additionalSelfUrlsScript = $this->getAdditionalSelfUrlsScript();
  123. // If no default-src is sent at all, it
  124. // seems browsers (or at least some), interpret
  125. // that as allow anything, but the spec seems
  126. // to imply that data: and blob: should be
  127. // blocked.
  128. $defaultSrc = [ '*', 'data:', 'blob:' ];
  129. $cssSrc = false;
  130. $imgSrc = false;
  131. $scriptSrc = [ "'unsafe-eval'", "'self'" ];
  132. if ( !isset( $policyConfig['useNonces'] ) || $policyConfig['useNonces'] ) {
  133. $scriptSrc[] = "'nonce-" . $this->nonce . "'";
  134. }
  135. $scriptSrc = array_merge( $scriptSrc, $additionalSelfUrlsScript );
  136. if ( isset( $policyConfig['script-src'] )
  137. && is_array( $policyConfig['script-src'] )
  138. ) {
  139. foreach ( $policyConfig['script-src'] as $src ) {
  140. $scriptSrc[] = $this->escapeUrlForCSP( $src );
  141. }
  142. }
  143. // Note: default on if unspecified.
  144. if ( !isset( $policyConfig['unsafeFallback'] )
  145. || $policyConfig['unsafeFallback']
  146. ) {
  147. // unsafe-inline should be ignored on browsers
  148. // that support 'nonce-foo' sources.
  149. // Some older versions of firefox don't follow this
  150. // rule, but new browsers do. (Should be for at least
  151. // firefox 40+).
  152. $scriptSrc[] = "'unsafe-inline'";
  153. }
  154. // If default source option set to true or
  155. // an array of urls, set a restrictive default-src.
  156. // If set to false, we send a lenient default-src,
  157. // see the code above where $defaultSrc is set initially.
  158. if ( isset( $policyConfig['default-src'] )
  159. && $policyConfig['default-src'] !== false
  160. ) {
  161. $defaultSrc = array_merge(
  162. [ "'self'", 'data:', 'blob:' ],
  163. $additionalSelfUrls
  164. );
  165. if ( is_array( $policyConfig['default-src'] ) ) {
  166. foreach ( $policyConfig['default-src'] as $src ) {
  167. $defaultSrc[] = $this->escapeUrlForCSP( $src );
  168. }
  169. }
  170. }
  171. if ( !isset( $policyConfig['includeCORS'] ) || $policyConfig['includeCORS'] ) {
  172. $CORSUrls = $this->getCORSSources();
  173. if ( !in_array( '*', $defaultSrc ) ) {
  174. $defaultSrc = array_merge( $defaultSrc, $CORSUrls );
  175. }
  176. // Unlikely to have * in scriptSrc, but doesn't
  177. // hurt to check.
  178. if ( !in_array( '*', $scriptSrc ) ) {
  179. $scriptSrc = array_merge( $scriptSrc, $CORSUrls );
  180. }
  181. }
  182. Hooks::run( 'ContentSecurityPolicyDefaultSource', [ &$defaultSrc, $policyConfig, $mode ] );
  183. Hooks::run( 'ContentSecurityPolicyScriptSource', [ &$scriptSrc, $policyConfig, $mode ] );
  184. // Check if array just in case the hook made it false
  185. if ( is_array( $defaultSrc ) ) {
  186. $cssSrc = array_merge( $defaultSrc, [ "'unsafe-inline'" ] );
  187. }
  188. if ( isset( $policyConfig['report-uri'] ) && $policyConfig['report-uri'] !== true ) {
  189. if ( $policyConfig['report-uri'] === false ) {
  190. $reportUri = false;
  191. } else {
  192. $reportUri = $this->escapeUrlForCSP( $policyConfig['report-uri'] );
  193. }
  194. } else {
  195. $reportUri = $this->getReportUri( $mode );
  196. }
  197. // Only send an img-src, if we're sending a restricitve default.
  198. if ( !is_array( $defaultSrc )
  199. || !in_array( '*', $defaultSrc )
  200. || !in_array( 'data:', $defaultSrc )
  201. || !in_array( 'blob:', $defaultSrc )
  202. ) {
  203. // A future todo might be to make the whitelist options only
  204. // add all the whitelisted sites to the header, instead of
  205. // allowing all (Assuming there is a small number of sites).
  206. // For now, the external image feature disables the limits
  207. // CSP puts on external images.
  208. if ( $mwConfig->get( 'AllowExternalImages' )
  209. || $mwConfig->get( 'AllowExternalImagesFrom' )
  210. || $mwConfig->get( 'AllowImageTag' )
  211. ) {
  212. $imgSrc = [ '*', 'data:', 'blob:' ];
  213. } elseif ( $mwConfig->get( 'EnableImageWhitelist' ) ) {
  214. $whitelist = wfMessage( 'external_image_whitelist' )
  215. ->inContentLanguage()
  216. ->plain();
  217. if ( preg_match( '/^\s*[^\s#]/m', $whitelist ) ) {
  218. $imgSrc = [ '*', 'data:', 'blob:' ];
  219. }
  220. }
  221. }
  222. $directives = [];
  223. if ( $scriptSrc ) {
  224. $directives[] = 'script-src ' . implode( ' ', $scriptSrc );
  225. }
  226. if ( $defaultSrc ) {
  227. $directives[] = 'default-src ' . implode( ' ', $defaultSrc );
  228. }
  229. if ( $cssSrc ) {
  230. $directives[] = 'style-src ' . implode( ' ', $cssSrc );
  231. }
  232. if ( $imgSrc ) {
  233. $directives[] = 'img-src ' . implode( ' ', $imgSrc );
  234. }
  235. if ( $reportUri ) {
  236. $directives[] = 'report-uri ' . $reportUri;
  237. }
  238. Hooks::run( 'ContentSecurityPolicyDirectives', [ &$directives, $policyConfig, $mode ] );
  239. return implode( '; ', $directives );
  240. }
  241. /**
  242. * Get the default report uri.
  243. *
  244. * @param int $mode self::*_MODE constant.
  245. * @return string The URI to send reports to.
  246. * @throws UnexpectedValueException if given invalid mode.
  247. */
  248. private function getReportUri( $mode ) {
  249. $apiArguments = [
  250. 'action' => 'cspreport',
  251. 'format' => 'json'
  252. ];
  253. if ( $mode === self::REPORT_ONLY_MODE ) {
  254. $apiArguments['reportonly'] = '1';
  255. }
  256. $reportUri = wfAppendQuery( wfScript( 'api' ), $apiArguments );
  257. // Per spec, ';' and ',' must be hex-escaped in report uri
  258. // Also add an & at the end of url to work around bug in hhvm
  259. // with handling of POST parameters when always_decode_post_data
  260. // is set to true. See https://github.com/facebook/hhvm/issues/6676
  261. $reportUri = $this->escapeUrlForCSP( $reportUri ) . '&';
  262. return $reportUri;
  263. }
  264. /**
  265. * Given a url, convert to form needed for CSP.
  266. *
  267. * Currently this does either scheme + host, or
  268. * if protocol relative, just the host. Future versions
  269. * could potentially preserve some of the path, if its determined
  270. * that that would be a good idea.
  271. *
  272. * @note This does the extra escaping for CSP, but assumes the url
  273. * has already had normal url escaping applied.
  274. * @note This discards urls same as server name, as 'self' directive
  275. * takes care of that.
  276. * @param string $url
  277. * @return string|bool Converted url or false on failure
  278. */
  279. private function prepareUrlForCSP( $url ) {
  280. $result = false;
  281. if ( preg_match( '/^[a-z][a-z0-9+.-]*:$/i', $url ) ) {
  282. // A schema source (e.g. blob: or data:)
  283. return $url;
  284. }
  285. $bits = wfParseUrl( $url );
  286. if ( !$bits && strpos( $url, '/' ) === false ) {
  287. // probably something like example.com.
  288. // try again protocol-relative.
  289. $url = '//' . $url;
  290. $bits = wfParseUrl( $url );
  291. }
  292. if ( $bits && isset( $bits['host'] )
  293. && $bits['host'] !== $this->mwConfig->get( 'ServerName' )
  294. ) {
  295. $result = $bits['host'];
  296. if ( $bits['scheme'] !== '' ) {
  297. $result = $bits['scheme'] . $bits['delimiter'] . $result;
  298. }
  299. if ( isset( $bits['port'] ) ) {
  300. $result .= ':' . $bits['port'];
  301. }
  302. $result = $this->escapeUrlForCSP( $result );
  303. }
  304. return $result;
  305. }
  306. /**
  307. * Get additional script sources
  308. *
  309. * @return array Additional sources for loading scripts from
  310. */
  311. private function getAdditionalSelfUrlsScript() {
  312. $additionalUrls = [];
  313. // wgExtensionAssetsPath for ?debug=true mode
  314. $pathVars = [ 'LoadScript', 'ExtensionAssetsPath', 'ResourceBasePath' ];
  315. foreach ( $pathVars as $path ) {
  316. $url = $this->mwConfig->get( $path );
  317. $preparedUrl = $this->prepareUrlForCSP( $url );
  318. if ( $preparedUrl ) {
  319. $additionalUrls[] = $preparedUrl;
  320. }
  321. }
  322. $RLSources = $this->mwConfig->get( 'ResourceLoaderSources' );
  323. foreach ( $RLSources as $wiki => $sources ) {
  324. foreach ( $sources as $id => $value ) {
  325. $url = $this->prepareUrlForCSP( $value );
  326. if ( $url ) {
  327. $additionalUrls[] = $url;
  328. }
  329. }
  330. }
  331. return array_unique( $additionalUrls );
  332. }
  333. /**
  334. * Get additional host names for the wiki (e.g. if static content loaded elsewhere)
  335. *
  336. * @note These are general load sources, not script sources
  337. * @return array Array of other urls for wiki (for use in default-src)
  338. */
  339. private function getAdditionalSelfUrls() {
  340. // XXX on a foreign repo, the included description page can have anything on it,
  341. // including inline scripts. But nobody sane does that.
  342. // In principle, you can have even more complex configs... (e.g. The urlsByExt option)
  343. $pathUrls = [];
  344. $additionalSelfUrls = [];
  345. // Future todo: The zone urls should never go into
  346. // style-src. They should either be only in img-src, or if
  347. // img-src unspecified they should be in default-src. Similarly,
  348. // the DescriptionStylesheetUrl only needs to be in style-src
  349. // (or default-src if style-src unspecified).
  350. $callback = function ( $repo, &$urls ) {
  351. $urls[] = $repo->getZoneUrl( 'public' );
  352. $urls[] = $repo->getZoneUrl( 'transcoded' );
  353. $urls[] = $repo->getZoneUrl( 'thumb' );
  354. $urls[] = $repo->getDescriptionStylesheetUrl();
  355. };
  356. $localRepo = RepoGroup::singleton()->getRepo( 'local' );
  357. $callback( $localRepo, $pathUrls );
  358. RepoGroup::singleton()->forEachForeignRepo( $callback, [ &$pathUrls ] );
  359. // Globals that might point to a different domain
  360. $pathGlobals = [ 'LoadScript', 'ExtensionAssetsPath', 'StylePath', 'ResourceBasePath' ];
  361. foreach ( $pathGlobals as $path ) {
  362. $pathUrls[] = $this->mwConfig->get( $path );
  363. }
  364. foreach ( $pathUrls as $path ) {
  365. $preparedUrl = $this->prepareUrlForCSP( $path );
  366. if ( $preparedUrl !== false ) {
  367. $additionalSelfUrls[] = $preparedUrl;
  368. }
  369. }
  370. $RLSources = $this->mwConfig->get( 'ResourceLoaderSources' );
  371. foreach ( $RLSources as $wiki => $sources ) {
  372. foreach ( $sources as $id => $value ) {
  373. $url = $this->prepareUrlForCSP( $value );
  374. if ( $url ) {
  375. $additionalSelfUrls[] = $url;
  376. }
  377. }
  378. }
  379. return array_unique( $additionalSelfUrls );
  380. }
  381. /**
  382. * include domains that are allowed to send us CORS requests.
  383. *
  384. * Technically, $wgCrossSiteAJAXdomains lists things that are allowed to talk to us
  385. * not things that we are allowed to talk to - but if something is allowed to talk to us,
  386. * then there is a good chance that we should probably be allowed to talk to it.
  387. *
  388. * This is configurable with the 'includeCORS' key in the CSP config, and enabled
  389. * by default.
  390. * @note CORS domains with single character ('?') wildcards, are not included.
  391. * @return array Additional hosts
  392. */
  393. private function getCORSSources() {
  394. $additionalUrls = [];
  395. $CORSSources = $this->mwConfig->get( 'CrossSiteAJAXdomains' );
  396. foreach ( $CORSSources as $source ) {
  397. if ( strpos( $source, '?' ) !== false ) {
  398. // CSP doesn't support single char wildcard
  399. continue;
  400. }
  401. $url = $this->prepareUrlForCSP( $source );
  402. if ( $url ) {
  403. $additionalUrls[] = $url;
  404. }
  405. }
  406. return $additionalUrls;
  407. }
  408. /**
  409. * CSP spec says ',' and ';' are not allowed to appear in urls.
  410. *
  411. * @note This assumes that normal escaping has been applied to the url
  412. * @param string $url URL (or possibly just part of one)
  413. * @return string
  414. */
  415. private function escapeUrlForCSP( $url ) {
  416. return str_replace(
  417. [ ';', ',' ],
  418. [ '%3B', '%2C' ],
  419. $url
  420. );
  421. }
  422. /**
  423. * Does this browser give false positive reports?
  424. *
  425. * Some versions of firefox (40-42) incorrectly report a csp
  426. * violation for nonce sources, despite allowing them.
  427. *
  428. * @see https://bugzilla.mozilla.org/show_bug.cgi?id=1026520
  429. * @param string $ua User-agent header
  430. * @return bool
  431. */
  432. public static function falsePositiveBrowser( $ua ) {
  433. return (bool)preg_match( '!Firefox/4[0-2]\.!', $ua );
  434. }
  435. /**
  436. * Should we set nonce attribute
  437. *
  438. * @param Config $config Configuration object
  439. * @return bool
  440. */
  441. public static function isNonceRequired( Config $config ) {
  442. $configs = [
  443. $config->get( 'CSPHeader' ),
  444. $config->get( 'CSPReportOnlyHeader' )
  445. ];
  446. foreach ( $configs as $headerConfig ) {
  447. if (
  448. $headerConfig === true ||
  449. ( is_array( $headerConfig ) &&
  450. !isset( $headerConfig['useNonces'] ) ) ||
  451. ( is_array( $headerConfig ) &&
  452. isset( $headerConfig['useNonces'] ) &&
  453. $headerConfig['useNonces'] )
  454. ) {
  455. return true;
  456. }
  457. }
  458. return false;
  459. }
  460. }