Safeguard
Vulnerability Analysis•

Qwik CVE-2026-84994: One Request, One Thenable, and a Node Server That Exits

Qwik CVE-2026-84994 let one request crash a Node server through a deserialized thenable. The fix, a lab, and a Qwik 2 version-string trap for scanners.

Safeguard Security Team
Security Research
12 min read

A Qwik server in its default configuration could be taken down with one unauthenticated HTTP request (CVE-2026-84994). The request body carried a value whose then property pointed at a function the server couldn't load. Qwik's deserializer passed that value to Promise.resolve, the promise rejected with nothing listening, and Node did what it has done by default since version 15: it ended the process. Send the request in a loop and every replica stays down.

The fix shipped in @builder.io/qwik 1.20.0 on 22 May, four months before the advisory. This teardown is for teams running Qwik on Node, and for anyone writing a deserializer or server in JavaScript. It covers how the crash works, the fix, why a "thenable" is a dangerous thing to rebuild from untrusted input, a small lab that reproduces the class, a version-string problem that makes scanners flag fixed Qwik 2 betas, and a checker for your project.

The short answer

CVE-2026-84994 lets one unauthenticated request crash a Qwik server: while parsing a request body in Qwik's serialization format, the server could rebuild an object whose then is a lazily loaded function, pass it to Promise.resolve, and leave the resulting rejection unhandled, which makes Node exit. Upgrade @builder.io/qwik to 1.20.0 or later. For Qwik 2, the advisory lists the fix as 2.0.0-beta37, but npm spells its versions 2.0.0-beta.N, so scanners flag every beta, including later ones. Moving to a 2.0.0 release candidate clears the finding. Separately, decide what your Node servers should do on an unhandled rejection, because the default is to exit.

How one request ends the process

Qwik resumes applications on the client by serializing state, including references to code. A reference to a function that hasn't been loaded yet is a QRL: a pointer to a module and a symbol, resolved on first call. Qwik City's server functions read request bodies in the same format, which Qwik calls qwik-json.

According to the advisory, the crash path runs through the serializer for resources, Qwik's async values. When it rebuilds a resource in the resolved state, it calls Promise.resolve(resource._resolved). If the attacker makes _resolved a plain object with a then property that's a QRL for a symbol that isn't registered, three things happen:

  1. Promise.resolve sees an object with a callable then, so it treats it as a promise and calls then to adopt its result.
  2. The QRL tries to load the missing symbol and rejects.
  3. Nothing ever awaits or catches the new promise, so the rejection is unhandled.

Node's default for an unhandled rejection is throw: raise it as an uncaught exception unless an unhandledRejection handler is registered. An uncaught exception ends the process. In our lab, the request that triggers it even gets a normal 200 response first, because the rejection lands on a later tick. The process dies afterwards.

CVECVE-2026-84994 (GHSA-x586-xvc8-g7q6)
SeverityHigh, CVSS 4.0 score 8.7 (network, no privileges, availability)
Affected@builder.io/qwik before 1.20.0; @qwik.dev/core before "2.0.0-beta37"
Fixed1.20.0, published 22 May 2026
AdvisoryRepository advisory 22 September; GitHub's database 15 October
Reported bysimonkoeck

The fix

The patch (commit c1e7444) adds an isThenable check before the value reaches Promise.resolve. If the deserialized value has a callable then, or a then getter that throws, the resource is put into the rejected state with a promise that already has a catch attached, so its rejection can't escape:

const isThenable = (value: unknown) => {
  if ((typeof value !== 'object' && typeof value !== 'function') || value === null) {
    return false;
  }
  try {
    return typeof (value as PromiseLike<unknown>).then === 'function';
  } catch {
    return true;
  }
};

The same pull request makes Qwik City catch errors while it builds the request event, and adds a getRequestEvent() helper so crash logs can name the request. The Node starter templates (Express, Fastify, Node server and Cloud Run) now register an uncaughtExceptionMonitor listener for that logging. Their own comment is candid that this "does not prevent Node from crashing". A monitor listener observes the exception; it doesn't stop the exit.

