Cloudflare Logo

When I found out that Cloudflare Pages could host Hugo, I decided to scrap creating this very site in Python with Django.

This was a way for me to get this site up and running quickly and a good way for me to learn about Hugo in the process.

Installing Hugo & Picking a Theme

As I run Linux, installing it is a breeze and I installed it using a snap package as I have snapd installed.

sudo snap install hugo --channel=extended

Next up was getting a theme and I found one that I liked from hugo themes called Hello-friend-ng. I forked it and made changes that have cloudflare products in mind (Cloudflare workers and their web analytics).

Getting My Modified Theme

The theme is available from my GitHub, which you can get here.

Cloudflare Changes

So what did I change, I hear you ask?

  • Add support to be able to use Cloudflares privacy preserving analytics. To use it add cloudflareWebAnalytics = "token" to your config.toml file which you will get from following the setup guide for it in the Cloudflare dashboard.
  • Added CSP nonce support that is used with Cloudflare Workers.
  • Removed prism.js as Hugo has builtin code syntax highlighting which fixed a bug in the theme.
  • Updated any third party CSS files.
  • Removed an inline style tag that that caused a CSP violation.
  • Removed support for Google Analytics.
  • Tweak to improve cls score, which you can learn more about here
  • Added the new browser early hints support.

CSP Nonce & Setting Up Security Headers With Cloudflare Workers

I would like to preface this by saying thank you to Scott Helme and the Cloudflare Discord Pages for helping me resolve my issues and getting me over the finish line as this was the last piece of the puzzle I needed.

From me reaching out on Twitter and copying in Scott Helme, he linked me to his article that I had forgotten about at the time which you can view here with taking that code I had a starting point and then tweaked it where needed.

Before sorting out how to inject the CSP nonce value, I had security headers setup in the worker and had it report CSP errors to https://report-uri.com

Once I had that up and running, I was stuck on how to get the CSP-NONCE header value actually injected into the HTML script tag nonce section. This is where the awesome people over on the Cloudflare Discord came to my rescue, by taking my code that I had and gave me the code snippet that I was missing. Bear in mind, knowing about Cloudflare Workers and actually trying to implement something with it is a whole different kettle of fish.

The bit of code that I was missing was using the HTML rewriter api as Hugo is a static site generator you need a way to inject it and using the HTML rewriter api takes care of that issue.

Here is the code that will get you up and running very quickly. You just need to modify the report-uri url and remove the GitHub url, but all the Cloudflare ones that are needed are there. I will update the worker code in the post as I make tweaks that either add something new Cloudflare wise or a bug(s) gets fixed.

const cspConfig = {
  "default-src": ["'self'", "cdnjs.cloudflare.com", "raw.githubusercontent.com", "cloudflareinsights.com", "static.cloudflareinsights.com", "'report-sample'"],
  "script-src": ["'self'", "ajax.cloudflare.com", "static.cloudflareinsights.com", "'nonce'", "'report-sample'"],
  "script-src-elem": ["'self'", "ajax.cloudflare.com", "static.cloudflareinsights.com", "'nonce'", "'report-sample'"],
  "connect-src": ["'self'", "cloudflareinsights.com", "'report-sample'"],
  "base-uri": ["'self'"],
  "style-src": ["'self'", "cdnjs.cloudflare.com", "'unsafe-inline'", "'report-sample'"],
  "style-src-elem": ["'self'", "cdnjs.cloudflare.com", "'report-sample'"],
  "manifest-src": ["'self'", "cybermon.cloudflareaccess.com"],
  "report-uri": ["https://cybermon.report-uri.com/r/d/csp/enforce"],
};

export default {
  async fetch(request) {
    const response = await fetch(request);

    // Clone the response so that it's no longer immutable
    const newResponse = new Response(response.body, response);

    let cspNonce = btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(18))));
    newResponse.headers.set("CSP-NONCE", cspNonce);

    // Inject the nonce value
    const newHtmlResponse = new HTMLRewriter()
      .on("script", {
        element(element) {
          element.setAttribute("nonce", cspNonce);
        },
      })
      .transform(newResponse);

    newHtmlResponse.headers.set("Content-Security-Policy", buildCspHeader(cspConfig, cspNonce));

    newHtmlResponse.headers.append("Strict-Transport-Security", "max-age=31536000; includeSubDomains");
    //newResponse.headers.append('x-content-type-options', 'nosniff');
    newHtmlResponse.headers.append("Permissions-Policy", "accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=(), interest-cohort=()");
    newHtmlResponse.headers.append("X-Frame-Options", "SAMEORIGIN");
    newHtmlResponse.headers.append("cross-origin-opener-policy-report-only", 'cross-origin; report-to="default"');
    newHtmlResponse.headers.append("cross-origin-embedder-policy-report-only", 'require-corp; report-to="default"');
    newHtmlResponse.headers.append("cross-origin-resource-policy", "cross-origin");

    // Adjust the value for an existing header
    // Cloudflare set these headers by default so need to overide it
    newHtmlResponse.headers.set("NEL", '{"report_to":"default","max_age":31536000,"include_subdomains":true}');
    newHtmlResponse.headers.set("Report-To", '{"group":"default","max_age":31536000,"endpoints":[{"url":"https://cybermon.report-uri.com/a/d/g"}],"include_subdomains":true}');

    return newHtmlResponse;
  },
};

function buildCspHeader(cspConfig, nonce = null) {
  let directives = [];
  Object.keys(cspConfig).forEach((directive) => {
    let values = Array.from(cspConfig[directive]);
    values.forEach((value, key) => {
      if (nonce && value === "'nonce'") {
        values[key] = "'nonce-" + nonce + "'";
      } else if (nonce === null && value === "'nonce'") {
        values.splice(key, 1);
      }
    });
    if (values.length === 0) {
      directives.push(directive);
    } else {
      directives.push(directive + " " + values.join(" "));
    }
  });
  return directives.join("; ");
}