Somewhere in PIM’s output area, a project card renders with a clickable link that opens it —
/open <projectId> baked into an onclick handler as a plain JavaScript string. That’s fine as
long as projectId is a UUID PIM generated itself. It stops being fine the moment any part of that
handler is built from text a user actually typed, because a single stray quote character in that
text can close the JavaScript string early and let whatever comes after it run as code instead of
sitting there as data.
We found this exact shape three separate times in the same week, working through Plan’s new
visualizations. The first instance was pre-existing — a project’s free-text name spliced directly
into ProjectViewRenderer’s generated onclick string, sitting there from an earlier session.
Fixed by moving the id into an HTML-escaped data-project-id attribute instead, read back at click
time rather than embedded in the JavaScript source at all — the standard fix for exactly this shape
of bug.
Then it came back. Building Project’s new Gantt-style timeline view a few days later, the exact same
pattern got written fresh into a brand-new class, TimelineBar, this time for the click-through
that opens a project from its own timeline bar. Same root cause, same fix, just in code that hadn’t
existed a week earlier — proof that knowing about a bug once doesn’t automatically stop you from
reintroducing its shape somewhere new.
The third time was different. Adding drag-and-drop to the same two boards for kanban, the exact same
class of mistake was available again — a free-text project name or plan item title needing to
travel from a drag gesture to a drop handler — and this time it got caught before it ever shipped,
using HTML5’s own DataTransfer object to carry the value across the drag instead of splicing it
into generated JavaScript at all.
The actual fix, both times it mattered, is almost boring: never let a value you don’t fully control
become part of the JavaScript source string itself. A UUID or a fixed status name from a known list
is safe to splice in directly, because there’s nothing a user could type into it that would contain
a quote. Anything a user or the AI actually wrote — a project name, an item title — goes into an
HTML attribute or a DataTransfer payload instead, and gets read back as data, never executed as
code. The pattern is now something to check for by reflex whenever a new renderer wraps a free-text
field into a click or drag handler, rather than something to rediscover the hard way a fourth time.