Back to Blog
critical SEVERITY11 min read

How origin validation bypass happens in Express.js and how to fix it

A `POST /changeData` route in `src/main/server/routes/index.js` guarded state-changing writes with an origin allowlist, but the guard was wrapped in an `if (origin && ...)` truthiness check. Any request that simply omitted both `Origin` and `Referer` — a one-line `curl` command, a local script, a background process — skipped validation entirely and modified application data. The fix removes the truthiness short-circuit so a *missing* header is now treated as a rejection, not a pass.

O
By Orbis AppSec
•Technically reviewed by Anupam Mediratta•Published September 8, 2026•Reviewed September 8, 2026

Answer Summary

This is an origin validation bypass leading to missing authentication on a state-changing endpoint (CWE-346 / CWE-306) in an Express.js route. The `POST /changeData` handler in `src/main/server/routes/index.js:54` only ran its allowlist regex when `origin` was truthy (`if (origin && !/^(file:|http:\/\/localhost...)/.test(origin))`), so requests with no `Origin` or `Referer` header fell through and were accepted. The fix drops the `origin &&` guard so an empty origin fails the regex and returns `403`; the durable solution is to authenticate the request with a per-session secret or CSRF token rather than relying on headers a non-browser client controls.

Vulnerability at a Glance

cweCWE-346 (Origin Validation Error), CWE-306 (Missing Authentication for Critical Function)
fixRemoved the `origin &&` truthiness guard so an empty origin string fails the allowlist regex and returns `403 Forbidden`
riskAny local process could POST arbitrary data to `/changeData` and mutate application state without authentication
languageJavaScript (Node.js / Express)
root causeThe allowlist check was gated behind `if (origin && ...)`, so a missing `Origin`/`Referer` header skipped validation entirely
vulnerabilityOrigin validation bypass on an unauthenticated state-changing endpoint

Summary

A POST /changeData route in src/main/server/routes/index.js guarded state-changing writes with an origin allowlist, but the guard was wrapped in an if (origin && ...) truthiness check. Any request that simply omitted both Origin and Referer — a one-line curl command, a local script, a background process — skipped validation entirely and modified application data. The fix removes the truthiness short-circuit so a missing header is now treated as a rejection, not a pass.

Introduction

The src/main/server/routes/index.js file is the HTTP surface of this application's embedded server. Most of its handlers are read-only GET endpoints — /creative-statements, for example. One handler is different: router.post("/changeData", ...) at line 54 calls changeData({ ...req.body }), spreading the caller's JSON body straight into a state-mutating function.

That handler had exactly one gate in front of it, and the gate had a hole:

router.post("/changeData", function (req, res) {
  const origin = req.headers.origin || req.headers.referer || "";
  if (origin && !/^(file:|http:\/\/localhost|http:\/\/127\.0\.0\.1)/.test(origin)) {
    return res.status(403).json({ success: false, message: "Forbidden" });
  }
  res.json(changeData({ ...req.body }));
});

Read the condition carefully. The regex test only runs if origin is truthy. The line above it deliberately defaults origin to the empty string "" when neither req.headers.origin nor req.headers.referer is present — and "" is falsy. So the entire if body is skipped, no 403 is returned, and changeData() executes.

The developer's intent was clear and reasonable: "only allow requests from our own local UI." The implementation inverted that intent for the single most likely attacker profile — a client that isn't a browser and therefore sends no origin at all.

This is a pattern worth internalizing, because it shows up anywhere a header is used as an authorization signal: a validation check that is conditional on the presence of the value it validates is not a validation check. It's an allowlist with an implicit allow * fallback.

The Vulnerability Explained

The three ways in

The allowlist regex was ^(file:|http:\/\/localhost|http:\/\/127\.0\.0\.1). Combined with the truthiness gate, that produces three distinct bypasses, all of which the PR's regression test exercises:

1. No headers at all (the primary bug).

