Skip to content

Elevate Your Astro Security: Implementing Static CSP Headers with a Custom Cloudflare Integration

In today's web landscape, security is paramount. Content Security Policy (CSP) is a crucial layer of defense against cross-site scripting (XSS) and other content injection attacks. For Astro projects deployed on Cloudflare, we can leverage Cloudflare's _headers file to define static CSP headers, ensuring a robust security posture from the get-go.

This article will guide you through creating a custom Astro integration that automatically generates these static CSP headers, including hashes for inline scripts and styles, for your Cloudflare deployments.

Why a Custom Integration?

While you could manually create a _headers file, a custom Astro integration offers several advantages:

  1. Automation: No more manual updates to your CSP when you add or change inline scripts/styles.
  2. Consistency: Ensures your CSP is always applied correctly across your project.
  3. Maintainability: Centralizes your CSP configuration, making it easier to manage and update.
  4. Security Best Practice: Automatically computes SHA256 hashes for inline content, eliminating the need for unsafe-inline (where possible and for modern browsers), a significant security improvement.

The Core Idea

Our integration will perform the following steps during the Astro build process:

  1. Define Base CSP Sources: We'll have a configuration file where you define your allowed domains and general CSP directives.
  2. Crawl and Hash: After Astro builds your project, we'll crawl the dist folder to identify all inline <script> and <style> tags, as well as style="..." attributes. For each, we'll compute its SHA256 hash.
  3. Generate _headers: Finally, we'll combine your base CSP sources with the collected hashes to create a comprehensive Content-Security-Policy header and write it to a _headers file in your dist directory. Cloudflare will automatically pick up this file and apply the headers.

Let's dive into the code!

Implementation

Step 1: CSP Configuration (cspSources.config.js)

First, create a file named cspSources.config.js at the root of your project. This file will hold the base configuration for your Content Security Policy, listing all trusted sources for different types of content.

// cspSources.config.js
export const cspSources = {
  defaultSrc: ["'self'"],
  scriptSrc: [
    "'self'",
    "https://example.com", // Add any external script sources
    //...
  ],
  styleSrc: [
    "'self'",
    "https://fonts.example.com", // Add any external style sources
    //...
  ],
  styleSrcAttr: [
    // Add any specific sources for style attributes if needed
    //...
  ],
  imgSrc: [
    "'self'",
    "data:", // For base64 encoded images
    //...
  ],
  fontSrc: [
    "'self'",
    "https://fonts.example.com", // Add any external font sources
    //...
  ],
  connectSrc: [
    "'self'",
    "https://example.com", // Add any external API endpoints
    //...
  ],
  frameSrc: [
    "'self'",
    "https://example.com", // Add any allowed iframe sources
    //...
  ],
  workerSrc: [
    "'self'",
    //...
  ],
  childSrc: [
    "'self'",
    //...
  ],
  objectSrc: [
    "'none'", // Generally a good practice to block <object> and <embed>
    //...
  ],
  frameAncestors: [
    "'none'", // Prevent your site from being embedded in iframes
    //...
  ],
};

Explanation:

  • Each property (e.g., scriptSrc, styleSrc) corresponds to a CSP directive.
  • 'self' allows resources from the same origin.
  • You'll add any external domains your site relies on (e.g., analytics scripts, font providers) to the respective arrays.
  • 'none' is used for directives where you want to block all sources.

Step 2: Inline Content Hashing Utility (processInlineScriptsStyles.js)

Next, create processInlineScriptsStyles.js. This module is responsible for crawling your built HTML files and generating SHA256 hashes for any inline scripts or styles. This is crucial for maintaining a strong CSP without resorting to unsafe-inline.

// processInlineScriptsStyles.js
import fs from "fs";
import path from "path";
import crypto from "crypto";
import { JSDOM } from "jsdom"; // You'll need to install this: npm install jsdom

/**
 * Compute a CSP hash (sha256) for a given string.
 * @param {string} content
 * @returns {string} CSP directive string like "sha256-XYZ..."
 */
function computeCspHash(content) {
  const hash = crypto
    .createHash("sha256")
    .update(content, "utf8")
    .digest("base64");
  return `'sha256-${hash}'`;
}

/**
 * Recursively crawl a directory and collect CSP hashes.
 * @param {string} rootDir - Path to the root directory
 * @returns {object} { scriptSrc: [], styleSrc: [], styleSrcAttr: [] }
 */
