harden(web-ui): escAttr/data-* sweep across remaining attribute interpolations (#123)#126
Merged
Conversation
…polations (#123) Completes the #121 hardening class: route every remaining server/catalog-derived id through escAttr() (attribute context) or the data-* + delegated-handler pattern, so a quote in an id can never break out of its HTML attribute or an inline-JS string. All sites are server/catalog-constrained ids today (not exploitable free-text), but the codebase now has the correct helper — this makes them consistent. Attribute-escape (esc()/raw -> escAttr()): - catalogCardHtml: data-id + data-search (server catalog ids) - renderFullWikipediaHero + renderContentDetail: data-id="${requestId}" (was raw) - renderProgressPanel: ${dl.type} text -> esc() Inline-JS string -> data-* + constant delegated handler: - reviewModItem('${esc(it.id)}', ...) -> data-mod-id/data-mod-act + reviewModFromEl(this) - startCommunityService('${...}', this) x3 (health repair, tool detail, community hub) -> data-svc-id + startServiceFromEl(this) A `'` in the id can no longer break out of a JS string that no longer exists, and the data-* value is escAttr-escaped so a `"` can't break out of the attribute either. Also: tighten the _onNotifAction comment to cite escAttr, matching its siblings. Tests: new tests/screenshots/specs/escattr-sweep.spec.ts (2 tests) drives the two highest-value converted sites with one id carrying " + ' + ');payload + onmouseover + <img onerror>: the catalog data-id (attribute escape) and the mod-review buttons (inline-JS -> delegation). Each asserts no injected handler attribute, no script execution on hover/click, and the real action still POSTs the verbatim id. Verified fail-without (both fail against the reverted pre-fix code) / pass-with. Knowledge: append a distinct anchor to .agents/knowledge/pipeline-insights.md — a hardening PR must prove the exact failure mode (test the real vector, not a harmless variant), durable docs must state accurate invariants, and prefer data-* + delegation over interpolating data into markup/inline-JS. Fixes #123 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This was referenced Jul 19, 2026
Closed
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 from the #121/#122 re-review. #121 added
escAttr()(escapes& < > " ') and applied it to the notification / dl-card / dl-panel-Cancel attribute sites. The review found the SAME class elsewhere inweb-ui/index.html, still using text-contextesc()(no quote escaping) or raw interpolation. All values are server/catalog-constrained ids today (not exploitable free-text), but the codebase now has the correct helper, so this is a consistency sweep completing the #121 hardening class.Sites fixed (per-site fix)
Attribute context →
escAttr()(a"in the value can no longer break out of the double-quoted attribute and inject an event handler):catalogCardHtml—data-id="${esc(it.id)}"→ escAttr (the exact esc-in-attribute pattern harden(web-ui): notification inbox passes id via inline onclick — safe today only by server id-constraint, not esc(); use data-id + delegation #121 warns against); also folded indata-searchon the same element / sameit(wasesc()), since leaving it would be inconsistent with the fixeddata-idbeside it.renderFullWikipediaHero—data-id="${full.requestId}"(was raw) → escAttr.renderContentDetail—data-id="${item.requestId}"(was raw) → escAttr.Inline-JS string →
data-*+ constant delegated handler (escAttralone is not enough here — a'still breaks the JS string; converting removes the inline-JS interpolation entirely, mirroringdlMiniAction/triggerRequestFromEl):renderSafetymod-review buttons —onclick="reviewModItem('${esc(it.id)}',…)"→data-mod-id/data-mod-act+ newreviewModFromEl(this).startCommunityService('${…}', this)×3 (health repair_repairBtn, tool detail, community hub — one wasesc()-in-inline-JS, two were raw) →data-svc-id+ newstartServiceFromEl(this). Covers the ticket's "any similaronXXX=\"fn('${...}')\"".Text context →
esc()(lower risk):renderProgressPanel—${dl.type}→esc(dl.type).Comment: tightened the
_onNotifActioncomment to citeescAttr(attribute-escape at render + read-via-getAttribute), matching its siblings.No
'or"breakout remains at any converted site: attribute values areescAttr-escaped (quotes included) and ids that used to sit in inline JS are now read fromdata-*viagetAttribute, never interpolated into a parser as code.Intentionally left as-is (documented residual): ~40 other attribute interpolations in
index.htmlare fed by hardcoded catalog constants baked into the HTML (TOOLS/MODEL_FAMILIES/CONTENT_LIBRARYliterals — logos,alt,href,data-search,triggerDownload('…','${constant}')), not server/free-text; and the constant-fed inline handlerssetNotifFilter/setModerationSensitivitytake hardcoded enum values. Two server-derivedesc()-in-attribute sites in the unrelatedrenderPackagesfunction (data-kind/hreffrom/api/packages, a constrained enum + server-generated archive path) were not flagged by the #121/#122 review and are out of this ticket's scope — noted here as a candidate for a future pass rather than widening this diff.Tests
New
tests/screenshots/specs/escattr-sweep.spec.ts(2 tests) drives the two highest-value converted sites with one adversarial id carrying every vector at once —"(attribute breakout) +'/');payload(inline-JS breakout) +onmouseover=+<img … onerror=>:data-id(attribute escape): no injectedonmouseover, id round-trips verbatim indata-id,onclickis the constanttriggerRequestFromEl(this), hover executes nothing, and clicking Download POSTs the verbatim id to/api/request.onclickis the constantreviewModFromEl(this)and contains neither the id nor the payload, no injectedonmouseover,data-mod-id/data-mod-actround-trip verbatim, hover executes nothing, and clicking Dismiss POSTs the verbatim{id, action}to/api/moderation/review.Fail-without / pass-with verified: both tests fail against the temporarily-reverted pre-fix code (catalog:
onmouseoverwas injected →Received: "window.__xss=1"; mod-review: thedata-mod-actbutton doesn't exist) and pass after the fix.Test counts (all offline, stubbed via
page.route):health+notifications(incl. the harden(web-ui): notification inbox passes id via inline onclick — safe today only by server id-constraint, not esc(); use data-id + delegation #121 sibling breakout tests) +nav+ui-exercise(clicks every button on every route, exercising the new wrappers) + new spec = 60 passed.web-ui.spec.ts: 226 passed, 33 skipped, 1 failed — the one failure (ZIM content directory exists) is a purefs.existsSynccheck forcontent/zim, which isn't materialized in a fresh worktree (runtime appliance symlink); unrelated to this diff and fails on cleandevhere too.index.htmlor these render functions, so none are affected.Knowledge
Appended a distinct anchor to
.agents/knowledge/pipeline-insights.md: a hardening/security PR must prove the specific failure mode it claims to close (test the exact attack, not a harmless variant — and verify fail-without by reverting), durable docs must state accurate invariants (the review once blocked a PR that shipped a false "now safe" guarantee —esc()in a double-quoted attribute, #121), and preferdata-*+ delegation over interpolating data into markup/inline-JS.Residual risk
Low. All swept ids are server/catalog-constrained today, so this is defense-in-depth; the change is behavior-preserving (delegated handlers read the same values via
getAttribute; dl/mod/community actions verified working). Web UI stays vanilla/zero-dep; both themes + a11y unaffected (no markup structure change beyond swapping inline handlers fordata-*+ constant handlers).Fixes #123
🤖 Generated with Claude Code