Search code examples
cloudflaregoogle-sitesgoogle-sites-2016cloudflare-apps

Is there a way to allow scripts from Cloudflare on new Google sites?


I created a site using the new Google sites (not Classic Sites), set up site proxying through Cloudflare, and enabled the Email Address Obfuscation feature in Cloudflare. Then I added a button that performs a simple action mailto:[email protected] and ran into a problem: When I click on the button, I am taken to the Cloudflare "Email Protection" page with the message "You are unable to access this email address example.com".

This is for a simple reason - my browser (and it will happen with any modern browser) does not load the email-decode.min.js script from Cloudflare. In turn, this is due to the fact that Google Sites uses CSP >= v2 and the CSP directives are configured in such a way that they do not allow the script from Cloudflare to load.

According to Cloudflare documentation, in order to use Scrape Shield you need to update CSP headers as follows:

script-src 'self' 'unsafe-inline'

This is what the new Google Sites CSP header looks like:

base-uri 'self';
object-src 'none';
report-uri /_/view/cspreport;
script-src 'report-sample' 'nonce-7+8CsMF6KihKnNmDwfM84w' 'unsafe-inline' 'unsafe-eval';
worker-src 'self';
frame-ancestors https://google-admin.corp.google.com/

* nonce-<base64-value> is updated with every request.

When loading a page that contains email I see the following error in the browser console:

Refused to load the script 'https://example.com/cdn-cgi/scripts/6d6ddgh8/cloudflare-static/email-decode.min.js' because it violates the following Content Security Policy directive: "script-src 'report-sample' 'nonce-7+8CsMF6KihKnNmDwfM84w' 'unsafe-inline' 'unsafe-eval'". Note that 'script-src-elem' was not explicitly set, so 'script-src' is used as a fallback.

I'm not sure about the reason, but the script won't load for one of two reasons:

  1. The source 'self' is not set for the script-src directive (I don’t think this is the case).
  2. The source 'unsafe-inline' is ignored because a cryptographic nonce is present.

It doesn't work anyway. I see two solutions, but I don't know how to implement them:

  1. For the first reason (although I don't think this is the case) this could be solved if Google added the source 'self' to the script-src directive, or if I had the ability to customize this header and I would do it myself.
  2. For the second reason, this could be solved if Cloudflare read the 'nonce-<base64-value>' that the Google server returns when the site is requested and adds it to its scripts.

It would be grateful if someone could share a solution to this problem.


Solution

  • So I figured out that the reason is the missing 'self' source in the script-src directive.

    I found a forum thread that suggests using Cloudflare Workers to change the required data in a request / response on the fly. I also found a ready-made example code for a worker that allows to replace headers in the request / response.

    Inspired by this idea and given that Cloudflare provides 100,000 requests per day for free, I wrote and deployed a worker code that changes the server response headers, in fact, it updates the script-src directive in the content-security-policy header, supplementing it with the sources specified in the variable sources.

    My problem is solved, now the Cloudflare script is loading and the button is working.

    * I don't promise quality code, but it works. The code could be made even more versatile, but I didn't have time for that.

    Here is my worker code:

    addEventListener("fetch", event => {
      event.respondWith(handleRequest(event.request))
    })
    
    const sources = ["'self'"]
    
    /**
     * The function to update a CSP directive with new sources.
     * @param {string} directive CSP directive.
     * @param {string[]} sources Sources to add to the directive.
     * @return {string} Updated CSP directive.
     */
     function updateDirective(directive, sources) {
      for (let i = 0; i < sources.length; i++) {
        if (!directive.toLowerCase().includes(sources[i])) {
          directive = directive.concat(" ", sources[i])
        }
      }
      
      return directive
    }
    
    /**
     * The function to update the Content-Security-Policy header.
     * @param {string} header The Content-Security-Policy header.
     * @param {string} directive The Content-Security-Policy directive whose sources need to be updated.
     * @param {string} sources Sources to add to the directive.
     * @return {string} Updated Content-Security-Policy header.
     */
    function updateHeader(header, directive, sources) {
      let sourceHeader = header.split(';')
      let updatedHeader = []
      
      for (let i = 0; i < sourceHeader.length; i++) {
        if (sourceHeader[i].includes(directive)) {
          updatedHeader.push(updateDirective(sourceHeader[i], sources))
        } else {
          updatedHeader.push(sourceHeader[i])
        }
      }
      
      return updatedHeader.join(";")
    }
    
    async function handleRequest(request) {
      let response = await fetch(request)
    
      response = new Response(response.body, response)
      response.headers.set('content-security-policy',
       updateHeader(response.headers.get('content-security-policy'), "script-src", sources))
    
      return response
    }