The Stranger in the Artist Field
The most dangerous text field in my house turned out to be the artist tag on an audio file.
Music Box builds its album header with a template literal: the artist’s name, interpolated straight into innerHTML. I wrote that line without a second thought, because I knew exactly who could reach the app. Nobody. No public URL, no accounts, no listeners outside this household. This site even published an essay to that effect (Two Apps, Not For You): these apps only work because strangers can’t hit them.
That essay went out on a Sunday morning. By Sunday evening I was escaping strangers’ writing out of the DOM.
Who wrote the artist field
The sentence was true. The inference I’d built on top of it was not. “Strangers can’t connect” had quietly become “strangers aren’t involved,” and those are different claims.
An artist tag is text a person typed. Whoever encoded the file wrote it, or copied it out of the communal tag databases that rippers share, where thousands of anonymous contributors edit the same strings. A personal library assembled over decades is a pile of other people’s keystrokes, and the player’s job is to take those keystrokes and render them. Album header, artist header, the genre filter dropdown: each one was interpolating tag text into innerHTML, which means a crafted artist name is not a name. It’s markup. An onerror handler tucked into a genre value would execute in the browser of whoever opened the filter, which is to say: in mine.
Stored cross-site scripting, where the store is the music library itself. The timeline is the part that stuck. The injection point was written years before the application it attacks existed. Whoever tagged those files finished their work long ago, with no idea that some future stranger would build a player around their text and hand it the DOM.
My threat model had asked one question: who can open a connection to this server? The answer (my own devices) was so comfortable that I never asked the second question: who has ever written a byte this server parses? That set is enormous. It includes every ripper, tagger, and database editor who ever touched a file I later saved. The private network screens who can knock on the door. It does not screen what I carry in through it.
The fix, and the sibling
The fix is deliberately boring. Header text is now assigned through textContent, which cannot be markup no matter what it contains. The dropdown options go through an escapeHtml helper. And the three <img src="${...}"> interpolations were moved to property assignment even though those values are database integers today. “Today” is the load-bearing word; the whole bug was an assumption that held right up until the input was older than the app.
Twenty-three minutes after that commit, the sibling app got one of its own. Once the question flips from “who can connect” to “what do I parse,” you reread everything with new eyes, and Home Movies had its own comfortable answer sitting in the streaming endpoint. It takes two URL parameters, an episode id and a file name, and joins them onto a cache directory. The file name was checked against a blacklist: no .., no slashes. The episode id was not checked at all. It went into path.join exactly as the client sent it.
Both parameters are now allowlisted against the only values they can legitimately have: sixteen hex characters for the id, and for the file name, the short fixed set a streaming session actually produces (a playlist, an init segment, numbered media segments, subtitle renditions). The endpoint no longer refuses the bad inputs it can think of; it refuses everything except the good ones it can prove.
What the perimeter is for
I still believe the essay from that Sunday morning. Keeping these apps off the public internet is the correct call, and it does real work: it keeps out every attacker who needs a route to the server. What it cannot do is make the input trustworthy, because the input doesn’t arrive over the network. It arrives in the library, carried past the perimeter by me, one file at a time. The boundary that matters runs somewhere else entirely: at every point where somebody else’s bytes meet an interpreter. No firewall moves that line an inch.
I verified the fix in a headless browser: an image tag with an onerror handler, planted in a tag field, comes out as inert escaped text. If a payload like that is sitting somewhere in the library’s genre strings, the dropdown will now print it exactly as written, angle brackets and all, like a band with a very stupid name.
Which is all it gets to be.