basic-ftp and probe-image-size ReDoS; Nodemailer Fixes SNI Mix-Up
Quadratic regexes in basic-ftp 6.2.1 and probe-image-size 7.4.0 let one listing or SVG stall Node. Nodemailer 10.0.2 fixes a TLS SNI mix-up and a stack crash.
Two widely installed Node packages shipped fixes this week for regular expressions that take quadratic time on hostile input: basic-ftp's directory-listing parser and probe-image-size's SVG parser. Either one lets a single crafted response or file pin your event loop. Nodemailer 10.0.2 also fixes a TLS server-name mix-up between transports and a recipient-array crash.
What shipped
basic-ftp: directory listings that never finish parsing
- Package:
basic-ftp, affected<= 6.2.0, fixed in 6.2.1 - Advisory: GHSA-c475-qrg2-pj4r (CVE-2026-102990), high
- Impact:
Client.list()parses Unix-style listings with a regex that has two adjacent "words separated by spaces" groups followed by a required size field. A line that starts like a real listing but never reaches a valid size makes the engine try every split of the tokens between the two groups. The advisory's proof of concept blocks the process for about 40 seconds with one 128 KB line. The FTP server controls the listing, so a malicious or compromised server is enough.
You may not have installed it on purpose. proxy-agent depends on pac-proxy-agent 9.1.0, which uses get-uri 8.0.1, which still asks for basic-ftp ^5.3.1. That range resolves to 5.3.1, which is inside the affected range, so updating your direct dependencies won't pull the fix down that path. Check where it comes from first:
npm ls basic-ftp
If you use basic-ftp directly, npm install basic-ftp@6.2.1. If it only arrives through a parent package, an overrides entry can force 6.2.1, but that is a major-version jump for the parent. Test the code path that uses it before you ship.
probe-image-size: an SVG that is all < and no >
- Package:
probe-image-size, affected<= 7.3.0, fixed in 7.4.0 - Advisory: GHSA-gjj5-9665-rwrc (CVE-2026-104861), high, CVSS 7.5
- Impact: the SVG header scan uses
/<[-_.:a-zA-Z0-9][^>]*>/. On input with many<characters and no>, the scan restarts at every<and runs to the end of the input each time.probe.sync(),probe(stream)andprobe(url)are all affected. The advisory also points out that the streaming parser's 64 KB cap doesn't help, because it re-parses the whole buffer on every chunk and the sender controls the chunk size.
This matters most if you probe images from user-supplied URLs: link previews, avatar-by-URL, upload validators.
npm install probe-image-size@7.4.0
Nodemailer 10.0.2: TLS server name leaks between transports
- Package:
nodemailer, affected>= 5.0.0, < 10.0.2, fixed in 10.0.2 - Advisory: GHSA-6vj9-mwq6-2f5v, medium, CVSS 5.9
- Impact: Nodemailer's process-wide DNS cache is keyed only by host, but each entry also stores the caller's TLS
servername. When twosecure: truetransports use the same host with differenttls.servernamevalues, the second one gets the first one's server name. In a multi-tenant app sending through an SNI-routed SMTP gateway, one tenant can prime the cache so another tenant's transport verifies against the wrong identity and sends its SMTP credentials there.
Nodemailer 10.0.2: nested recipient arrays exhaust the stack
- Package:
nodemailer, affected< 10.0.2, fixed in 10.0.2 - Advisory: GHSA-8vvx-rff5-p5rq, medium, CVSS 5.9
- Impact: only the outermost array of
to/cc/bccgets flattened. A recipient value wrapped in about 5,000 arrays, roughly 10 KB of JSON, throwsRangeError: Maximum call stack size exceededinsidesendMail(), beforemaxRecipientsis checked. This is reachable if a request body goes straight intosendMail().
npm install nodemailer@latest # 10.0.2 or later
figlet: an infinite loop behind two options
- Package:
figlet, affected< 1.11.3, fixed in 1.11.3 - Advisory: GHSA-62ch-8vmq-8xm7 (CVE-2026-96780)
- Impact: with
whitespaceBreak: trueand awidthsmaller than one rendered character, word wrapping never ends. The maintainers rate the real-world severity low to medium because both options have to be set, andwidthhas to come from untrusted input. Upgrade if users can choose the width.
Regex backtracking (ReDoS), explained
Most regex engines, including V8's, use backtracking. When part of a pattern fails, the engine goes back and tries another way to split the input among the pattern's quantifiers (*, +, {n,}). For most patterns there are only a few ways to try. But if two quantifiers can match the same characters, the number of splits grows with the input. Quadratic growth is enough to hurt: a line 1,000 times longer takes about a million times longer to reject.
Both bugs this week show the two common shapes:
- Adjacent overlapping groups. basic-ftp's
(\S+(?:\s\S+)*)for the owner, then the same thing for the group. Any token can belong to either group, so every split gets tried before the match fails. - A search that rescans. probe-image-size's
<...[^>]*>restarts at every<, and each[^>]*runs to the end of the input looking for a>that never comes.
Node runs your JavaScript on one thread, so a regex that runs for 40 seconds stops every other request for 40 seconds. No crash, nothing in the error logs, just a server that stops answering.
AI-generated code tends to produce these. Ask a model to "parse this log line" and it usually writes one big regex with several .* or \S+(\s\S+)* groups. It works on the test line. Nobody tries a hostile line.
A vulnerable pattern in a Next.js route handler:
// Matches "key = value ; key = value ; ..." — on a bad ending the work doubles with every extra pair
const HEADER = /^(\w+\s*=\s*.*\s*;\s*)*$/;
export async function POST(req: Request) {
const { header } = await req.json();
return Response.json({ ok: HEADER.test(header) });
}
.* can swallow the ; separators, so on an input with no valid ending, every way of dividing the text among the repeats gets tried. That is worse than quadratic: in Node, 22 a=b; pairs followed by a=b already take over 100 ms, and each extra pair doubles it. A safer version limits the input and makes each piece unable to match its neighbour's characters:
const PAIR = /^\w+\s*=\s*[^;]*$/;
export async function POST(req: Request) {
const { header } = await req.json();
if (typeof header !== "string" || header.length > 2048) {
return Response.json({ ok: false }, { status: 413 });
}
return Response.json({ ok: header.split(";").every((p) => PAIR.test(p.trim())) });
}
When you review a regex, ask two questions. Can any character be matched by two different quantified parts of the pattern? Is the input length capped before the regex runs? If the first answer is yes and the second is no, test it with a few thousand repeats of the ambiguous part and a bad ending. If it takes more than a few milliseconds, rewrite it, or split the input and match the pieces.
Check your own repo
npm ls basic-ftp probe-image-size nodemailer figlet # are they in your tree, and through which parent?
npm audit # npm's view of the same advisories
npx guardvibe@3.49.0 audit . # 3.49.0 flags pinned basic-ftp and probe-image-size versions (VG1190, VG1194)
Sources
- GHSA-c475-qrg2-pj4r — basic-ftp quadratic-time CPU DoS in Client.list()
- GHSA-gjj5-9665-rwrc — probe-image-size quadratic-time DoS in the SVG parser
- GHSA-6vj9-mwq6-2f5v — Nodemailer DNS cache reuses TLS servername across transports
- GHSA-8vvx-rff5-p5rq — Nodemailer nested recipient arrays exhaust the stack
- GHSA-62ch-8vmq-8xm7 — figlet infinite loop with whitespaceBreak and small width
- get-uri on npm (basic-ftp dependency range)