export function collectCspHashes(rootDir) {
  const result = {
    scriptSrc: [],
    styleSrc: [],
    styleSrcAttr: [],
  };

  function crawl(dir) {
    const entries = fs.readdirSync(dir, { withFileTypes: true });

    for (const entry of entries) {
      const fullPath = path.join(dir, entry.name);

      if (entry.isDirectory()) {
        crawl(fullPath);
      } else if (entry.isFile() && entry.name.endsWith(".html")) {
        const html = fs.readFileSync(fullPath, "utf8");
        const dom = new JSDOM(html);
        const { document } = dom.window;

        // Inline <script>
        document.querySelectorAll("script:not([src])").forEach((el) => {
          const content = el.textContent.trim();
          if (content) {
            result.scriptSrc.push(computeCspHash(content));
          }
        });

        // Inline <style>
        document.querySelectorAll("style").forEach((el) => {
          const content = el.textContent.trim();
          if (content) {
            result.styleSrc.push(computeCspHash(content));
          }
        });

        // style="..." attributes
        document.querySelectorAll("[style]").forEach((el) => {
          const content = el.getAttribute("style").trim();
          if (content) {
            result.styleSrcAttr.push(computeCspHash(content));
          }
        });
      }
    }
  }

  crawl(rootDir);
  return {
    scriptSrc: [...new Set(result.scriptSrc)], // Remove duplicates
    styleSrc: [...new Set(result.styleSrc)],
    styleSrcAttr: [...new Set(result.styleSrcAttr)],
  };
}

Key parts of this module:

  • computeCspHash(content): Takes a string (e.g., the content of an inline script) and returns its SHA256 hash formatted for CSP.
  • collectCspHashes(rootDir):
    • Recursively reads all .html files in the rootDir (your dist folder).
    • Uses jsdom to parse the HTML and find inline <script> tags without a src attribute, <style> tags, and elements with style attributes.
    • Computes and collects hashes for all found inline content.
    • Returns unique hashes to avoid redundancy.

Installation Note: This module uses jsdom, so you'll need to install it:

npm install jsdom
# or
pnpm install jsdom
# or
yarn add jsdom

Step 3: The Astro Integration (cloudflare-http-headers.mjs)

Now, let's create the actual Astro integration in a file named cloudflare-http-headers.mjs. This file defines the integration's logic and hooks into Astro's build process.

// cloudflare-http-headers.mjs
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
import { cspSources } from "./cspSources.config.js"; // Note the .js extension
import { collectCspHashes } from "./processInlineScriptsStyles.js"; // Note the .js extension

/**
 * Helper to format CSP sources into a space-separated string.
 * Filters out any empty or null values.
 * @param {Array<string>} arr
 * @returns {string}
 */
function formatSources(arr) {
  return arr.filter(Boolean).join(" ");
}

/**
 * Astro integration to generate Cloudflare _headers file with CSP.
 */
export default function writeCloudflareCSPHeaders() {
  return {
    name: "astro-csp-headers",
    hooks: {
      "astro:build:done": async ({ dir }) => {
        // `dir` is a URL, convert it to a file path
        const distPath = fileURLToPath(dir);

        console.log("Collecting CSP hashes for inline content...");
        const hashes = collectCspHashes(distPath);
        console.log(`Found ${hashes.scriptSrc.length} inline script hashes.`);
        console.log(`Found ${hashes.styleSrc.length} inline style hashes.`);
        console.log(`Found ${hashes.styleSrcAttr.length} inline style attribute hashes.`);

        // Combine base CSP sources with collected hashes
        const defaultSrc = formatSources(cspSources.defaultSrc);
        const scriptSrc = formatSources([
          ...cspSources.scriptSrc,
          ...hashes.scriptSrc,
          // 'unsafe-inline' is added for backward compatibility with older browsers.
          // Modern browsers that support CSP Level 2+ will ignore this if hashes are present.
          // Consider removing if you are confident your target browsers support hashes.
          "'unsafe-inline'",
        ]);

        const styleSrc = formatSources([
          ...cspSources.styleSrc,
          ...hashes.styleSrc,
        ]);
        const styleSrcAttr = formatSources([
          ...cspSources.styleSrcAttr,
          ...hashes.styleSrcAttr,
        ]);

        // ... rest of your CSP directives
        const imgSrc = formatSources(cspSources.imgSrc);
        const fontSrc = formatSources(cspSources.fontSrc);
        const connectSrc = formatSources(cspSources.connectSrc);
        const frameSrc = formatSources(cspSources.frameSrc);
        const objectSrc = formatSources(cspSources.objectSrc);
        const frameAncestors = formatSources(cspSources.frameAncestors);
        const workerSrc = formatSources(cspSources.workerSrc);
        const childSrc = formatSources(cspSources.childSrc);

        // Construct the full CSP string
        const cspDirectives = [
          `default-src ${defaultSrc};`,
          `script-src ${scriptSrc};`,
          `style-src ${styleSrc};`,
          `style-src-attr ${styleSrcAttr};`,
          `img-src ${imgSrc};`,
          `font-src ${fontSrc};`,
          `connect-src ${connectSrc};`,
          `frame-src ${frameSrc};`,
          `object-src ${objectSrc};`,
          `frame-ancestors ${frameAncestors};`,
          `worker-src ${workerSrc};`,
          `child-src ${childSrc};`,
        ].filter(Boolean); // Remove any empty directives

        // Additional security headers
        const csp = cspDirectives.join(" ");
        const trustedTypes = "require-trusted-types-for 'script';"; // Good for preventing DOM XSS
        const coop = "Cross-Origin-Opener-Policy: same-origin"; // Enhances isolation
        const coep = "Cross-Origin-Embedder-Policy: require-corp"; // Prevents unwanted cross-origin embedding

        const headersContent = `/*
Content-Security-Policy: ${csp} ${trustedTypes}
${coep}
${coop}

X-Frame-Options: SAMEORIGIN
X-Content-Type-Options: nosniff
Referrer-Policy: no-referrer-when-downgrade
Permissions-Policy: geolocation=(), microphone=(), camera=()
`;

        // Write the _headers file to the build output directory
        const headersPath = path.join(distPath, "_headers");
        fs.writeFileSync(headersPath, headersContent.trim()); // trim to remove trailing newline

        console.log(`_headers file generated at: ${headersPath}`);
        console.log("CSP Headers content:");
        console.log(headersContent);
      },
    },
  };
}

