Content Security Policy in 3.4

I’m adding CSP to a new app, and it took some doing to get everything working on Safari.

I was getting these errors on desktop and IOS Safari, both on localhost and in production:

Refused to execute a script because its hash, its nonce, or ‘unsafe-inline’ does not appear in the script-src directive of the Content Security Policy.
ReferenceError: Can’t find variable: meteor_runtime_config

I got it working by iterating for about 7 hours with multiple LLMs. Here’s the working code:

import { RUNNING_WITH_PRODUCTION_FLAG_ON_LOCALHOST } from "../both/api_endpoints";
import { WebApp } from 'meteor/webapp';
import { WebAppInternals } from 'meteor/webapp';
import { Autoupdate } from 'meteor/autoupdate';
import crypto from 'crypto';

// --- 1️⃣ Load CSP config from JSON ---
const cspRawJSON = await Assets.getTextAsync(Meteor.settings.csp_json_filename);
const content_security_policy = JSON.parse(cspRawJSON);

let {
    allowedOrigins: {
        script_src: allowedOrigins_script_src,
        style_src: allowedOrigins_styles,
        font_src: allowedOrigins_fontSrc,
        img_src: allowedOrigins_imgSrc,
        manifest_src: allowedOrigins_manifestSrc,
        connect_src: allowedOrigins_connectSrc,
        child_src: allowedOrigins_childSrc,
        worker_src: allowedOrigins_workerSrc
    }
} = content_security_policy;

// --- 2️⃣ Filter dev endpoints in production ---
function filterDevEndpoints(endpoint) {
    const prohibited = ['localhost', 'pagekite'];
    return !prohibited.some(item => endpoint.includes(item));
}

if (Meteor.isProduction && !RUNNING_WITH_PRODUCTION_FLAG_ON_LOCALHOST) {
    allowedOrigins_script_src = allowedOrigins_script_src.filter(filterDevEndpoints);
    allowedOrigins_styles = allowedOrigins_styles.filter(filterDevEndpoints);
    allowedOrigins_fontSrc = allowedOrigins_fontSrc.filter(filterDevEndpoints);
    allowedOrigins_imgSrc = allowedOrigins_imgSrc.filter(filterDevEndpoints);
    allowedOrigins_manifestSrc = allowedOrigins_manifestSrc.filter(filterDevEndpoints);
    allowedOrigins_connectSrc = allowedOrigins_connectSrc.filter(filterDevEndpoints);
    allowedOrigins_childSrc = allowedOrigins_childSrc.filter(filterDevEndpoints);
    allowedOrigins_workerSrc = allowedOrigins_workerSrc.filter(filterDevEndpoints);
}

// --- 3️⃣ Generate hash for __meteor_runtime_config__ script ---
// In Meteor 3, the runtime config is already prepared in WebApp.clientPrograms
// We'll use the first client program (usually 'web.browser')
const clientProgram = WebApp.clientPrograms['web.browser'];

// Get the exact meteorRuntimeConfig string that Meteor will use
let runtimeConfigJson;
if (clientProgram && clientProgram.meteorRuntimeConfig) {
    // Meteor 3+ has the runtime config already prepared
    runtimeConfigJson = clientProgram.meteorRuntimeConfig;
} else {
    // Fallback: build it manually (Meteor 2 style)
    const runtimeConfig = Object.assign({}, __meteor_runtime_config__, Autoupdate, {
        accountsConfigCalled: true,
        isModern: true
    });

    Object.keys(WebApp.clientPrograms).forEach(arch => {
        runtimeConfig.versions = runtimeConfig.versions || {};
        runtimeConfig.versions[arch] = {
            version: Autoupdate.autoupdateVersion || WebApp.clientPrograms[arch].version,
            versionRefreshable: Autoupdate.autoupdateVersion || WebApp.clientPrograms[arch].versionRefreshable,
            versionNonRefreshable: Autoupdate.autoupdateVersion || WebApp.clientPrograms[arch].versionNonRefreshable,
            versionReplaceable: Autoupdate.autoupdateVersion || WebApp.clientPrograms[arch].versionReplaceable
        };
    });

    runtimeConfigJson = JSON.stringify(runtimeConfig);
}

