November 20, 2025

Esbuild's XSS Bug that Survived 5 Billion Downloads and Bypassed HTML Sanitization

Mav Levin
Founding Security Researcher

Esbuild has been downloaded 5 billion times since this XSS bug was introduced in 2022. The bug hid in a function that promised to escape html called escapeForHTML. But, apparently, the promise was more of a suggestion. To bypass the HTML escaping, I used the quote of the day, literally a quote ". A malicious folder with a quote in its name could be used to attack anyone using the dev server. The fix was one line. The exploit involved making an invisible script take over your entire screen. 

Buckle up.

The Initial Finding: Suspicious XSS

This adventure kicked off with our depthfirst system tapping me on the shoulder like an overeager intern with a suspiciously confident smile.

> Flagged issue: XSS in esbuild dev server

Possible XSS? Sure.

> Severity: Low

Low severity? Ok.

> Codebase: github.com/evanw/esbuild (40k Github stars)
> Vulnerable code: html.WriteString(escapeForHTML( ... ))

In esbuild? Using a function literally named escapeForHTML? Unlikely. I had the same reaction you’d have if someone told you a toaster was capable of launching a space shuttle: charming, but wrong.

Our system claimed there’s an XSS bug inside code designed to prevent XSS? In a major codebase built around generating safe HTML? If true, that’s like finding out the lifeguard can't swim.

Still, if valid, this would be a significant finding. The esbuilt npm package alone has five billion downloads. And a restless “but what if?” rang in the back of my mind. So I sighed, cracked my knuckles, and set out to prove the machine wrong. Spoiler: the machine was not wrong.

The Investigation: A Friendly Challenge Turns Into a Rabbit Hole

The depthfirst system had already labeled it “low severity,” which is our polite way of telling engineers, “not a fire, but this smells funny.”

But I couldn’t let it go. Even when a machine says “low severity,” I still want to understand why it thinks something is off. It’s like hearing your dog growl at a blank wall. Maybe it’s nothing, but maybe it’s time to call a priest.

So I followed the trail into esbuild’s code.

The “Aha!” Moment

Here’s the vulnerable code :

func escapeForHTML(text string) string {
	text = strings.ReplaceAll(text, "&", "&")
	text = strings.ReplaceAll(text, "<", "&lt;")
	text = strings.ReplaceAll(text, ">", "&gt;")
	return text
}

func respondWithDirList(/*...*/) {
	// ...
	html.WriteString("</title>\n")
	html.WriteString("<h1>Directory: ")
	var parts []string
	if queryPath == "/" {
		parts = []string{""}
	} else {
		parts = strings.Split(queryPath, "/")
	}
	for i, part := range parts {
		if i+1 < len(parts) {
			html.WriteString("<a href=\"")
			html.WriteString(escapeForHTML(strings.Join(parts[:i+1], "/")))
			html.WriteString("/\">")
		}
		html.WriteString(escapeForHTML(part))
		html.WriteString("/")
		if i+1 < len(parts) {
			html.WriteString("</a>")
		}
	}
	html.WriteString("</h1>\n")
	// ...
}

At first, nothing seemed odd. The dev server is creating the h1 title from directory listings. It's escaping  HTML in the folder names. All the classics get neutralized: <, >, &, '.

But one thing didn’t get escaped. Quotes ".

I have confirmed our system's finding and suddenly everything clicked into place. I gave my laptop a pat on the head to reward the AI.

HTML 101: The Difference Between Text and Attributes

escapeForHTML correctly protects you when you put user-controlled text between tags, like:

<div>wonderful user text over here</div>

But esbuild wasn’t putting the escaped text there. It was putting it inside an HTML attribute, in an href:

<a href="not-so-wonderful user text over here">Link</a>

If your sanitization doesn’t escape double quotes, you can break out of the attribute and add your own. You can slap on a new style, an event handler, or an entire circus of JavaScript!

The correct function to use was escapeForAttribute:

func escapeForAttribute(text string) string {
	text = escapeForHTML(text)
	text = strings.ReplaceAll(text, "\"", "&quot;")
	text = strings.ReplaceAll(text, "'", "&apos;")
	return text
}

Crafting the Exploit: Making an Invisible Screen-Sized Mousetrap

Once I realized I could break out of the attribute, the rest was pure puzzle-solving joy.

I needed a folder name that:

  1. Included a double quote to terminate the attribute
  2. Added a malicious attribute to execute Javascript
  3. Worked even though esbuild would automatically append /" at the end
  4. Easily triggered (because asking a user to click a link isn't sexy).

The Payload Folder

Here’s the command that created the malicious directory:

mkdir -p 'public/foo"style="position:absolute;top:0;left:0;width:100vw;height:100vh;" onmouseover="alert(`xss`)" data-x="'

Let me unpack the magic:

  • style="position:absolute;top:0;left:0;width:100vw;height:100vh;"
    This creates an invisible full-screen div. This is important for the next part.
  • onmouseover="alert('xss')"
    The moment your cursor moves over the div, which is now the whole screen, boom. Arbitrary JavaScript execution.
  • data-x="
    This dummy attribute was the key to neutralizing esbuild’s auto-appended /". I needed a place to absorb the trailing characters so they don't cause a syntax error in the other attributes.

Reload the dev server. Move your mouse. Instant satisfaction.

The Fix: A One-Word Patch and a Thoughtful Maintainer

After confirming the exploit was real, I sent the automatically generated fix upstream. The patch was immediately merged.

The fix? Literally a swap:

- escapeForHTML(...)
+ escapeForAttribute(...)

One word. Billions of future downloads affected.

I love bugs like this. They're subtle, and make you think deeply about the edge cases of the code.

The maintainers thanked us for finding and fixing the bug, and was correct to point out this didn't have a security impact. Since this only affects the dev server, and the dev server assumes a trusted environment, it’s not a “security vulnerability” in the traditional sense. And that’s true. This wasn’t a CVE-worthy disaster. No one’s production servers were melting because of this.

But it was still a bug. An elusive, fun, intellectually stimulating bug that was completely exploitable.

And depthfirst’s system correctly found, categorized, and drafted a patch. All automatically.

I just got to be the human who enjoyed the ride.

Conclusion

This adventure felt like tugging on a loose thread in a sweater: you don’t expect much, but suddenly half the sleeve is in your hand. All I did was follow a quote mark out of an attribute, and it led to a bug that had been downloaded billions of times. The funny part is that nothing here was “wrong” in isolation. The trick was noticing the context had changed. escapeForHTML was perfectly fine for text, just not for attributes.

Depthfirst surfaced the loose thread; I pulled it because I can’t resist seeing where those threads lead. Together, we solved a tiny mystery tucked away in a project downloaded five billion times.

Book a demo of DepthFirst
Book Demo