←Research
Researchvulnerability8 min read

Koodo Reader PR #1598 replaced wildcard CORS with an ALLOWED_ORIGINS allowlist

Koodo Reader's optional HTTP server advertised Access-Control-Allow-Origin: * with credentials enabled. PR #1598 removes the wildcard, rejects untrusted cross-origin requests and adds an ALLOWED_ORIGINS allowlist.

Koodo Reader PR #1598 replaced wildcard CORS with an ALLOWED_ORIGINS allowlist

Koodo Reader's optional HTTP server used a wildcard CORS policy while also advertising credential support. In httpServer.js, the request handler set Access-Control-Allow-Origin: * and Access-Control-Allow-Credentials: true for every request. That is the shape of CWE-942: a permissive cross-domain policy that trusts untrusted domains.

I submitted PR #1598 to replace the wildcard with an explicit allowlist. The fix was applied on 3 May 2026.

The vulnerable handler

The relevant code path was the single http.createServer request handler in httpServer.js. Before the fix, the handler began by setting CORS headers unconditionally:

res.setHeader("Access-Control-Allow-Origin", "*");
res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
res.setHeader("Access-Control-Allow-Credentials", "true");

That did two things at once. First, it told browsers that any web origin was allowed to read responses from the server. Second, it claimed that credentialed cross-origin requests were supported.

Modern browsers do not allow the wildcard origin to be used with credentialed CORS reads. MDN documents this directly: when the credentials flag is true, Access-Control-Allow-Origin cannot be *. That browser behaviour matters because it reduces the practical exploitability of this exact configuration today. It does not make the server-side policy correct.

The server was still declaring a globally permissive cross-origin policy. Any unauthenticated response body would be readable from any website. Any future endpoint added without authentication would inherit the same exposure. Non-browser clients would not necessarily enforce the browser's credential rules. A security property that depends on clients rejecting a malformed header pair is weaker than a server that refuses untrusted origins in the first place.

What made the impact bounded

This was a medium-severity hardening issue, not a catastrophic remote compromise.

The preconditions mattered:

  • the HTTP server is optional and requires ENABLE_HTTP_SERVER=true
  • the current endpoint set is protected by HTTP Basic authentication
  • the victim's browser must be able to reach the server
  • credentialed browser reads are blocked in current browsers because * and Allow-Credentials: true are not a valid combination

Those constraints keep the immediate blast radius limited. An attacker-controlled page would not get an authenticated response body from a current browser simply because the browser refuses to expose a credentialed response under that wildcard policy.

The remaining problem is fragility. The old handler made the safe outcome depend on everything around it staying exactly as it was: no unauthenticated endpoint, no different client behaviour, no browser quirk, no future refactor that changes how credentials are sent. Security-sensitive defaults should not be that brittle.

This same pattern has shown up elsewhere. CVE-2024-25124, disclosed in Fiber's CORS middleware and tracked as GHSA-fmg4-x8pw-hjhg, involved insecure CORS configuration that could expose applications to cross-origin risks. The older CVE-2017-13717 in Starry Station also centred on Access-Control-Allow-Origin: * on a device web server. The details differ, but the underlying mistake is the same: treating cross-origin policy as a convenience header rather than an access-control decision.

The fix in PR #1598

The fix adds a new environment variable, ALLOWED_ORIGINS, parsed as a comma-separated allowlist:

const ALLOWED_ORIGINS = (process.env.ALLOWED_ORIGINS || "")
  .split(",")
  .map((o) => o.trim())
  .filter((o) => o.length > 0);

An empty allowlist is the safe default. Same-origin requests continue to work, but cross-origin browser access is denied unless the operator opts in. At startup, the server logs a warning when no origins are configured so users who rely on cross-origin access get a visible signal:

if (ALLOWED_ORIGINS.length === 0) {
  console.warn(
    "Warning: No ALLOWED_ORIGINS configured. Cross-origin requests will be denied. " +
      "Set ALLOWED_ORIGINS to a comma-separated list of trusted origins if needed."
  );
}

The new applyCorsHeaders(req, res) helper is deliberately narrow:

function applyCorsHeaders(req, res) {
  const origin = req.headers["origin"];
  res.setHeader("Vary", "Origin");
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, OPTIONS");
  res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization");
 
  if (origin && ALLOWED_ORIGINS.includes(origin)) {
    res.setHeader("Access-Control-Allow-Origin", origin);
    res.setHeader("Access-Control-Allow-Credentials", "true");
    return true;
  }
  return false;
}

If the request origin is allow-listed, the server echoes that exact origin and sends Access-Control-Allow-Credentials: true. If not, it does not send Access-Control-Allow-Origin at all. The wildcard is gone. The credential header is sent only for trusted origins. Vary: Origin is set so shared caches do not reuse a response authorised for one origin across another.