curl -X POST http://127.0.0.1:PORT/changeData \
  -H 'Content-Type: application/json' \
  -d '{"data":"attacker-controlled"}'

curl sends no Origin and no Referer. origin becomes "", "" is falsy, the if never evaluates, and changeData() runs. This is the bypass that required zero cleverness — the default behavior of every HTTP client that isn't a browser.

2. A spoofed local origin. Even with the truthiness bug fixed, Origin is a client-controlled string on any non-browser request:

curl -X POST http://127.0.0.1:PORT/changeData \
  -H 'Origin: http://localhost' \
  -d '{"data":"attacker-controlled"}'

3. A file:// page. The regex explicitly allows file:. A malicious HTML file dropped in the user's Downloads folder and opened in a browser can fetch() this endpoint; browsers send Origin: null or a file:-scheme origin depending on version and configuration, and the file: branch was written specifically to accept that class of caller.

Why "it's localhost, so it's fine" isn't fine

The instinct behind an origin-only check on a local server is that only the app's own UI can reach 127.0.0.1. That's not true. On a desktop machine, 127.0.0.1:PORT is reachable by:

  • Any other application the user has installed, including anything sandboxed less strictly than a browser tab
  • Any npm/pip/cargo postinstall script, browser extension helper, or scheduled task
  • Any malware or adware already resident on the box
  • Any web page the user visits, for the subset of requests browsers allow cross-origin (and any page at all, if it can be loaded from file://)

None of those need to guess a token, because there was no token. The port is discoverable by scanning a handful of candidates.

Concrete impact

The handler's body is res.json(changeData({ ...req.body })). Whatever changeData persists — configuration, user records, saved documents, feature toggles, connection settings — an unauthenticated local caller could overwrite with arbitrary JSON. There is no per-field validation visible at the route layer; the object spread hands the entire request body through.

This matters more, not less, because the endpoint lives in a desktop app's embedded server. The blast radius isn't a single tenant's row in a database; it's the user's local application state, and it can be mutated silently while the user is doing something else entirely. In an app that stores credentials or tokens locally (this codebase pairs its local server with an OAuth token store), an attacker who can rewrite application data can potentially redirect the app to attacker-controlled endpoints or corrupt trust state — turning a data-integrity bug into a credential-exposure bug.

CVSS-wise, this is unauthenticated, network-adjacent (local loopback), low-complexity, with an integrity impact — comfortably critical for a client application.

The Fix

The change is a single line, and it's the right single line for the bug that was reported:

 router.post("/changeData", function (req, res) {
   const origin = req.headers.origin || req.headers.referer || "";
-  if (origin && !/^(file:|http:\/\/localhost|http:\/\/127\.0\.0\.1)/.test(origin)) {
+  // Require a matching Origin/Referer for every request; previously a
+  // missing header skipped validation entirely, letting any local process
+  // (curl, scripts, etc. that omit these headers) bypass the check.
+  if (!/^(file:|http:\/\/localhost|http:\/\/127\.0\.0\.1)/.test(origin)) {
     return res.status(403).json({ success: false, message: "Forbidden" });
   }
   res.json(changeData({ ...req.body }));
 });

What changed, precisely

Removing origin && converts the check from conditional-deny to default-deny. The || "" fallback on the line above is now load-bearing in the correct direction: when no Origin or Referer is present, origin is "", /^(file:|http:\/\/localhost|http:\/\/127\.0\.0\.1)/.test("") returns false, !false is true, and the handler returns 403 Forbidden before changeData() is ever reached.

The behavior for legitimate callers is unchanged. The app's own UI — whether loaded from file:// in a packaged desktop shell or from http://localhost in development — still sends a matching origin and still gets through. The only requests whose outcome changed are the ones that sent nothing, and those were never legitimate.

The regression test that locks it in

The PR ships a supertest test that enumerates every bypass shape as a table, so the truthiness bug cannot silently return:

const payloads = [
  { origin: "file:///malicious.html", desc: "file:// origin bypass" },
  { origin: "http://localhost:3000", desc: "localhost origin bypass" },
  { origin: "http://127.0.0.1:8080", desc: "127.0.0.1 origin bypass" },
  { origin: undefined,               desc: "missing origin header" },
  { origin: "https://attacker.com",  desc: "external origin (should reject)" }
];

test.each(payloads)("rejects adversarial input: %s", async (payload) => {
  const headers = payload.origin !== undefined ? { Origin: payload.origin } : {};
  const res = await request(app).post("/changeData").set(headers).send({ data: "malicious" });
  expect(res.status).not.toBe(200);
  expect([401, 403]).toContain(res.status);
});

The { origin: undefined } case is the one that failed before this patch and passes after it. The security invariant the test encodes — "protected endpoints reject unauthenticated requests" — is broader than the current implementation, which is deliberate: it will keep passing when the team upgrades the guard to real authentication.

Why this is a fix, not the finish line

Be clear-eyed about what a one-line change buys. It closes the header-omission bypass, which was the actually-exploited hole. It does not make /changeData authenticated, because Origin remains attacker-controllable from any non-browser client (bypass #2 above). Origin checks are a CSRF mitigation, not an authentication mechanism.

The durable fix for a local server like this one is a shared secret the UI knows and other local processes don't:

// At server startup, mint a per-launch secret and inject it into the UI
// (via the preload script / window init, never via a file or the URL bar).
const SESSION_TOKEN = require("crypto").randomBytes(32).toString("hex");

const requireSessionToken = (req, res, next) => {
  const presented = req.get("X-Session-Token") || "";
  const expected = SESSION_TOKEN;
  const a = Buffer.from(presented);
  const b = Buffer.from(expected);
  // Constant-time compare; length check first since timingSafeEqual throws on mismatch.
  if (a.length !== b.length || !require("crypto").timingSafeEqual(a, b)) {
    return res.status(401).json({ success: false, message: "Unauthorized" });
  }
  next();
};

// Keep the origin check as defense-in-depth, then add real auth.
router.post("/changeData", requireSessionToken, function (req, res) {
  const origin = req.headers.origin || req.headers.referer || "";
  if (!/^(file:|http:\/\/localhost|http:\/\/127\.0\.0\.1)/.test(origin)) {
    return res.status(403).json({ success: false, message: "Forbidden" });
  }
  res.json(changeData(validateChangePayload(req.body)));
});

Two further hardening steps worth queuing behind this: bind the server to 127.0.0.1 explicitly (never 0.0.0.0), and replace { ...req.body } with an explicit schema validation (zod, ajv, joi) so changeData receives only the fields it expects rather than whatever the caller spread in.

Key Takeaways

  • if (origin && !allowlist.test(origin)) in src/main/server/routes/index.js:54 meant "no header = authorized." The || "" default on the previous line guaranteed a falsy value for exactly the clients most likely to be hostile.
  • POST /changeData spread { ...req.body } directly into changeData() with no schema validation, so a single unauthenticated request could overwrite arbitrary application state.
  • The allowlist regex intentionally permitted file:, which means a malicious HTML file on disk was inside the trust boundary by design — a strong signal that header-based checks were never sufficient here.
  • Removing origin && flips conditional-deny to default-deny and costs nothing for legitimate file:// and localhost callers, whose behavior is unchanged.
  • This route still needs real authentication. Origin is spoofable by any local process; the next step is a per-launch X-Session-Token compared with crypto.timingSafeEqual, applied as router-level middleware.
  • The { origin: undefined } row in the new supertest table is the regression test that matters — it's the only case that failed before the patch.

How Orbis AppSec Detected This

  • Source: The Origin and Referer HTTP request headers, read as req.headers.origin || req.headers.referer || "" in src/main/server/routes/index.js:54, plus the fully attacker-controlled JSON body req.body.
  • Sink: changeData({ ...req.body }) in the router.post("/changeData", ...) handler — a state-mutating call reachable on the embedded HTTP server with no credential of any kind.
  • Missing control: No authentication on a state-changing endpoint, and the sole authorization check was short-circuited by the origin && truthiness guard, so requests omitting both headers bypassed validation entirely. No schema validation on the spread request body either.
  • CWE: CWE-346 (Origin Validation Error), with CWE-306 (Missing Authentication for Critical Function) and CWE-352 (Cross-Site Request Forgery) as co-occurring weaknesses.
  • Fix: Removed the origin && truthiness guard so an empty origin string fails the allowlist regex and the handler returns 403 Forbidden before changeData() executes.

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.

Conclusion

The scariest security bugs are rarely exotic. This one was two characters — origin && — sitting in front of a check that otherwise did what its author intended. Those two characters turned an allowlist into an allow-all for every client that doesn't send browser headers, which is to say every client an attacker would actually use.

The lesson generalizes past this file: absence of a signal must never mean presence of authorization. Write your guards so the empty, null, undefined, and missing cases all land in the deny branch, and then test that they do. The { origin: undefined } case in the new regression suite is worth more than the comment explaining the fix, because it will still be enforcing the invariant long after everyone has forgotten why it was added.

And when you find yourself authorizing on Origin alone, take the extra hour to mint a session token. Headers describe where a request claims to come from; only a secret proves who sent it.

Prevention and further reading

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #14

Related Articles

high

CVE-2026-104850: MCP TypeScript SDK OAuth Credential Leak

The MCP TypeScript SDK (`@modelcontextprotocol/sdk`) contained a flaw in its OAuth client flow where the MCP server being connected to could influence which authorization server the client talked to, meaning client credentials and token-exchange traffic could be directed to an endpoint the attacker controls. This project was pinned to the affected `1.30.0`; the dependency has been moved to `^1.31.0`, which carries the upstream fix for CVE-2026-104850. Any MCP client that performs OAuth against t

high

SQLite Auth Database Plaintext Storage in InitAuthDB()

The `InitAuthDB()` function opened SQLite databases without restricting filesystem permissions, leaving bcrypt password hashes, session tokens, and user settings exposed to any local account with read access. The fix explicitly sets `os.Chmod(filepath, 0600)` immediately after database creation, ensuring only the owner can access authentication secrets.

high

undici 8.10.0 Cache Poisoning: CVE-2026-85152 Auth Bypass

undici, the HTTP client used by Node.js's `fetch()` implementation, shipped a caching layer that did not properly isolate cached responses by origin, letting a response poisoned on one origin be served to requests for another. This created a path to cross-origin authentication bypass, tracked as CVE-2026-85152 and fixed in undici 8.10.2.

critical

Hybridauth Telegram OAuth Timing Attack in `authenticateCheckError()`

The Telegram OAuth provider in Hybridauth relied on `strcmp()` to validate HMAC-SHA256 signatures, exposing a timing side-channel that could allow attackers to forge authentication tokens character by character. The fix replaces this with PHP's timing-safe `hash_equals()` function, eliminating the information leak.

critical

Node.js Auth Query SQL Injection via Group Code Interpolation

A sign-in authorization check built its SQL query by mapping an `authorizedGroups` array into quoted string literals and joining them directly into a template literal, creating a classic SQL injection point in a critical authentication path. The fix replaces every interpolated value — including the previously "typed" parameter — with `?` placeholders bound through a value-builder helper, closing off the injection vector entirely.

high

PHP session_start() Missing Secure Cookie Flags in lasturl.php

The `session_start()` call in the last URL tracking component failed to set secure session cookie parameters, allowing session hijacking via man-in-the-middle attacks on HTTP connections or XSS exploitation. The fix configures `session_set_cookie_params()` with `httponly`, `secure`, and `samesite` attributes before starting the session.