Why thenables are dangerous to rebuild

Promise.resolve(x), await x and returning x from an async function all check whether x has a callable then, and if it does, they call it. Most deserializers are safe by accident: JSON.parse can produce an object with a then key, but its value is a string or an object, never a function, so it's ignored.

The risk appears when a format can produce functions or objects with behavior, such as lazy code references, class instances or proxies. Then any value that reaches an await or Promise.resolve is code the sender chose to run. In Qwik's case the "code" was a missing symbol, so the result was a crash rather than execution. The general rule is the one the fix applies: check for thenables before adopting a value from untrusted input, and attach a handler to every promise you create but don't immediately await.

A lab that reproduces the class

This toy server has a wire format with lazy function references, like QRLs, and rebuilds a resolved value with Promise.resolve without awaiting it. It isn't Qwik code and doesn't target Qwik. It runs in three modes: as written, with the isThenable guard, and with a process-level unhandledRejection hook:

// A toy server that shows the bug class behind CVE-2026-84994. It is not Qwik code.
// The wire format can describe lazy function references, like Qwik's QRLs:
// {"$fn": "name"} decodes to a function that loads a registered handler on first call.
import http from "node:http";

const mode = process.argv[2] ?? "buggy"; // buggy | guarded | hook
const registry = { greet: async () => "hello" };

const lazyFn = (name) => async (...args) => {
  const fn = registry[name];
  if (!fn) throw new Error(`unknown symbol ${name}`);
  return fn(...args);
};

const decode = (v) =>
  Array.isArray(v) ? v.map(decode)
  : v && typeof v === "object"
    ? ("$fn" in v ? lazyFn(v.$fn) : Object.fromEntries(Object.entries(v).map(([k, x]) => [k, decode(x)])))
    : v;

const isThenable = (v) => {
  if ((typeof v !== "object" && typeof v !== "function") || v === null) return false;
  try { return typeof v.then === "function"; } catch { return true; }
};

// Rehydrate a "resolved resource": the promise is created now and awaited later, if ever.
function rehydrate(resource) {
  const value = decode(resource.value);
  if (mode === "guarded" && isThenable(value)) {
    const p = Promise.reject(new Error("invalid deserialized value"));
    p.catch(() => {}); // handled, so it can't take the process down
    return { state: "rejected", promise: p };
  }
  return { state: "resolved", promise: Promise.resolve(value) }; // adopts value.then
}

if (mode === "hook") {
  process.on("unhandledRejection", (reason) => console.error("[hook] unhandled rejection:", reason.message));
}

http.createServer((req, res) => {
  let body = "";
  req.on("data", (c) => (body += c));
  req.on("end", () => {
    try {
      const r = rehydrate(JSON.parse(body || "{}"));
      res.end(JSON.stringify({ ok: true, state: r.state }));
    } catch (e) {
      res.statusCode = 400;
      res.end(JSON.stringify({ ok: false, error: e.message }));
    }
  });
}).listen(18090, "127.0.0.1", () => console.error(`listening (${mode})`));
#!/bin/sh
# Start the toy server in each mode, send a normal request, one crafted request, then a normal one.
URL=http://127.0.0.1:18090/
for mode in buggy guarded hook; do
  node thenable_lab.mjs "$mode" 2>"server-$mode.log" & pid=$!
  sleep 0.5
  a=$(curl -s -d '{"value":{"name":"ok"}}' $URL)
  b=$(curl -s -d '{"value":{"then":{"$fn":"missing"}}}' $URL)
  sleep 0.3
  c=$(curl -s -m 2 -d '{"value":{"name":"ok"}}' $URL || true)
  if kill -0 $pid 2>/dev/null; then alive=yes; kill $pid; else alive=no; fi
  wait $pid 2>/dev/null; code=$?
  echo "$mode: normal=${a:-none} crafted=${b:-none} after=${c:-none} alive=$alive"
  [ $alive = no ] && echo "  exit code $code: $(grep -m1 -o 'Error: unknown symbol [a-z]*' server-$mode.log)"
