* * deny — ~/blog/pac-tester-security-audit

I tried to break my own PAC tester

I built a PAC file tester that runs entirely in your browser. No upload, no account, nothing leaves your machine. I've been telling people that on this site for a couple of weeks.

Then I sat down and asked the uncomfortable question: is "runs entirely in your browser" the same as "safe"?

It isn't. Those are two different claims and I'd been treating them as one. So I spent a session trying to break my own tool. Here's everything I found, including the one that was bad.

what could even go wrong

"Is it secure" isn't answerable without saying against what.

The tester is a static page. No server-side code, no database, no accounts, no sessions, no file uploads. The PAC file you paste never leaves the tab — it isn't sent anywhere and it isn't written to local storage. I verified that rather than assuming it: no fetch, no XMLHttpRequest, no sendBeacon, no localStorage write anywhere in the tool's code path.

That takes the entire classic category off the table. A pasted PAC file can't compromise a server that isn't running, leak a database that doesn't exist, or persist into another visitor's session. There's nothing to steal and nowhere to store it.

What's left is narrower, but real:

  1. Can a pasted file execute code in the page? That's cross-site scripting — the file escaping the interpreter and running as JavaScript on my domain.
  2. Can a pasted file wedge the tab? Denial of service against the person using the tool.
  3. Does anything leak by accident — into storage, a URL, a log?

Three questions. The answers were no, yes, and no.

the bad one

The tester implements shExpMatch, the PAC function that does glob matching: shExpMatch(host, "*.example.com"). The obvious way to implement a glob is to translate it into a regular expression. Turn * into .*, turn ? into ., escape everything else, anchor both ends, done. That's what I did. It's what most implementations you'll find do.

That's a trap.

Feed that translator a pattern like this:

*a*a*a*a*a*a*a*a*a*a*a*a*b

and it produces the regex ^.*a.*a.*a.*a.*a.*a.*a.*a.*a.*a.*a.*a.*b$. Now test it against a URL with plenty of as in it that does not end in b. The engine has to try every possible way of dividing the string between those twelve .* groups before it can conclude the whole thing fails. The number of ways grows exponentially with the length of the input.

I measured it. One call, on one string:

test URL lengthtime to fail
41 characters16 ms
45 characters171 ms
49 characters1.3 seconds
53 characters7.4 seconds
57 characters34.8 seconds

Every four characters multiplies the time by roughly five. At a normal URL length the tab stops responding — not slow, hung, with the spinner going, until you kill it.

This is ReDoS, regular expression denial of service. None of my existing guardrails touched it. The tester already had an interpreter step budget to stop infinite loops and a 256 KB file cap. But the freeze happens inside a single step. The budget is checked between steps, so it never gets a turn. A limit that can't be reached is not a limit.

It was also worse than I first thought. I assumed you'd need to run a trace to hit it. You don't — the linter's rule-overlap analysis compiles patterns from the file too, so clicking lint on a hostile file was enough.

why it bothered me

The blast radius is small. It freezes the tab of whoever pasted the file. It can't reach the server, other visitors, or anything else. Paste a PAC file that hangs your own browser and you've mostly inconvenienced yourself.

But think about how this tool gets used. Someone posts a PAC file in a forum thread and says "this one's weird, run it through that tester." Now the attack is: hand someone a plausible-looking PAC file, watch their browser lock up on my domain. The failure has my name on it. For a tool whose whole pitch is "you can trust this, it runs locally," a hang is the wrong failure mode.

the fix: stop using a regex

I could have papered over it — cap the URL length, reject patterns with more than N wildcards, wrap it in a timeout. Those are all guesses about where the cliff is, and they all leave the cliff there.

The real fix is that glob matching doesn't need a regex engine. Globs are simpler than regular expressions, and there's a standard two-pointer algorithm for them: walk the pattern and the string together; when you hit a *, remember where you were; if you get stuck later, jump back and let the * swallow one more character. It never explores the same position twice.

That pattern now resolves in under a millisecond, and there's nothing left to tune.

I wanted to prove the replacement didn't change how real patterns behave. So the test suite runs 400 pattern/subject pairs through both the old regex implementation and the new matcher and asserts they agree — including the fiddly cases that trip people up in production: *.example.com not matching the bare domain, *example.com also matching badexample.com, a leading dot matching nothing at all, and . staying a literal dot rather than becoming a regex wildcard.

In the old implementation a . in your pattern was escaped, so it matched a literal dot. If I'd rewritten this carelessly and let . through to a regex, shExpMatch(host, "a.b") would have started matching axb. That's the kind of change nobody notices until a rule matches something it shouldn't, six months later.

