Add allowedFramingOrigins config for CSP frame-ancestors whitelist (#7599)

- Add settings.allowedFramingOrigins and domain-level override - Implement Content-Security-Policy frame-ancestors with domain whitelist, always includes self if set - Adjust X-Frame-Options when external origins are allowed - Enable iframe feature flag when allowedFramingOrigins is set - Support array or comma-separated string config Enables secure embedding in parent portals (e.g. TacticalRMM) while blocking clickjacking from arbitrary sites. Replaces need for allowFraming: true when only specific origins should frame the site Co-authored-by: Matthew Graham <matthew.graham@aspiritech.org>

JonBons committed Feb 20, 2026 at 11:11 UTC 4a4575c0fd120566a434f406a0d4de96cb7b710f
4 files changed +57 -5
.vscode/settings.json
+1
@@ -53,6 +53,7 @@
53 "allevents",
54 "allowaccountreset",
55 "allowframing",
56 + "allowedframingorigins",
57 "allowfullscreen",
58 "allowhighqualitydesktop",
59 "allowsavingdevicecredentials",
meshcentral-config-schema.json
+22
@@ -454,6 +454,17 @@
454 "default": false,
455 "description": "When enabled, the MeshCentral web site can be embedded within another website's iframe."
456 },
457 + "allowedFramingOrigins": {
458 + "type": [
459 + "array",
460 + "string"
461 + ],
462 + "items": {
463 + "type": "string"
464 + },
465 + "default": null,
466 + "description": "List of origins allowed to embed MeshCentral in an iframe. Uses Content-Security-Policy frame-ancestors. 'self' is always included. Example: [\"https://rmm.example.com\"]. Comma-separated string also supported."
467 + },
468 "cookieIpCheck": {
469 "type": [
470 "string",
@@ -2923,6 +2934,17 @@
2934 "secret"
2935 ]
2936 },
2937 + "allowedFramingOrigins": {
2938 + "type": [
2939 + "array",
2940 + "string"
2941 + ],
2942 + "items": {
2943 + "type": "string"
2944 + },
2945 + "default": null,
2946 + "description": "Per-domain override. List of origins allowed to embed this domain in an iframe. Uses Content-Security-Policy frame-ancestors. Overrides settings.allowedFramingOrigins when set."
2947 + },
2948 "httpHeaders": {
2949 "type": "object",
2950 "additionalProperties": {
sample-config-advanced.json
+1
@@ -50,6 +50,7 @@
50 "_allowLoginToken": true,
51 "_StrictTransportSecurity": true,
52 "_allowFraming": true,
53 + "_allowedFramingOrigins": ["https://rmm.example.com"],
54 "_cookieIpCheck": false,
55 "_cookieEncoding": "hex",
56 "_webRTC": false,
webserver.js
+33 -5
@@ -842,6 +842,19 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
842 return parent.config.domains[''];
843 }
844
845 + function parseAllowedFramingOrigins(val) {
846 + if (val == null) return [];
847 + var arr = [];
848 + if (Array.isArray(val)) { arr = val.slice(); } else if (typeof val == 'string') { arr = val.split(',').map(function (s) { return s.trim(); }).filter(function (s) { return s.length > 0; }); } else { return []; }
849 + var out = [];
850 + for (var i = 0; i < arr.length; i++) {
851 + var o = arr[i].trim().replace(/\/+$/, '');
852 + if (o.length === 0) continue;
853 + if (o.indexOf('https://') === 0 || o.indexOf('http://') === 0) { out.push(o); }
854 + }
855 + return out;
856 + }
857 +
858 function handleLogoutRequest(req, res) {
859 const domain = checkUserIpAddress(req, res);
860 if (domain == null) { return; }
@@ -3330,7 +3343,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
3343 if (obj.args.nousers == true) { features += 0x00000004; } // Single user mode
3344 if (domain.userQuota == -1) { features += 0x00000008; } // No server files mode
3345 if (obj.args.mpstlsoffload) { features += 0x00000010; } // No mutual-auth CIRA
3333 - if ((parent.config.settings.allowframing != null) || (domain.allowframing != null)) { features += 0x00000020; } // Allow site within iframe
3346 + if ((parent.config.settings.allowframing != null) || (domain.allowframing != null) || (parent.config.settings.allowedframingorigins != null) || (domain.allowedframingorigins != null)) { features += 0x00000020; } // Allow site within iframe
3347 if ((domain.mailserver != null) && (obj.parent.certificates.CommonName != null) && (obj.parent.certificates.CommonName.indexOf('.') != -1) && (obj.args.lanonly != true)) { features += 0x00000040; } // Email invites
3348 if (obj.args.webrtc == true) { features += 0x00000080; } // Enable WebRTC (Default false for now)
3349 // 0x00000100 --> This feature flag is free for future use.
@@ -3405,7 +3418,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
3418 function handleRootRequestLogin(req, res, domain, hardwareKeyChallenge, passRequirements) {
3419 parent.debug('web', 'handleRootRequestLogin()');
3420 var features = 0;
3408 - if ((parent.config != null) && (parent.config.settings != null) && ((parent.config.settings.allowframing == true) || (typeof parent.config.settings.allowframing == 'string'))) { features += 32; } // Allow site within iframe
3421 + if ((parent.config != null) && (parent.config.settings != null) && ((parent.config.settings.allowframing == true) || (typeof parent.config.settings.allowframing == 'string') || (parent.config.settings.allowedframingorigins != null) || (domain != null && domain.allowedframingorigins != null))) { features += 32; } // Allow site within iframe
3422 if (domain.usernameisemail) { features += 0x00200000; } // Username is email address
3423 var httpsPort = ((obj.args.aliasport == null) ? obj.args.port : obj.args.aliasport); // Use HTTPS alias port is specified
3424 var loginmode = 0;
@@ -6858,15 +6871,30 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
6871 }
6872 }
6873
6874 + // allowedFramingOrigins: domain override, else settings
6875 + var allowedFramingOriginsVal = (domain != null && domain.allowedframingorigins != null) ? domain.allowedframingorigins : parent.config.settings.allowedframingorigins;
6876 + var framingOrigins = parseAllowedFramingOrigins(allowedFramingOriginsVal);
6877 + var hasAllowedFramingOrigins = (domain != null && domain.allowedframingorigins != null) || (parent.config.settings.allowedframingorigins != null);
6878 +
6879 +
6880 // Finish setup security headers
6881 + var cspBase = "default-src 'none'; font-src 'self' fonts.gstatic.com data:; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' " + extraScriptSrc + "; connect-src 'self'" + geourl + selfurl + "; img-src 'self' blob: data:" + geourl + extraImgSrc + " data:; style-src 'self' 'unsafe-inline' fonts.googleapis.com; frame-src 'self' blob: mcrouter:" + extraFrameSrc + "; media-src 'self'; form-action 'self' " + duoSrc + "; manifest-src 'self'";
6882 + if (hasAllowedFramingOrigins) {
6883 + var frameAncestors = "'self'" + (framingOrigins.length > 0 ? ' ' + framingOrigins.join(' ') : '');
6884 + cspBase += "; frame-ancestors " + frameAncestors;
6885 + }
6886 const headers = {
6887 'Referrer-Policy': 'no-referrer',
6888 'X-XSS-Protection': '1; mode=block',
6889 'X-Content-Type-Options': 'nosniff',
6866 - 'Content-Security-Policy': "default-src 'none'; font-src 'self' fonts.gstatic.com data:; script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval' " + extraScriptSrc + "; connect-src 'self'" + geourl + selfurl + "; img-src 'self' blob: data:" + geourl + extraImgSrc + " data:; style-src 'self' 'unsafe-inline' fonts.googleapis.com; frame-src 'self' blob: mcrouter:" + extraFrameSrc + "; media-src 'self'; form-action 'self' " + duoSrc + "; manifest-src 'self'"
6890 + 'Content-Security-Policy': cspBase
6891 };
6892 if (req.headers['user-agent'] && (req.headers['user-agent'].indexOf('Chrome') >= 0)) { headers['Permissions-Policy'] = 'interest-cohort=()'; } // Remove Google's FLoC Network, only send this if Chrome browser
6869 - if ((parent.config.settings.allowframing !== true) && (typeof parent.config.settings.allowframing !== 'string')) { headers['X-Frame-Options'] = 'sameorigin'; }
6893 + if (hasAllowedFramingOrigins) {
6894 + if (framingOrigins.length === 0) { headers['X-Frame-Options'] = 'sameorigin'; }
6895 + } else if ((parent.config.settings.allowframing !== true) && (typeof parent.config.settings.allowframing !== 'string')) {
6896 + headers['X-Frame-Options'] = 'sameorigin';
6897 + }
6898 if ((parent.config.settings.stricttransportsecurity === true) || ((parent.config.settings.stricttransportsecurity !== false) && (obj.isTrustedCert(domain)))) { if (typeof parent.config.settings.stricttransportsecurity == 'string') { headers['Strict-Transport-Security'] = parent.config.settings.stricttransportsecurity; } else { headers['Strict-Transport-Security'] = 'max-age=63072000'; } }
6899
6900 // If this domain has configured headers, add them. If a header is set to null, remove it.
@@ -8093,7 +8121,7 @@ module.exports.CreateWebServer = function (parent, db, args, certificates, doneF
8121 strategy.options = Object.assign(strategy.options, { 'client': client, sessionKey: 'oidc-' + domain.id });
8122 strategy.client = client.metadata
8123 strategy.obj.client = client
8096 -
8124 +
8125 // Validate OIDC Icon Url once and null it if it fails validation
8126 if (obj.common.validateObject(strategy.custom) && obj.common.validateString(strategy.custom.buttoniconurl)) {
8127 if (obj.common.validateUrl(strategy.custom.buttoniconurl)){