Back to Feed
article 3m read

HTML escaping merged into the read path, with save paths carrying it back into the database

An agent's script-injection patch escaped staff names and chore notes as they leave the database. Reading the code, every save that starts from loaded data writes the escaped text back, and it grows each round. The page it was meant to protect already showed plain text.

Article DepthPractice

Flow

Checking the round trip

If the text changes at the last step, the escape is being saved.

  1. 1Seed a record

    Store a chore note with an ampersand and an apostrophe in it.

  2. 2Edit something else

    Change a harmless field on a different record through the normal save.

  3. 3Compare the stored text

    Read the seeded row straight from storage and compare it byte for byte.

  4. 4Move the encoding

    If it changed, return raw text and encode in the page that shows it.

The agent wrote a small patch to stop script injection in my scheduling app, and I merged it. Managers type staff names, task lists and chore notes. The API used to send those back exactly as typed. Any page that pasted one into its HTML would run whatever markup was in it.

The patch escapes those fields in the code that loads them from the database, before the API sends them. The store and user loaders already did this, so it matched. The README now says every text field in a response is escaped. A new test checks that tags come back as harmless entities.

All true. I still think the escape is in the worst spot.

The read path is also the write path

To update one chore, the server loads all of the store's chores, escaped, changes the one that matches, and saves the whole list back. From the code, that means one small edit stores every other chore note in escaped form. Each pass makes it worse, because the escaper escapes its own output:

$text = 'Mop & bucket';
// after three load-and-save rounds:
// Mop & bucket

Staff does the same through the browser. The staff screen loads the list, and saving posts the whole list back. Change one lunch length and every name gets saved in its escaped form.

Most names have no ampersand, quote or angle bracket, so they come through fine. A name with an apostrophe would not. I haven't checked the real tables yet.

Where the hole actually was

The main schedule page sets names as plain text, not as HTML. It was likely safe already, and now it would show the entity codes on screen. The risky pages are a few admin screens that build rows from strings. Escaping in the loader probably looked like one fix for all of them. But the loader can't know whether a string is going into a web page, a script, a spreadsheet export or an email, and each needs different handling.

Why the test stayed green

The test brings its own copy of the escaper, and it never saves anything. So it checks the wiring and misses the round trip completely.

Encoding at the screen instead

Store text as typed. Send it as typed. Let each page encode it when it shows it, ideally by setting text instead of building HTML. If you need a read-side escape for a while, keep it away from anything that saves.

The test I'd write first is dull: change one harmless field, then check that every other record's text is byte-for-byte what it was before the edit.

Keep reading

Pick up a connected idea or branch into a nearby one.

5 paths forward