[wp-trac] [WordPress Trac] #39941: Allow using Content-Security-Policy without unsafe-inline

WordPress Trac noreply at wordpress.org
Mon Apr 15 18:09:23 UTC 2024


#39941: Allow using Content-Security-Policy without unsafe-inline
-------------------------------------------------+-------------------------
 Reporter:  tomdxw                               |       Owner:
                                                 |  adamsilverstein
     Type:  enhancement                          |      Status:  closed
 Priority:  normal                               |   Milestone:  5.7
Component:  Security                             |     Version:  4.8
 Severity:  normal                               |  Resolution:  fixed
 Keywords:  has-patch has-unit-tests commit      |     Focuses:  javascript
  has-dev-note                                   |
-------------------------------------------------+-------------------------

Comment (by amanandhishoe):

 So for those who happen to read through this dialog, I have managed to
 implement a CSP on my frontend using a mu-plugin I wrote which calculates
 hashes for all inline scripts and creates a CSP with those hashes.

 I used Pascal CESCATO's CSP-ANTS&ST plugin as a starting point to create
 the plugin. He added nonces to all inline scripts. I had my mu-plugin
 doing the same, but when I discovered how easy it was to calculate hashes
 for all inline scripts, I switched to that because with nonces I needed to
 modify the html and place the nonces inside the inline scripts. With
 hashes, I don't have to touch the final html. Browsers will calculate
 hashes on their end for inline scripts and see if those hashes are
 included in the CSP. They won't run any scripts which don't generate one
 of the hashes in my CSP.

 This is the code that creates hashes for all the inline scripts:
 {{{
         $hashes_a = array(); // array of hashes for inline scripts.

         $page_html = preg_replace_callback(
                 '#<script.*?>(.*?)<\/script>#s',
                 function ( $matches ) use ( &$hashes_a ) {
                         $script_content = $matches[1]; // Extract the
 content between the script tags.
                         // phpcs:ignore
                         $hash       = base64_encode( hash( 'sha256',
 $script_content, true ) ); // Compute the SHA-256 hash and encode it in
 Base64.
                         $hashes_a[] = "'sha256-" . $hash . "'"; // Add the
 hash to the list with the 'sha256-' prefix.
                         return $matches[0]; // Return the original script
 tag unmodified.
                 },
                 $page_html
         );

         // Now $hashes_a contains the hashes for the inline scripts, and
 can be included in the CSP header.

         // Reduce the array so that each hash only appears once.
         $hashes_a = array_unique( $hashes_a );

         // Make a string with a list from hashes.
         $header     = '';
         $hashes_csp = array_reduce(
                 $hashes_a,
                 function ( $header, $hash ) {
                         return "{$header} {$hash}"; // Note the space
 between the quotes to separate the hashes.
                 },
                 ''
         );
 }}}
 Once I have that, I make my CSP:
 {{{
         header( sprintf( "Content-Security-Policy: base-uri 'self' %1s
 ;object-src 'none';script-src 'self' %2s %3s %4s;style-src 'self' %5s
 'unsafe-inline';", $uris, $uris, $extra_script_rules, $hashes_csp, $uris )
 );

 }}}
 In $uris I have a list of allowed sources for scripts.
 In $extra_script_rules I have a list of extra script rules if I need any.

 So my CSP looks like this and you can see the list of hashes in the
 script-src policy:

 base-uri 'self' mycdn.rocketcdn.me challenges.cloudflare.com
 checkout.clover.com ;object-src 'none';script-src 'self'
 mycdn.rocketcdn.me challenges.cloudflare.com checkout.clover.com data:
 'sha256-77WmSGVq6PlE+/dOVkQSZGQWCrUBl6KIyLWH507dV1o='
 'sha256-M1etSTsLTYyio9eWYa4749eeV1vBu8wcRrvhuWcZKC4='
 'sha256-7y9/KNsyJQGWriyCQmEaf3FZwqU52r1AuCBxscB1YcY='
 'sha256-hmgfyXY6AzlJeRWqm+bMvB3XPWFuPrZX1muhIi+gtSc='
 'sha256-MTQk+ZugiSyMOmP4Z9xCUFM5CCHjf5UIyrv7RIN82oE='
 'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU='
 'sha256-5cm3ZpD+ehJUySErDDMlNpvcRe51l8wF7vpRdvQIU/Y='
 'sha256-JifyozaSM89dFYWoPUZeY3Sz3S/t5J/0HY1rFsIxq68='
 'sha256-1X+hmeDCSiqR5517YUrA2tiViPVS+yZYRp3WkC1g3vk='
 'sha256-A8P10y0WSvNDEFERE8AOaLoSXb/km+njslYccDvOCok='
 'sha256-eHL/Izx7K/qWL0kdBXXnHwsLSHvGOJn/THLHydUZdog='
 'sha256-C7CIpwSS5m3mXZcXJPPh/8geedELe6rTiB841eQdFZ4=';style-src 'self'
 mycdn.rocketcdn.me challenges.cloudflare.com checkout.clover.com 'unsafe-
 inline';

 And since the CSP is created dynamically on every page, it doesn't care
 what inline scripts are in the final html. It will make a hash for it. And
 if an inline script changes because a plugin updates, it doesn't matter
 because a hash for the new script will be created when that new script
 ends up in the final html.

 Since I do use WP-Rocket, for pages optimized by WP-Rocket, I have to hook
 into the filter 'rocket_buffer' to get the final html which WP-Rocket
 generates. For frontend pages not handled by WP-Rocket I hook into the
 'template_redirect' action to get the final html.

 On the admin side, I hook into 'admin_init' to get the final html this
 way:

 {{{
 add_action(
         'admin_init',
         /**
         * Callback function to modify output before it is sent to the
 browser.
         *
         * @param  void
         *
         * @return string The modified output.
         */
         function () {

                 if ( ! is_admin() ) {
                         return;  // Exit the function if not on an admin
 page.....
                 }

                 ob_start(
                         /**
                         * Callback function to modify output before it is
 sent to the browser.
                         *
                         * @param  string $output The output to be
 modified.
                         *
                         * @return string The modified output.
                         */
                         function ( $output ) {

                                 global $wp;

                                 $output_length = strval( strlen( $output )
 );

                                 make_admin_csp_hashes( $output, '' );

                                 return $output;
                         }
                 );
         },
         PHP_INT_MAX
 );
 }}}

 On the admin side, I did have to add this logic to add 'unsafe-eval' to
 the CSP because there are some included javascript files which call things
 like Function() which require 'unsafe-eval' in the CSP for the scripts to
 run.
 {{{
         if ( false !== strpos($page_html, 'block-templates/index.js' ) ||
 // WooCommerce problem.
              false !== strpos($page_html, 'underscore.min.js') ||
              false !== strpos($page_html, 'handlebars.min.js')) { //
 UpdraftPlus issue.
                 $extra_script_rules .= " 'unsafe-eval' ";
              }
 }}}
 So there are some pages on the admin side that must have 'unsafe-eval' in
 order for them to work. But I can control that page by page and leave
 'unsafe-eval' off many admin side pages.

 This is a plugin specific to my site. Depending on what optimizing plugin
 you use, how you access the final html may vary. And there may be plugins
 on your frontend that use javascript files that won't run without 'unsafe-
 eval' in your script-src CSP. Fortunately I haven't found any on my
 WordPress site. Major plugins I use are Happyforms, Simple Cloudflare
 Turnstile, UpdraftPlus, WooCommerce, Wordfence, Wp Mail SMTP Pro, WP-
 Rocket, Yoast SEO ( with Premium & WooComerce additions).

-- 
Ticket URL: <https://core.trac.wordpress.org/ticket/39941#comment:115>
WordPress Trac <https://core.trac.wordpress.org/>
WordPress publishing platform


More information about the wp-trac mailing list