Breakdown of cloudflare-http-headers.mjs:

  • formatSources(arr): A utility function to join array elements into a space-separated string, suitable for CSP directives.
  • writeCloudflareCSPHeaders(): This is the function that defines your Astro integration.
    • name: A unique name for your integration.
    • hooks: Astro integrations use hooks to run code at specific points in the build lifecycle.
      • "astro:build:done": This hook fires after Astro has completed building your project and written all output files to the dist directory. This is the perfect time to crawl the output and generate headers.
    • Inside the hook:
      • It gets the distPath (the output directory).
      • Calls collectCspHashes to get all inline script/style hashes.
      • Combines the cspSources.config.js with the dynamically generated hashes for scriptSrc, styleSrc, and styleSrcAttr.
      • Constructs the full Content-Security-Policy string, including additional security headers like Cross-Origin-Opener-Policy and Cross-Origin-Embedder-Policy.
      • Writes the complete headers content to a _headers file in the dist directory.

Step 4: Integrating into Astro (astro.config.mjs)

Finally, you need to tell Astro to use your new integration. Open your astro.config.mjs file and add the integration:

// astro.config.mjs
import { defineConfig } from "astro/config";
import writeCloudflareCSPHeaders from "./cloudflare-http-headers.mjs"; // Import your integration

// https://astro.build/config
export default defineConfig({
  // ... other Astro configurations
  integrations: [
    writeCloudflareCSPHeaders(), // Add your integration here
  ],
});

Running Your Build

Now, when you build your Astro project, the integration will run:

npm run build
# or
pnpm run build
# or
yarn build

After the build completes, you should find a _headers file in your dist directory (e.g., dist/_headers). This file will contain your comprehensive Content Security Policy, ready for Cloudflare to pick up and apply to your deployed site.

Here's an example of what the _headers file might look like:

/*
Content-Security-Policy: default-src 'self'; script-src 'self' https://example.com 'sha256-abc...' 'sha256-xyz...' 'unsafe-inline'; style-src 'self' https://fonts.example.com 'sha256-123...'; style-src-attr 'sha256-456...'; img-src 'self' data:; font-src 'self' https://fonts.example.com; connect-src 'self' https://example.com; frame-src 'self' https://example.com; object-src 'none'; frame-ancestors 'none'; worker-src 'self'; child-src 'self'; require-trusted-types-for 'script';
Cross-Origin-Embedder-Policy: require-corp
Cross-Origin-Opener-Policy: same-origin

X-Frame-Options: SAMEORIGIN
X-Content-Type-Options: nosniff
Referrer-Policy: no-referrer-when-downgrade
Permissions-Policy: geolocation=(), microphone=(), camera=()

Conclusion

By creating this custom Astro integration, you've automated the generation of static CSP headers for your Cloudflare deployments. This not only streamlines your development workflow but also significantly enhances the security of your Astro application by implementing robust content security policies and automatically hashing inline resources. Happy building, and stay secure!