// Generate the exact script text that will appear in the HTML
const runtimeConfigScript = `__meteor_runtime_config__ = JSON.parse(decodeURIComponent("${encodeURIComponent(runtimeConfigJson)}"))`;

// Create SHA-256 hash of the script
const runtimeConfigHash = crypto.createHash('sha256').update(runtimeConfigScript).digest('base64');

console.log('Runtime config hash:', `sha256-${runtimeConfigHash}`);

// --- 4️⃣ Generate per-request nonces for other inline scripts ---
const requestNonces = new WeakMap();

WebApp.connectHandlers.use((req, res, next) => {
    // Generate a unique nonce for this request (for other inline scripts)
    const nonce = crypto.randomBytes(16).toString('base64');

    // Store it on the response object
    requestNonces.set(res, nonce);

    // Set it globally so Meteor will use it for dynamically injected scripts
    WebAppInternals.scriptNonce = nonce;

    next();
});

// --- 5️⃣ Set CSP header with both hash and nonce ---
WebApp.connectHandlers.use((req, res, next) => {
    // Get the nonce we generated for this request
    const nonce = requestNonces.get(res) || 'fallback-nonce';

    const csp = [
        `default-src 'self'`,
        // Include both the hash for __meteor_runtime_config__ AND the nonce for other scripts
        `script-src 'self' 'sha256-${runtimeConfigHash}' 'nonce-${nonce}' ${allowedOrigins_script_src.join(' ')}`,
        `style-src 'self' 'unsafe-inline' ${allowedOrigins_styles.join(' ')}`,
        `img-src 'self' data: ${allowedOrigins_imgSrc.join(' ')}`,
        `connect-src 'self' ${allowedOrigins_connectSrc.join(' ')}`,
        `font-src 'self' ${allowedOrigins_fontSrc.join(' ')}`,
        `object-src 'none'`,
        `base-uri 'self'`,
        `manifest-src 'self' ${allowedOrigins_manifestSrc.join(' ')}`,
        `worker-src 'self' ${allowedOrigins_workerSrc.join(' ')}`,
    ].join('; ');

    res.setHeader('Content-Security-Policy', csp);
    next();
});

// --- 6️⃣ Verification helper (optional - remove in production) ---
console.log(`
To verify the hash matches in browser console, run:
const scriptElement = Array.from(document.getElementsByTagName('script'))
  .find(script => script.textContent.includes('__meteor_runtime_config__'));
if (scriptElement) {
  const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(scriptElement.textContent));
  console.log('Expected:', 'sha256-${runtimeConfigHash}');
  console.log('Actual:', 'sha256-' + btoa(String.fromCharCode(...new Uint8Array(hash))));
}
`);

Is there an easier way to do this? I know there’s a BrowserPolicy package, but it appears to be outdated.

Conversely, if the above code is a best-practices approach, it would be great to have it, or something like it, packaged up as a single Meteor call to make it easy for people to implement. :slight_smile:

1 Like

This is similar to what I’ve done, but without nonces. How did you get the script tags to have the nonce attribute + value?

I don’t know if the script tags do have it yet. I have this unsafe-inline on script-src:

script-src 'self' 'sha256-${runtimeConfigHash}' 'nonce-${nonce}' ${allowedOrigins_script_src.join(' ')},

The LLMs were asking me if I wanted them to do something so that I could remove unsafe-inline there, and I didn’t proceed with that as it was late in the day at the time.

I see. That’s what I was up against with nonces, was that the script tags lacked the nonce attribute. I’m using my app as a SPA, so the nonce would have to be generated by the server and done unique for each visitor/user, which is not easy in SPA mode.

My app is also an SPA. Did you find a solution yet? If not I might let the LLMs have a shot at it.

