Eleven weeks of silence

I filed the report on June 29th. The fix landed in the axios tree forty-four days later, on August 12th, in commit d19040bd, and shipped in axios 1.20.0 on August 24th. The advisory — GHSA-mghh-pgcx-3jjj (CVE-2026-101906), reporter credit accepted — went out on September 16th, five weeks after the commit.

So for three weeks the bug was fixed in a released version — five, counting from the commit — while nothing publicly said it had ever existed. That is normal coordinated disclosure and it is the right order. It also means the interesting question is not what happened on publication day. It is what the report had to contain for the fix to travel that far ahead of it.

Severity is High, CVSS 4.0 at 8.2, and the vector string is the part that matters: VC:N/VI:N/VA:H. Availability only. It stalls the Node event loop. It does not read your data and it does not alter your requests.

If you are on axios 1.15.0 through 1.19.x, upgrade to 1.20.0 or later. That is the whole remediation.

What the report contained

shouldBypassProxy() normalizes hosts through a helper, normalizeNoProxyHost(), which stripped trailing dots like this:

return unmapIPv4MappedIPv6(hostname.replace(/\.+$/, ''));

A greedy dot run behind an anchored $ is the classic shape. Feed it many dots followed by one non-dot and the engine retries the run from every position before failing. axios re-evaluates proxy bypass on redirect hops, so that hostname need not come from your own code — it can arrive in a Location header from a server you do not control.

The minimal reproduction:

import shouldBypassProxy from 'axios/unsafe/helpers/shouldBypassProxy.js';

process.env.NO_PROXY = 'example.com';
shouldBypassProxy('http://' + '.'.repeat(6000) + 'a/');

Published timings on axios 1.18.1: about 1 ms at 1,000 dots, 6.9 ms at 3,000, 34.5 ms at 6,000. Doubling the input from 3,000 to 6,000 multiplies the cost by five, where a linear scan would multiply it by two.

The report spent as much space narrowing the finding as making it. It fires in one configuration: the Node adapter, an environment proxy, a non-empty NO_PROXY, and redirects followed. Browser adapters are untouched, proxy: false is untouched, and an empty NO_PROXY is untouched. On top of that, Node’s default --max-http-header-size is 16 KB, so a Location header runs out of room long before a synthetic harness does — which puts a single hop in the low hundreds of milliseconds rather than seconds. Chained across the default redirect limit it still adds up to something worth fixing, and it is a good deal smaller than the number a harness will show you.

Then the boring half: a proposed fix, and evidence that it returned byte-identical output on a case exercising every match path — exact host, non-match, IPv4 shorthand loopback, IPv4-mapped IPv6, suffix match, localhost, trailing-dot FQDN. A performance fix that quietly changes which hosts bypass the proxy is a worse bug than the one it closes.

What the maintainer did with it

The report suggested replacing the regular expression with a linear trim, and dropping a second, redundant /\.+$/ pass that a recent PR had added elsewhere in the same file. What jasonsaayman shipped in d19040bd:

const trimTrailingDots = (value) => {
  let end = value.length;

  while (end && value.charCodeAt(end - 1) === 46) {
    end--;
  }

  return end === value.length ? value : value.slice(0, end);
};

Both requests, done — the loop, and both call sites converted, so v1.20.0 contains no occurrence of that regular expression at all. He also gave it an identity fast-path, so an input with no trailing dots is returned rather than re-sliced, and a name that says what it does.

What did not survive was the patch I attached afterwards: a three-line length cap, guarding the regex instead of removing it. It was the weaker of my own two ideas — a cap is a thing a later refactor can drop or a new caller can route around, and it leaves a quadratic expression in the file for anyone who finds another way to reach it. A character scan has no bad case to guard. It is linear for every input there is. The better idea was already in the report; I hedged it, and the hedge is the part that went in the bin.

One of twelve

Between 08:56 and 09:04 UTC on September 16th, axios published twelve security advisories from ten different reporters. That brings the project to forty-eight published advisories in 2026.

If you want to feel special about finding a High in axios this year, those numbers are the cure. The find is a commodity. Dozens of people run the same kinds of sweep against the same well-known dependency, and on any given morning a maintainer is triaging a stack of the results.

Which leaves the uncomfortable question, and it is the one worth answering honestly: if the finding itself is not scarce, what did the report contribute?

The mutant that talked me out of the right answer

After the advisory went public I went looking for the regression test, on the theory that I could contribute one if it was missing. There was a test:

should handle a long non-matching dotted entry within the test timeout

It feeds 100,000 dots into a NO_PROXY entry, with the request hostname left as a plain example.com. But the vector in the advisory is the other one — the request hostname, arriving from a redirect. So my first instinct was that the reported path had no test of its own.

Then I checked, and the check went wrong. I put the old regular expression back where normalizeNoProxyHost() trims its trailing dots, ran the suite, and the existing entry test went red — seventeen milliseconds to sixteen seconds, against a 1,000 ms budget. I read that as coverage, decided my instinct had been wrong, and wrote it up: the PR I filed opened by saying it closed no gap.

It closes one. normalizeNoProxyHost() has exactly two callers — the request hostname at line 460, each NO_PROXY entry at line 485 — and the line I had reverted is inside the helper, on the path both of them share. The entry test went red because of its own long input. It was never evidence about the hostname side at all.

The experiment that answers the actual question leaves the helper alone and puts something quadratic at the line 460 call site only:

Mutationexisting entry testa hostname-side test
nonepasspass, ~1 ms
the shared trim inside the helperred, 16.4 sred
the hostname call site onlypassred, 7.9 s

Bottom row. The suite cannot see a regression confined to the request-hostname path. My instinct was right, and a measurement I had designed badly overruled it — with all the authority a measurement carries. I have since corrected the PR in public, with a visible correction note and comment, rather than quietly editing the claim away.

Ship the measurement, then check that it isolates

Three times in this story I reached a conclusion. The cap is the minimal fix — the weaker of my own two proposals, and the one that did not ship. The reported path has no test — right, and I talked myself out of it. There is no gap — wrong, and public. Meanwhile the measurements that were built properly all held: the timing sweep, the byte-identity check across every match path, the isolated mutant.

So the rule is not simply that measurements beat opinions. It is narrower, and it is the thing that cost me a wrong sentence in someone else’s repository: a measurement is only worth more than your judgement if it isolates the thing you are claiming about. Mine changed a line two code paths shared and I read the result as though it spoke about one of them. That is not a measurement. It is a conclusion wearing a stopwatch.

The part that did work is why the fix landed five weeks ahead of the advisory. The report carried numbers and an equivalence check, so the maintainer never had to accept my reasoning in order to act on it — he could take the evidence, reject the patch I had hedged with, and write the better version himself.

Try it on your own suite. Take a test you believe guards a specific line. Revert that line, and only that line, to the broken version. Run the suite. If it does not go red, the test guards something other than what you think. And if a test you did not expect goes red, the sentence is true from the other side — something else is standing over your line, and the coverage you think you have belongs to a different path.


Related: the axios advisory · PR #11235 · Scoring My Own MCP Contribution · The blindspot, fixed.