Skip to content

harden(web-ui): escAttr/data-* sweep across remaining attribute interpolations (#123)#126

Merged
mvalancy merged 1 commit into
devfrom
fix/123-escattr-sweep
Jul 19, 2026
Merged

harden(web-ui): escAttr/data-* sweep across remaining attribute interpolations (#123)#126
mvalancy merged 1 commit into
devfrom
fix/123-escattr-sweep

Conversation

@mvalancy

Copy link
Copy Markdown
Owner

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 in web-ui/index.html, still using text-context esc() (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):

Inline-JS string → data-* + constant delegated handler (escAttr alone is not enough here — a ' still breaks the JS string; converting removes the inline-JS interpolation entirely, mirroring dlMiniAction/triggerRequestFromEl):

  • renderSafety mod-review buttons — onclick="reviewModItem('${esc(it.id)}',…)"data-mod-id/data-mod-act + new reviewModFromEl(this).
  • startCommunityService('${…}', this) ×3 (health repair _repairBtn, tool detail, community hub — one was esc()-in-inline-JS, two were raw) → data-svc-id + new startServiceFromEl(this). Covers the ticket's "any similar onXXX=\"fn('${...}')\"".

Text context → esc() (lower risk):

  • renderProgressPanel${dl.type}esc(dl.type).

Comment: tightened the _onNotifAction comment to cite escAttr (attribute-escape at render + read-via-getAttribute), matching its siblings.

No ' or " breakout remains at any converted site: attribute values are escAttr-escaped (quotes included) and ids that used to sit in inline JS are now read from data-* via getAttribute, never interpolated into a parser as code.

Intentionally left as-is (documented residual): ~40 other attribute interpolations in index.html are fed by hardcoded catalog constants baked into the HTML (TOOLS/MODEL_FAMILIES/CONTENT_LIBRARY literals — logos, alt, href, data-search, triggerDownload('…','${constant}')), not server/free-text; and the constant-fed inline handlers setNotifFilter/setModerationSensitivity take hardcoded enum values. Two server-derived esc()-in-attribute sites in the unrelated renderPackages function (data-kind/href from /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=>:

  1. catalog Download data-id (attribute escape): no injected onmouseover, id round-trips verbatim in data-id, onclick is the constant triggerRequestFromEl(this), hover executes nothing, and clicking Download POSTs the verbatim id to /api/request.
  2. mod-review Dismiss (inline-JS → delegation): onclick is the constant reviewModFromEl(this) and contains neither the id nor the payload, no injected onmouseover, data-mod-id/data-mod-act round-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: onmouseover was injected → Received: "window.__xss=1"; mod-review: the data-mod-act button doesn't exist) and pass after the fix.

Test counts (all offline, stubbed via page.route):

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 prefer data-* + 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 for data-* + constant handlers).

Fixes #123

🤖 Generated with Claude Code

…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>
@mvalancy
mvalancy merged commit f21fa8a into dev Jul 19, 2026
1 check passed
@mvalancy
mvalancy deleted the fix/123-escattr-sweep branch July 19, 2026 00:33
mvalancy added a commit that referenced this pull request Jul 19, 2026
#126 (#123 escAttr/data-* sweep completing the #121 attribute-XSS class) + version bump. Single item, Gate-1 reviewed 2/2; release-level scope+secrets self-verified. CI green.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant