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.
Prevention & Best Practices
Never gate a validator on the presence of the thing it validates. The anti-pattern is mechanical and greppable:
if (value && !isAllowed(value)) return deny(); // ❌ missing value == allowed
if (!isAllowed(value)) return deny(); // ✅ missing value == denied
Anywhere you see if (header && ...), if (token && ...), or if (signature && ...) guarding a deny branch, you have an implicit bypass. Ask: what happens when this is absent? If the answer is "we allow it," that's the bug.
Default-deny at the router, not per-handler. Inline checks copy-pasted into each route drift and get forgotten. Mount authentication as middleware over the mutating surface:
router.use(["/changeData", "/updateSettings", "/writeFile"], requireSessionToken);
Even better, deny by default and allowlist the public reads, so a newly added route is protected unless someone explicitly opts out.
Treat Origin and Referer as hints, never credentials. Both are enforced only by browsers. They're useful for CSRF defense-in-depth on top of a cookie or token; they are worthless against curl, a native process, or anything with a raw socket. If your threat model includes local malware — and for a desktop app it must — headers prove nothing.
Prefer strict comparison to prefix regexes. ^http:\/\/localhost matches http://localhost.attacker.com in some parsing contexts and any port. Compare against an exact allowlist of full origin strings, or parse with new URL(origin) and check protocol/hostname/port individually.
Detection tooling. ESLint with eslint-plugin-security, Semgrep rules for Express routes reaching req.headers.origin without auth middleware, and a table-driven test per state-changing endpoint (exactly like the one this PR added) covering: no headers, spoofed allowed origin, external origin. If your test suite doesn't include a "sent nothing at all" case, you won't catch this class of bug.
Standards mapping. This is OWASP API Security Top 10 API5:2023 – Broken Function Level Authorization and OWASP Top 10 A01:2021 – Broken Access Control, mapping to CWE-346, CWE-306, and CWE-352.
Key Takeaways
if (origin && !allowlist.test(origin))insrc/main/server/routes/index.js:54meant "no header = authorized." The|| ""default on the previous line guaranteed a falsy value for exactly the clients most likely to be hostile.POST /changeDataspread{ ...req.body }directly intochangeData()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 legitimatefile://andlocalhostcallers, whose behavior is unchanged. - This route still needs real authentication.
Originis spoofable by any local process; the next step is a per-launchX-Session-Tokencompared withcrypto.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
OriginandRefererHTTP request headers, read asreq.headers.origin || req.headers.referer || ""insrc/main/server/routes/index.js:54, plus the fully attacker-controlled JSON bodyreq.body. - Sink:
changeData({ ...req.body })in therouter.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 returns403 ForbiddenbeforechangeData()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.
References
- CWE-346: Origin Validation Error
- CWE-306: Missing Authentication for Critical Function
- CWE-352: Cross-Site Request Forgery (CSRF)
- OWASP Cross-Site Request Forgery Prevention Cheat Sheet
- OWASP Authorization Cheat Sheet
- OWASP API Security Top 10 — API5:2023 Broken Function Level Authorization
- Express: Writing middleware
- [Node.js
crypto.timingSafeEqual()](https://nodejs