---
title: "Narrow First, Fix at the Choke Point: The Script-Escape Audit"
canonical: https://dxdev.com/blog/2026-09-26_audit-narrow-then-choke-point-fix/
datePublished: 2026-08-08
---
267 places in the codebase write a string into a `<script>` block or an inline handler, and I had to decide which of them were dangerous. The bug class is simple: if customer text contains `</script>`, the browser ends the script block right there and treats whatever follows as markup. A league admin pastes an embed snippet into a page field, and the snippet breaks out.

## Narrowing 267 to 33

The first pass was a grep across the classic ASP/JScript site for every place that emits into a script context. That gave 267 hits. Fixing 267 sites by hand would have produced a huge diff that nobody could review, and most of it would have been wrong anyway.

So I sorted each hit by one question: can a customer-typed value reach this string? Most sites emit constants, IDs, or values we generate ourselves. Following each remaining hit back to its source, I found 33 that carry customer input, meaning text a staff user or customer typed into a field that later renders on a public page. We fixed those 33 with explicit escaping at the call site. That part shipped first, and it is the boring, reviewable part.

## Why 33 fixes were not enough

Thirty-three patched call sites protect us against the 33 we found. The 34th is written next spring by someone who has never heard of this audit, and it reintroduces the hole with no warning. Page render is the one place every page passes through on its way out, so that is where the permanent fix belongs. The rule at that choke point is that a closing script tag in dynamic output gets escaped before the page leaves the server. Nobody has to remember anything.

## The first version, and what it broke

My first version was the obvious one. At the render choke point, take the finished output and replace every `</script` with the escaped form `<\/script`. One rule covers every page and every future developer.

It passed the checks I had written for it, and I would have merged it. Then I drove it in a real session instead of trusting the unit tests, clicking through the actual admin flows with a browser attached. Image uploads broke.

The cause was that a global replace on finished output cannot tell a customer's `</script>` from ours. The site has script-based content blocks of its own, and they legitimately emit closing script tags. My rewrite reached into those and mangled them. The upload flow depends on one, so the block failed to close cleanly and the page stopped behaving.

The wrong turn cost real time. I had built the fix, written tests that confirmed it worked, and felt done. The tests only proved the escape happened. They said nothing about whether it happened to things it should have left alone, and that gap only showed up in a live session. If I had merged it, the failure would have been image uploads silently dying for customers on a Tuesday release, with no hint that a security change was responsible.

## The gated version

The version that merged makes one distinction. Output that came from customer-controlled values gets escaped. Output emitted by our own script-based content blocks passes through untouched. The escaping applies to the untrusted side of the boundary, so a `</script>` in a customer's pasted snippet is neutralized while the site's own blocks keep working.

I gated the merge on repeating the same browser session against it: the upload flow, the pages that use our script blocks, and a page with a hostile snippet pasted into a field. All three behaved. The ticket landed on `develop`, set to go live with the next Tuesday release.

## What I would do the same way again

Do the count first. Going from 267 to 33 turned the audit from "fix everything" into a review a person could finish. Then move the guarantee to the choke point, because a fix that depends on people remembering will eventually fail.

The cost of the wrong turn is narrower. A choke-point filter sees everything, including things you did not mean to catch. Before it merges, run the real flows that pass through it and look at what it changed on the way out. Tests told me my escape worked, and the browser session told me what else it hit.
