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: