CVE-2026-34841: The Caret That Let a RAT In
A hijacked npm maintainer account published booby-trapped `axios` tarballs, and every project whose lockfile said `^` politely fetched them. Bruno was one of them — and the fix wasn't a patch to a function, it was a lesson about how much trust a single caret encodes.
A hijacked npm maintainer account published booby-trapped axios tarballs, and every project whose lockfile said ^ politely fetched them. Bruno was one of them — and the fix wasn't a patch to a function, it was a lesson about how much trust a single caret encodes.
The advisory in plain English
CVE-2026-34841 is not a bug in a line of anyone's code. It's a supply-chain compromise. Between roughly 00:21 and 03:30 UTC on March 31, 2026, an attacker with control of the axios npm publishing credentials pushed malicious versions of the package to the registry. Those versions carried a hidden dependency that dropped a cross-platform Remote Access Trojan onto the installing machine. Anyone who ran npm install for @usebruno/cli (or the broader Bruno monorepo) inside that window resolved their axios dependency to a poisoned tarball and installed the RAT along with it.
The single most important forensic fact — and the one that makes this CVE confusing to newcomers — is that the malicious code is not in axios's git history. It never was. The axios/axios source tree at the current release is clean: axios @ 311fcc5, L141-145 declares exactly the three legitimate runtime dependencies you'd expect, and its scripts block has no install hook to abuse.
"dependencies": {
"follow-redirects": "^1.16.0",
"form-data": "^4.0.6",
"proxy-from-env": "^2.1.0"
},
axios/axios package.json @ 311fcc5, L141–145 — no preinstall/postinstall, no fourth "hidden" dependency.
The "hidden dependency deploying a RAT" the NVD text describes existed only in the tarball the attacker uploaded to npm, assembled outside CI and outside version control. Grepping the repository's commit log for compromise, malicious, RAT, hijack, or postinstall returns nothing but a single documentation change (axios @ 1de8d51) that later tightened the wording of the provenance section in SECURITY.md. If you go looking for the malware in the source, you will not find it, because a registry-publish compromise attacks the artifact, not the repo. That's the whole trick.
The flawed "function"
So where's the defect a downstream project could actually own? It's in the dependency declaration. Before the fix, Bruno's packages pinned axios with caret ranges — the npm default, and the thing that quietly widened the blast radius. In the pre-fix tree (bruno @ 53aa9ed):
"aws4-axios": "^3.3.0",
"axios": "^1.8.3",
"axios-ntlm": "^1.4.2",
usebruno/bruno packages/bruno-cli/package.json @ 53aa9ed, L56–58.
The same pattern held elsewhere: packages/bruno-requests/package.json asked for "axios": "^1.9.0" (L27), packages/bruno-js/package.json for "axios": "^1.8.3" (L20), and the root package.json overrides block (L95) pinned rollup, pbkdf2, and a nested json-schema-typed — but said nothing at all about axios.
A caret range means "this version or any newer non-breaking release." ^1.8.3 happily accepts 1.8.4, 1.12.0, 1.99.0 — whatever the highest matching version on the registry happens to be at resolution time. On a machine with no lockfile entry pinning the exact artifact (a fresh npm install, a CI runner, a contributor's first clone), the resolver's job is to go fetch the newest thing that satisfies the range. On March 31, the newest thing that satisfied the range was the attacker's.
Why the check was insufficient
The uncomfortable part: nothing Bruno wrote was "wrong" by conventional standards. Caret ranges are the npm default; npm install <pkg> writes them for you. The implicit security model of a caret is "I trust this maintainer to keep publishing good code under this major version." That assumption is load-bearing, and it silently transfers your project's integrity to the weakest credential in someone else's account.
A lockfile (package-lock.json) normally blunts this — it records the exact resolved version and integrity hash, so npm ci reinstalls byte-for-byte what you last resolved. But a lockfile only protects installs that use it. Any operation that re-resolves the range — npm install <newdep>, a Dependabot bump, a developer without npm ci discipline, a transitive dependency pulling axios through its own caret — reopens the door. And the compromise window shows the second gap: even integrity hashes don't help if the poisoned version is the first one you ever resolve, because then the bad hash simply becomes your lockfile's source of truth.
The check that was missing wasn't in a validator or a parser. It was the absence of a policy that said: pin exact, and don't auto-adopt a version the moment it appears.
What the fix changed
Bruno PR #7632 is a three-part response, and it's a tidy blueprint for how to react to exactly this class of incident.
1. Convert caret ranges to exact pins. Commit bruno @ c5e04b0 ("chore: pin axios version") strips the carets everywhere axios is resolved:
- "axios": "^1.7.9",
+ "axios": "1.7.9",
...
- "axios": "^1.8.3",
+ "axios": "1.8.3",
usebruno/bruno package-lock.json @ c5e04b0 — caret→exact across every transitive and first-party axios entry.
2. Centralize the pin with a top-level override. The merged fix (bruno @ e1f56bb) adds axios to the root overrides block, so every dependency in the workspace — including transitive ones that request axios through their own ranges — is forced to one vetted version:
"overrides": {
"axios":"1.13.6",
"rollup": "3.30.0",
"pbkdf2":"3.1.5",
usebruno/bruno package.json @ e1f56bb, L95–98 — axios newly present in the override map.
An overrides (or resolutions, in Yarn) entry is the real muscle here: it collapses the whole dependency graph onto a single axios version you chose, denying any nested caret the chance to drag in something newer and hostile.
3. Add a maturity cooldown. The subtlest and most interesting change (bruno @ e1f56bb) is a one-line .npmrc:
min-release-age=30
usebruno/bruno .npmrc @ e1f56bb — refuse to install any release younger than 30 days.
This directly targets the timing of the attack. The malicious axios versions lived on the registry for roughly three hours before being pulled. A 30-day cooldown that refuses freshly-published releases would have sailed straight over the entire compromise window — you simply never install a version until it's had time to be noticed, flagged, and yanked. It's defense-in-depth against the fact that a registry can serve you a package seconds after an attacker uploads it.
Upstream, axios responded on its own side of the trust boundary: hardening the publish workflow (axios @ c44f8d0, "use bundled npm for v1 publish") and clarifying which historical releases carry provenance attestations so consumers can actually verify a tarball was built in CI from a known commit (axios @ 1de8d51).
The lesson
The exploit path here has no clever payload to redact, because the "attacker-controlled source" was the npm registry itself and the "sink" was your resolver's willingness to fetch the newest match for a range. There is no source→sink flow inside either codebase to trace — the malicious bytes were never committed. That is precisely what makes supply-chain compromise so slippery: your SAST, your code review, your git history are all clean, and you're still owned, because the defect lives in the trust relationship expressed by three characters: ^1.
Three concrete takeaways, all visible in the diff:
- Pin exact, and enforce it with overrides/resolutions. A caret is a standing offer to run any future code the maintainer (or whoever steals their token) publishes. Exact pins plus a top-level override collapse that offer to one auditable version.
- Use
npm ci, notnpm install, in CI and releases. The lockfile only protects the installs that honor it. - Add a release-age cooldown. Most malicious publishes are caught and yanked within hours. Refusing brand-new versions turns "instant infection" into "someone else's early-warning system." Bruno's
min-release-age=30is the cheapest line in the whole PR and arguably the one that would have prevented the incident outright.
The RAT was never in the source. The vulnerability was in how much we let a caret decide on our behalf.
References
- NVD — CVE-2026-34841
- Bruno security advisory — https://github.com/usebruno/bruno/security/advisories/GHSA-658g-p7jg-wx5g
- Bruno fix PR #7632 — https://github.com/usebruno/bruno/pull/7632
- Pre-fix caret range — https://github.com/usebruno/bruno/blob/53aa9ed/packages/bruno-cli/package.json#L57
- Pre-fix caret range — https://github.com/usebruno/bruno/blob/53aa9ed/packages/bruno-requests/package.json#L27
- Pin commit (caret→exact) — https://github.com/usebruno/bruno/commit/c5e04b0
- Override +
.npmrccooldown — https://github.com/usebruno/bruno/commit/e1f56bb - Root
overrideswith axios pin — https://github.com/usebruno/bruno/blob/e1f56bb/package.json#L95 - axios publish workflow hardening — https://github.com/axios/axios/commit/c44f8d0
- axios clean dependency set — https://github.com/axios/axios/blob/311fcc5/package.json#L141
- axios provenance wording fix — https://github.com/axios/axios/commit/1de8d51
- Aikido writeup — https://www.aikido.dev/blog/axios-npm-compromised-maintainer-hijacked-rat
— the resident
A caret is a signed blank check