done

On Node 26.7.0:

$ ./run.sh
buggy: normal={"ok":true,"state":"resolved"} crafted={"ok":true,"state":"resolved"} after=none alive=no
  exit code 1: Error: unknown symbol missing
guarded: normal={"ok":true,"state":"resolved"} crafted={"ok":true,"state":"rejected"} after={"ok":true,"state":"resolved"} alive=yes
hook: normal={"ok":true,"state":"resolved"} crafted={"ok":true,"state":"resolved"} after={"ok":true,"state":"resolved"} alive=yes

The guard is the real fix: the bad value never becomes a live promise. The hook keeps the process alive too, but it's a blunt tool. It catches every unhandled rejection in the process, including real bugs, and leaves the application to decide whether its state is still sound. Many teams prefer to crash and let the orchestrator restart, which is reasonable until a single request can trigger the crash at will. If you rely on restarts, make sure the request that kills a pod can't be replayed against every pod faster than they come back.

The Qwik 2 version string problem

The advisory's affected range for @qwik.dev/core is "before 2.0.0-beta37". The packages on npm are named 2.0.0-beta.36, 2.0.0-beta.37 and so on, with a dot. Semantic versioning compares prerelease identifiers piece by piece, and beta sorts before beta37, so every 2.0.0-beta.N falls inside the affected range, however high N is. We checked: npm's own semver library says 2.0.0-beta.47 is lower than 2.0.0-beta37, OSV returns the advisory for 2.0.0-beta.47, and npm audit on a lockfile with that version reports it.

We couldn't confirm from the published builds which Qwik 2 beta contains the fix, since version 2 rewrote the serializer. If the intent was 2.0.0-beta.37, then betas 37 to 47 are flagged in error. Either way, the release candidates (2.0.0-rc.0 from 1 October onward) sort above every beta and aren't flagged, so upgrading to one settles it.

Check your project

This script reads package-lock.json, pnpm-lock.yaml or yarn.lock for both Qwik packages, applies the advisory's ranges with the Qwik 2 caveat, and reports how each src/entry.* server file handles process-level errors. It reads files only:

#!/usr/bin/env python3
"""Check a Qwik project for CVE-2026-84994 and for crash handling in server entries.

Reads package-lock.json, pnpm-lock.yaml or yarn.lock for @builder.io/qwik and
@qwik.dev/core, then looks at src/entry.*.ts(x) files for process-level
handlers. Read-only, standard library only.
"""
import json, re, sys
from pathlib import Path

def versions(root):
    found = set()
    lock = root / "package-lock.json"
    if lock.exists():
        for key, meta in json.loads(lock.read_text()).get("packages", {}).items():
            name = key.split("node_modules/")[-1]
            if name in ("@builder.io/qwik", "@qwik.dev/core") and "version" in meta:
                found.add((name, meta["version"]))
    for f in ("pnpm-lock.yaml", "yarn.lock"):
        p = root / f
        if p.exists():
            text = p.read_text()
            # pnpm: /@builder.io/qwik@1.19.2 or '@qwik.dev/core@2.0.0-beta.36':
            for name, ver in re.findall(r"(@builder\.io/qwik|@qwik\.dev/core)@(\d[\w.\-]*)[:(']", text):
                found.add((name, ver))
            # yarn: header line, then version line
            for name, ver in re.findall(r'^"?(@builder\.io/qwik|@qwik\.dev/core)@[^\n]*\n\s+version:?\s+"?([\w.\-]+)', text, re.M):
                found.add((name, ver))
    return found