the sandbox

The design principle of the tester is that the pasted PAC file is data, never code. It's parsed into a syntax tree and my own interpreter walks that tree. There's no eval, no new Function, no script injection. That's deliberate, because a PAC file is a JavaScript file, and running a stranger's JavaScript in your page is exactly what you're trying to prevent.

The classic way out of a hand-written JavaScript interpreter is this:

"".constructor.constructor("alert(document.domain)")()

Read it right to left. "" is a string. "".constructor is String. And String.constructor is Function — the real one. Once you hold Function you can build any code you like out of a string and run it. Every homemade JS sandbox gets probed with some variant of this.

Mine held, for a reason I'd like to claim was foresight: property access in the interpreter is only permitted on strings, arrays, and numbers. String is a function, so the second .constructor has nowhere to go and the expression is refused. Method calls are separately restricted to an allowlist — toLowerCase, indexOf, substring, split, and a handful of others — so even a function you can reach can't be called unless it's on the list.

I also confirmed prototype pollution is structurally impossible rather than merely unlikely. Variable scopes are built with Object.create(null), so they have no prototype chain at all. In a normal object, assigning to __proto__ reaches through a setter and changes the object's prototype; here __proto__ is a nine-character property name with no special powers. A PAC file that assigns to it does nothing.

But — and this is the part I'd have missed if I hadn't gone looking — identifier lookup had a hole. When the interpreter resolved a bare name it fell back to checking the PAC standard library object. That object was a plain object literal, which means it inherits from Object.prototype, which means a PAC file using the bare identifier constructor got handed the real Object function. Same for toString, valueOf, hasOwnProperty.

I traced every path out of that and couldn't turn it into code execution — the member-access restriction blocks the follow-up move, which is why the escape above still fails. So it wasn't exploitable. It was also unambiguously a hole in a wall I'd claimed was solid, and it produced confusing crashes instead of a clean "that's not a PAC function" message. It's a one-line fix: check for an own property instead of any property. I've also made the interpreter refuse constructor, __proto__, and prototype by name at every property access, so the ladder's bottom rung is gone rather than merely unreachable.

three smaller ones

Memory had no limit. The step budget bounds time, not space. This PAC file uses about forty steps and asks for a terabyte:

var s = "x";
for (var i = 0; i < 40; i++) { s = s + s; }

Each pass doubles the string. Forty doublings is 2⁴⁰ bytes. The step budget never fires because forty steps is nothing; the tab dies of memory exhaustion first. Strings are now capped at 1 MB.

The linter could fail silently. The parser was wrapped in error handling but the linter that runs afterward wasn't. Any unexpected exception meant the click handler threw and the page did nothing — no output, no error, no clue. Silent failure is worse than a crash, because the user assumes they did something wrong. It now degrades into a visible finding that says the linter broke.

The trace could be unrenderable. A PAC file with a loop can produce tens of thousands of trace rows, and the page builds a DOM table row for every one. Nobody reads the forty-thousandth row, so the trace now stops at 500 and says so.

what I'd take away

"Runs entirely in your browser" is a privacy claim, not a safety claim. It means your data isn't going anywhere. It says nothing about whether hostile input can wreck your session. I'd been letting one stand in for the other, and I don't think I'm alone — client-side tools get a pass that server-side ones never would.

A limit in the wrong place isn't a limit. I had a step budget and a size cap and felt covered. Neither could see a single regex call that ran for 35 seconds. When you add a guardrail the question isn't "does a limit exist" — it's "what does this limit measure, and what can grow without touching it?" Time, memory, output size, and recursion depth are four different budgets, and I had one and a half of them.

Translating one language into another inherits the second one's problems. Globs have no backtracking. Regular expressions do. Converting the first into the second silently imports a failure mode the original never had. That trap is waiting in any code that builds a regex out of a user-supplied wildcard string — URL matchers, allowlists, log filters, WAF rules, search boxes.

Audit the part you're proudest of. The interpreter sandbox is what I'd thought hardest about, and it held. The bug was in a helper function I wrote in about four minutes because it looked obvious. That's where I wasn't paying attention.

what changed

Everything above is fixed in the tester as it stands today. Alongside the fixes there's now a test suite — 93 assertions covering the glob parity checks, the sandbox escape attempts, the resource limits, the interpreter's routing decisions, and every linter rule. It runs against the exact file the site ships, not a copy.

That detail matters more than the number. A test suite that tests a lookalike copy of your code tells you about the copy.

If you find something I missed, I'd like to hear about it — robert@anyanydeny.com. I'll fix it and write it up.