Content Security Policy and the plugin scripts
-
Hi.
I like your plugin. It does work well, but recently I’ve faced issues with Content Security Policy (CSP) implementation with your plugin.The best practices are not use ‘unsafe-inline’ and ‘unsafe-eval’ for scripts.
To make a long story short, I coded the functionality of dynamic CSP (it calculates hashes or adds a nonce for inline and external scripts). Details are not so important, but I use standard WP functions and filters for scripts: wp_add_inline_script(), wp_print_inline_script_tag(), wp_localize_script(), etc. It allows me to add the SCP nonce to them or calculate hashes for them. Everything works well, but…
Your plugin outputs scripts in a not appropriate way without using any WP functions or filters (‘wp_script_attributes’, ‘wp_inline_script_attributes’). At least in the fix_malformed_script_link_tags(). It makes it impossible to implement CSP for your scripts and forces me to do dirty tricks to fix it. It’s really sad.
I urge you to support CSP and output JS scripts with WP functions/filters only (and don’t use inline handlers like onclick, etc.).
Thanks.
-
Hi there,
Web Stories does not and cannot use typical WordPress functions for generating script tags. If you want to add CSP nonces to scripts, I went ahead and created this example for you to achieve this the proper way: https://gist.github.com/swissspidy/a51a13eb5b6236ed7a1dfcaebfd4080b
Hope this helps!
Thank you for fast answer and the code example. I’m going to implement it and back with feedback.
Could you help me a bit more?
I’ve implemented this transformer functionality. Firstly I changed it in a more OOP way for my plugin structure, but later I just put your code “as is” in functions.php for debug purposes.
On the blog page (
http://localhost:8000/insights/) I have a Web Stories shortcode and can see 4 stories. It’s thediv.web-stories-list. I see CSP errors they referrer tohttp://localhost:8000/web-stories/all-you-need-to-knowabout-iomt/. It’siframe.story-player-iframewith src:http://localhost:8000/web-stories/all-you-need-to-knowabout-iomt/#visibilityState=prerender&origin=http%3A%2F%2Flocalhost%3A8000&showStoryUrlInfo=0&storyPlayer=v0&cap=swipe.The issue: Your code works, $document is updated, but an iframe story page is pretty different. It’s strange…
- During debugging, I can see the updated scripts with the nonce (I used
$updatedHtml = $document->saveHTML();) in our transform() method. So, your code applies the nonce to scripts in$document. - But on the frontend (the same on the outputting of the page buffer
self::sendHeaders($headers); echo $page;) I can see a partially different HTML page: scripts are without the nonce;styletag with theamp-customattribute is NOT compressed; my <meta> CSP is present (I insert it before page outputting), but it is absent in the #1, etc.
Could you explain it and advise me on how to fix it? It seems I don’t understand well how web stories are rendered.
-
This reply was modified 1 year, 8 months ago by
Kostiantyn Petlia.
The code I provided is for adding CSP nonces on single stories pages, i.e. when you visit a page like http://localhost:8000/web-stories/all-you-need-to-knowabout-iomt/. Can you verify that you see the nonce value being applied there? It worked in my testing.
That said, you are actually referring to CSP issues on pages where you are using the shortcode, is that correct?
But on the frontend (the same on the outputting of the page buffer
self::sendHeaders($headers); echo $page;) I can see a partially different HTML page:Which page are you referring to? The single story page or the page where you used the shortcode? What is this
sendHeaders()method? This is not something that exists in the Web Stories plugin.Here is the shortcode:
[web_stories title=”true” excerpt=”false” author=”false” date=”false” archive_link=”true” archive_link_label=”View all” circle_size=”150″ sharp_corners=”false” image_alignment=”left” number_of_columns=”1″ number_of_stories=”5″ order=”DESC” orderby=”post_date” view=”carousel” /]
The shortcode outputs web stories on the
http://localhost:8000/insights/blog page. On this page I can see 4 CSP errors. All them have the same link on the first story in the carousel:http://localhost:8000/web-stories/all-you-need-to-knowabout-iomt/#visibilityState=prerender&origin=http%3A%2F%2Flocalhost%3A8000&showStoryUrlInfo=0&storyPlayer=v0&cap=swipe
Scripts:https://cdn.ampproject.org/v0.js&https://cdn.ampproject.org/v0/amp-story-1.0.js.
It seems the same two CSP errors were triggered twice: 2+2 = 4 errors.When I click (or open in a new tab) on the first story on the carousel I see the color-filled screen and the story excerpt super small text and the same 4 CSP errors, but with URL:
http://localhost:8000/web-stories/all-you-need-to-knowabout-iomt/.So, the issue is the same for the blog page and the single story page. But the blog page looks normal at least.
Let me provide a bit more details about the CSP implementation.
How it currently works:- we don’t use CSP on the server level (.htaccess);
- we add our CSP on the WP/PHP level based on standard WP functions and filters for JS scripts;
- we use the whole PHP page buffering and build the dynamic SCP (start on the ‘init’ hook), calculate hashes and add the nonce to scripts, update HTTP headers with the CSP, add <meta> with the CSP to <head>, and output the page on the ‘shutdown’ WP action (right after the mentioned our
sendHeaders()method that updates headers with the CSP);
The error message example:
“Refused to load the script https://cdn.ampproject.org/v0.js because it violates the following Content Security Policy directive: “script-src ‘self’ ‘strict-dynamic’ {domains} ‘nonce-2295cb6ff3′”. Note that ‘strict-dynamic’ is present, so host-based allowlisting is disabled. Note that ‘script-src-elem’ was not explicitly set, so ‘script-src’ is used as a fallback.”Important:
When I use a dirty fix withstr_replace()before buffer outputting on the $page everything works well.
I’m logged and the WP-Rocket cache doesn’t work. I don’t remember that we changed Web Stories functionality somehow; only styles.Debug:
$document in your method has:<$cript async="" src="https://cdn.ampproject.org/v0.mjs" type="module" crossorigin="anonymous" nonce="2295cb6ff3">
The buffered $page on the echo moment has:<$cript async="" src="https://cdn.ampproject.org/v0.js">Yeap, our CSP plugin breaks Web Stories. When I disable it web stories work. But I need to understand where there is a conflict and why it is. It’s not so complicated plugin with the page buffering from ‘init, 0’ to ‘shutdown, 0’ and changing HTTP headers right before the buffered page outputting.
Do web stories do some work after the ‘shutdown, 0’ hook? According to not compressed style, it looks possible… The order of execution that I can see:- ‘init, 0’: ob_start();
- your transform() code;
- ‘shutdown, 0’: $page = ob_get_clean(); … echo $page;
I’m going to dive deeper into it and happy with any suggestions.
$document in your method has:
<$cript async="" src="https://cdn.ampproject.org/v0.mjs" type="module" crossorigin="anonymous" nonce="2295cb6ff3">
The buffered $page on the echo moment has:<$cript async="" src="https://cdn.ampproject.org/v0.js">we use the whole PHP page buffering and build the dynamic SCP (start on the ‘init’ hook), calculate hashes and add the nonce to scripts, update HTTP headers with the CSP, add <meta> with the CSP to <head>, and output the page on the ‘shutdown’ WP action (right after the mentioned our
sendHeaders()method that updates headers with the CSP);That means your output buffering is running too early, before Web Stories has done its own output buffering.
For single story pages it will be easier to use the code I provided to you to make all of these modifications like setting the nonce and setting the meta tag or header. This way you can be sure the code runs at the right step.
First of all, thank you for your fast support, Pascal.
I’m going to test the site well, but currently, everything works well.
I think the next information can be useful for other developers.
Conflict with the web stories PHP buffer
The main problem with the PHP buffer was that the web stories’s buffer was opened after my (‘init, 0’) and it is closed by WordPress in the next line:/wordpress/wp-includes/default-filters.php line 412: add_action( 'shutdown', 'wp_ob_end_flush_all', 1 );
As a result, any priority of ‘shutdown’ higher than 1 didn’t work for my closing method. At that moment all buffers are flushed already. Any priority of ‘shutdown’ less than 1 breaks web stories, because my code closes the web stories buffer (not mine). So, I decided to use your approach and set a callback function for my buffering:ob_start([self::class, 'preparePageBufferToOutput']);
On ‘shutdown, 1’ WordPress closes all open buffers: the web stories buffer and mine. No conflicts!
CSP header, CSP meta, and scripts’s nonces are present, but web stories still throw a CSP blocking error
It was extraordinary. Everything was present and set well, but there were two CSP errors in the Chrome Console. Despite this, it seemed web stories worked well… Anyway, I figured out where was a problem:<link rel="preload" as="script" href="https://cdn.ampproject.org/v0/amp-story-1.0.js">
When you add the CSP nonce to this link those errors disappear. (Nonce to a link tag? Why? It’s still a mystery to me 🙂 ) So, here is a modified version of your example:if (interface_exists(Transformer::class)) {
class WebStoryNonceTransformer implements Transformer
{
/**
* Applies nonce addition to scripts and preload links in the provided DOM document.
*
* @param Document $document DOM document to apply the transformations to.
* @param ErrorCollection $errors Collection of errors that are collected during transformation.
*
* @return void
*/
public function transform(Document $document, ErrorCollection $errors): void
{
$nonce = PolicyBuilder::getTheNonce();
$xpath = $document->xpath;
$query = '//script[not(@type) or @type="module"] | '
. '//link[@rel="preload" and @as="script"] | '
. '//link[@rel="modulepreload"]';
$elements = $xpath->query($query);
if ($elements instanceof DOMNodeList) {
foreach ($elements as $element) {
/** @var DOMElement $element */
$element->setAttribute('nonce', $nonce);
}
}
}
}
}I hope I won’t have to bother you again.
- During debugging, I can see the updated scripts with the nonce (I used
The topic ‘Content Security Policy and the plugin scripts’ is closed to new replies.