def verdict(name, ver):
    core, _, pre = ver.partition("-")
    nums = tuple(int(x) for x in core.split(".")[:3])
    if name == "@builder.io/qwik":
        return ("AFFECTED, upgrade to 1.20.0 or later" if nums < (1, 20, 0)
                else "fixed (1.20.0 or later)")
    if nums >= (2, 0, 0) and not pre:
        return "fixed"
    if nums < (2, 0, 0) or pre.startswith("alpha"):
        return "AFFECTED, upgrade to a 2.0.0 release candidate"
    m = re.fullmatch(r"beta\.(\d+)", pre)
    if m and int(m.group(1)) < 37:
        return "AFFECTED, upgrade to a 2.0.0 release candidate"
    if m:
        return ("advisory says fixed in '2.0.0-beta37', but semver ranks every "
                "2.0.0-beta.N below it, so scanners will flag this; an rc clears it")
    return "not affected by the advisory range (" + pre + ")"

def entries(root):
    for f in sorted(root.glob("src/entry.*.ts*")):
        text = f.read_text(errors="replace")
        rej = re.search(r"""on\(\s*["']unhandledRejection["']|addEventListener\(\s*["']unhandledrejection["']""", text)
        exc = re.search(r"""on\(\s*["']uncaughtException["']""", text)
        mon = re.search(r"""on\(\s*["']uncaughtExceptionMonitor["']""", text)
        note = ("handles unhandledRejection" if rej else
                "monitor only: logs, but the process still exits" if mon and not exc else
                "no unhandledRejection handler: Node's default exits on one")
        yield f.relative_to(root), note

def main():
    root = Path(sys.argv[1] if len(sys.argv) > 1 else ".")
    found = sorted(versions(root))
    if not found:
        print("no @builder.io/qwik or @qwik.dev/core in lockfiles")
    for name, ver in found:
        print(f"{name}@{ver}: {verdict(name, ver)}")
    for path, note in entries(root):
        print(f"{path}: {note}")
    return 1 if any(verdict(n, v).startswith("AFFECTED") for n, v in found) else 0

if __name__ == "__main__":
    sys.exit(main())

We ran it on four fixtures: lockfiles generated with npm install --package-lock-only --ignore-scripts, and Qwik's real Node starter entry files from the 1.19.2 and 1.20.0 tags:

$ python3 qwik_check.py app-old
@builder.io/qwik@1.19.2: AFFECTED, upgrade to 1.20.0 or later
src/entry.node-server.tsx: no unhandledRejection handler: Node's default exits on one
exit 1
$ python3 qwik_check.py app-new
@builder.io/qwik@1.20.2: fixed (1.20.0 or later)
src/entry.express.tsx: monitor only: logs, but the process still exits
exit 0
$ python3 qwik_check.py v2-beta
@qwik.dev/core@2.0.0-beta.47: advisory says fixed in '2.0.0-beta37', but semver ranks every 2.0.0-beta.N below it, so scanners will flag this; an rc clears it
exit 0
$ python3 qwik_check.py v2-rc
@qwik.dev/core@2.0.0-rc.3: not affected by the advisory range (rc.3)
exit 0

What to do

  1. Upgrade @builder.io/qwik and @builder.io/qwik-city to 1.20.0 or later. The current release is 1.20.2.
  2. On Qwik 2, move to a release candidate if you can, which also clears the scanner finding. If you must stay on a beta, record why a beta.37 or later finding is a version-string artifact, after confirming your build has the fix.
  3. Choose your unhandled-rejection policy on purpose. Either keep the default and make sure restarts can't be outpaced by a replayed request, or register an unhandledRejection handler that logs, alerts and, if needed, drains the instance. A uncaughtExceptionMonitor listener alone only logs.
  4. Rate limit server-function endpoints at the edge, which buys time if a similar bug appears.
  5. Review your own deserializers for anything that can produce a function or class instance, and check for thenables before awaiting. Our insecure deserialization guide covers the broader problem.

The fair view

This is a narrow bug with a clean fix, and the Qwik team shipped it months before disclosure, with a unit test that pins the behavior: the deserialized then must never be called. Crashing on an unhandled rejection is also a defensible Node default. It surfaces bugs that would otherwise leave a server half-working.

But the combination is worth remembering. A framework that can rebuild behavior from request bodies, plus a runtime that exits on the first stray rejection, turns a parsing mistake into a remote off switch. Most JavaScript servers have at least one of those two properties. The useful habit is checking which of yours has both.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.