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. ![]()