If think you will have to use unsafe inline if you rely on dynamic import, or was it unsafe eval :thinking:

Anyway: the nonce is important because it limits unsafe scripts to those you delivered, basically the initial clinent bundle. Removing the nonce makes it very unsafe with unsafe-* script sources.

You can also use CSP validators online to check your CSP.

The ”classic” support for dynamic import requires unsafe eval for sure.
I have no idea about the rspack implemention, hopefully it is more modern and loads dynamic data in a more csp friendly way.

I think in Meteor 3 this is solved for everything but style-src, given content security policy such as the above. The big danger was in having to use unsafe-inline with script-src of course.

So I’ve been “asking” chatGPT and Claude about this. Of course anything an LLM says has to be verified and double-checked. Here is what they’re saying:

chatGPT

'unsafe-inline' on style-src is a containable, low-risk compromise, especially in Meteor.

It:

  • Does not enable code execution
  • Does not defeat your CSP
  • Is widely accepted in modern production apps
  • Is often unavoidable with CSS-in-JS frameworks

If you ever see 'unsafe-inline' on script-src, that’s the five-alarm fire :fire_engine:"

On style-src, it’s more like a slightly open window.

CSP Style Inline Exception Justification

Our application permits 'unsafe-inline' in the style-src directive of the Content Security Policy due to framework-level runtime style injection that cannot currently be nonce- or hash-based in Meteor 3. This exception applies only to styles, not scripts.

Inline styles do not permit JavaScript execution, event handler execution, or bypass of script-src restrictions. All script execution is strictly controlled using per-request nonces and SHA-256 hashes, and 'unsafe-inline' is explicitly disallowed for script-src.

The residual risk introduced by inline styles is limited to potential UI manipulation (e.g., visual deception or layout changes) in the event of a separate HTML injection vulnerability. Such attacks do not enable code execution, data access, or privilege escalation. Backend authorization, data access, and state-changing operations are fully enforced server-side and do not rely on client-side UI controls.

This configuration represents a pragmatic, industry-standard tradeoff for modern frameworks that rely on dynamic styling, while maintaining strong protections against cross-site scripting (XSS) and other high-impact web attacks.

CLAUDE

Claude takes it a little more seriously, offer some ways to reduce the attack surface on styles, and wants us to open a feature request for it.

'unsafe-inline' in style-src is a moderate security concern, not a critical one. The main risk is data exfiltration via CSS selectors, which can be mitigated by:

  1. Restricting img-src to block exfiltration channels
  2. Not storing sensitive data in CSS-readable attributes
  3. Using additional security layers (CSRF protection, SameSite cookies)
  4. Working toward removing it eventually (but it’s not urgent like 'unsafe-inline' in script-src would be)

Your current setup - with proper script CSP but 'unsafe-inline' for styles - represents a reasonable security posture for most applications.

Mitigation Strategies

If you must keep 'unsafe-inline' for styles, add these protections:

1. Use more specific CSP directives

javascript

const csp = [
    `default-src 'self'`,
    `script-src 'self' 'sha256-${runtimeConfigHash}' 'nonce-${nonce}'`,
    `style-src-elem 'self' ${allowedOrigins_styles.join(' ')}`, // Only for <style> and <link>
    `style-src-attr 'unsafe-inline'`, // Only for style attributes - more targeted
    `img-src 'self' data:`, // Block external images to prevent CSS exfiltration
    // ...
].join('; ');

2. Restrict img-src to prevent data exfiltration

javascript

// Don't allow arbitrary external URLs
`img-src 'self' data: blob: ${trustedImageHosts.join(' ')}`,

This blocks the most common CSS exfiltration technique (background images to attacker domains).

3. Use form-action directive

javascript

`form-action 'self'`,

You could open a feature request on the Meteor GitHub to add WebAppInternals.styleNonce support similar to scriptNonce. This would allow Meteor to automatically add nonces to any style tags it generates

1 Like