For preflight requests, disallowed cross-origin callers get an explicit 403 Origin not allowed:

if (req.method === "OPTIONS") {
  if (isCrossOrigin && !corsAllowed) {
    res.writeHead(403, { "Content-Type": "text/plain" });
    return res.end("Origin not allowed");
  }
  res.writeHead(204);
  return res.end();
}

The same check applies to actual requests before authentication runs:

if (isCrossOrigin && !corsAllowed) {
  res.writeHead(403, { "Content-Type": "text/plain" });
  return res.end("Origin not allowed");
}

That ordering is important. The server no longer waits for the browser to interpret CORS headers and decide what to expose. It rejects untrusted cross-origin requests itself.

The edge case in the review

The first version of the fix could have broken legitimate same-origin writes. The mistake was subtle: treating every request with an Origin header as cross-origin.

Browsers can send an Origin header on same-origin non-GET requests, including POST, DELETE and fetch() calls with credentials. Koodo Reader's upload and delete flows use methods that can trigger that behaviour. If the server rejected any request with an Origin value not present in ALLOWED_ORIGINS, same-origin app traffic could fail when the allowlist was empty.

The final patch adds getServerOrigin(req) to distinguish same-origin from cross-origin requests:

function getServerOrigin(req) {
  const host = req.headers["host"];
  if (!host) return null;
  const scheme =
    req.socket && req.socket.encrypted
      ? "https"
      : (req.headers["x-forwarded-proto"] || "http").split(",")[0].trim();
  return `${scheme}://${host}`;
}

The handler then compares the request's Origin with the effective server origin:

const origin = req.headers["origin"];
const serverOrigin = getServerOrigin(req);
const isCrossOrigin = !!origin && origin !== serverOrigin;
const corsAllowed = applyCorsHeaders(req, res);

This is the difference between a security fix and a regression disguised as one. Same-origin requests, including same-origin requests that carry an Origin header, keep flowing through the normal authentication path. Only requests whose Origin differs from the server's own origin need CORS authorisation.

CodeRabbit later raised a further proxy-normalisation concern around X-Forwarded-Host and host casing. That is a useful operational edge case for reverse-proxied deployments. The applied fix still removed the CWE-942 condition: the wildcard policy was eliminated and untrusted cross-origin requests no longer receive a credentialed allow-all response.

Why this belongs with the localhost daemon pattern

Koodo Reader is not an AI agent framework, but this bug sits in the same architectural family as several AI and LLM tool vulnerabilities I have been fixing: local or optional HTTP services that need to be reachable from a browser context, then make their cross-origin policy too broad.

In Summarize's localhost daemon, the server reflected arbitrary origins and sent credential-friendly CORS headers. In AIPex, the browser automation daemon accepted WebSocket upgrades from any website. In both cases, the service existed to bridge a browser-facing interface to local capability. Koodo Reader's optional HTTP server has a smaller capability set and stronger authentication, but the design pressure is familiar: make the browser integration work, then discover that the browser's origin model was part of the security boundary all along.

This matters more as AI tools normalise local control planes. MCP servers, browser-extension bridges, local RAG services and desktop sync daemons increasingly expose HTTP or WebSocket endpoints on a user's machine. Developers often reason about those endpoints as local, optional or authenticated. Attackers reason about them as reachable from any tab the user opens unless the server says otherwise.

CORS is not decoration. It is the server's statement about which web origins may read responses and, when credentials are involved, which origins may ask the browser to attach ambient authority. A wildcard is rarely the right statement for a service that has authentication, private data or state-changing methods.

What operators should change

For Koodo Reader users who only access the HTTP server from the same origin, the safe default requires no configuration. Leave ALLOWED_ORIGINS empty and cross-origin browser requests will be denied.

For deployments that intentionally need cross-origin access, configure exact trusted origins:

ALLOWED_ORIGINS="https://reader.example.com,https://app.example.com"

Do not use patterns. Do not include origins because they are convenient during testing and then forget they exist. The fix uses exact string matching, which is intentionally boring. Boring allowlists are easier to reason about than permissive pattern matching that later turns into *.example.com.evil.test.

The useful lesson from PR #1598 is not that every wildcard CORS header is instantly exploitable. This one was constrained by opt-in server exposure, Basic auth and browser enforcement. The lesson is that a server should not need luck from the client to keep a cross-origin policy safe. The browser can enforce the same-origin policy, but only the server knows which origins it meant to trust.

Newsletter

One email a week. Security research, engineering deep-dives and AI security insights - written for practitioners. No noise.