• Resolved Kostiantyn Petlia

    (@kostiantynpetlia)


    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.

Viewing 8 replies - 1 through 8 (of 8 total)
  • Plugin Author Pascal Birchler

    (@swissspidy)

    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!

    Thread Starter Kostiantyn Petlia

    (@kostiantynpetlia)

    Thank you for fast answer and the code example. I’m going to implement it and back with feedback.

    Thread Starter Kostiantyn Petlia

    (@kostiantynpetlia)

    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 (https://localhost:8000/insights/) I have a Web Stories shortcode and can see 4 stories. It’s the div.web-stories-list. I see CSP errors they referrer to https://localhost:8000/web-stories/all-you-need-to-knowabout-iomt/. It’s iframe.story-player-iframe with src:
    https://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…

    1. 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.
    2. 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; style tag with the amp-custom attribute 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.

    Plugin Author Pascal Birchler

    (@swissspidy)

    The code I provided is for adding CSP nonces on single stories pages, i.e. when you visit a page like https://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.

    Thread Starter Kostiantyn Petlia

    (@kostiantynpetlia)

    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 https://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:
    https://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: https://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 with str_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">

    Thread Starter Kostiantyn Petlia

    (@kostiantynpetlia)

    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.

    Plugin Author Pascal Birchler

    (@swissspidy)

    $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.

    Thread Starter Kostiantyn Petlia

    (@kostiantynpetlia)

    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" >
    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.

Viewing 8 replies - 1 through 8 (of 8 total)
  • You must be logged in to reply to this topic.