fix: polish & hardening — notify inbox data-id (#121), catalog empty-feed (#95), loop-lock test (#96)#122
Merged
Merged
Conversation
…feed (#95), loop-lock test (#96) Three small, independent follow-ups from prior Gate-1 reviews. #121 (web-ui XSS defense-in-depth): esc() escapes <>& but NOT quotes, so an inline onclick="dismissNotif('${esc(id)}')" had zero XSS safety net — safe only by the server id-constraint. Convert the notification dismiss/restore buttons to data-id + a delegated click handler on the persistent panel (no data in inline JS), and the dl-card mini cancel/retry buttons to the file's onclick="fn(this)" + data-* idiom. Fix the misleading "esc() → XSS-safe" comment. Extend notifications.spec.ts: assert dismiss works via the new mechanism (data-id present, no onclick) and that a quote/JS-bearing id neither executes nor breaks dismiss. #95 (catalog empty-feed): kiwix_catalog.py's completeness signal was exception-based, so an HTTP-200 well-formed but ENTRY-LESS feed for a requested language parsed to 0 rows and counted as complete — it could atomically replace a fuller cache and silently drop that language. Treat 0 rows for a requested language as INCOMPLETE (return 2), preserving #57's invariant and the bootstrap-empty-cache exception. Offline fixture (200/empty feed) in test-catalog-cache.sh asserts the prior fuller cache is kept byte-identical. #96 (loop-lock test): test-loop-lock.sh §7 grep'd 8>&- and matched the comment line too (vacuous — deleting the operative redirect still passed). Strip comment lines / anchor to the operative spawn line, and add functional coverage (§6b) driving ensure_services' service spawn like §6 drives _va_start_web, asserting loop.lock is free afterward. No loop.sh production change. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…te-breakout XSS (#121) The #121 hardening moved the notification dismiss/restore buttons and the dl-card mini buttons to `data-id="${esc(n.id)}"` + a delegated handler. But esc() (textContent->innerHTML) escapes < > & and NOT quotes, and the value is interpolated into a DOUBLE-QUOTED attribute — so a double quote in the id breaks out of the attribute and injects a real event-handler attribute (e.g. onmouseover) that fires on hover, no < > required. Moving the id to data-id fixed inline-JS injection but left this attribute-breakout open, and the code comments + the gotchas.md #121 entry asserted a false "can never become executable script" invariant — poisoning the shared knowledge base. Fix: - Add escAttr() (escapes & < > " ') for attribute-context interpolation and use it for data-id/data-type in the notif buttons + dl-card mini (the two #121 paths). The delegated getAttribute read is unchanged (injection-safe). - Harden the pre-existing same-class site the review flagged: the dl-panel Cancel button used raw `cancelDownload('${id}')` in an inline onclick — convert to the data-* + delegation pattern (reuses dlMiniAction), escAttr on data-id. - Correct the false invariant in the code comments AND the gotchas.md #121 entry: interpolating into an HTML attribute requires attribute-context escaping (quotes included) or setAttribute/dataset; esc() (text context) is NOT sufficient. Tests (offline, no network): new double-quote + onmouseover adversarial cases in notifications.spec.ts and web-ui.spec.ts assert no injected handler attribute is parsed onto the button, the payload stays inside data-id (round-trips verbatim), window.__xss stays undefined on hover, and dismiss/cancel still work via delegation. Both FAIL against the pre-escAttr render and PASS after the fix; the existing 9 notification assertions + the dl-card render test stay green. Scope: #121 sub-slice only — #95 (catalog) and #96 (loop-lock test) untouched. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This was referenced Jul 18, 2026
Closed
mvalancy
added a commit
that referenced
this pull request
Jul 18, 2026
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.
Three small, independent polish/hardening follow-ups from prior Gate-1 reviews. Each was independently re-verified against the code before fixing; each ships fail-without/pass-with offline tests. No
loop.shproduction change; server stays zero-dep; web UI stays vanilla.#121 — web-ui XSS defense-in-depth (notification inbox inline onclick)
What was broken: The notification inbox rendered
onclick="dismissNotif('${esc(n.id)}')"(and a restore variant).esc()(textContent→innerHTML) escapes<>&but not quotes, so it gives zero protection for a value in a JS-string-in-attribute context — safety rested entirely on the server id-constraint. A latent trap: the moment an id is derived from less-constrained data it becomes stored XSS with no safety net.Fix:
data-notif-act+data-id+ a delegated click handler (_onNotifAction) attached to the persistent panel element (survives theinnerHTMLswap insetNotifFilter, GC'd on close). No id is interpolated into inline JS.web-ui/index.html~3901) → the file's existingonclick="fn(this)"+data-*idiom (dlMiniAction(this), mirrorstriggerRequestFromEl(this)). No id/type in inline JS.// … esc() — XSS-safecomment to state esc() does not cover the onclick case.Test:
notifications.spec.ts+2 cases — (a) dismiss works via the new mechanism (data-notif-act/data-idpresent,onclicknull); (b) a'+<img onerror>id neither executes (window.__xssundefined) nor breaks dismiss. Both fail without the fix (2 failed), pass with it. Full spec 9/9; Activity dl-card render test 14/14.#95 — catalog: HTTP-200 empty feed treated as INCOMPLETE
What was broken:
kiwix_catalog.py's completeness signal was exception-based. A well-formed HTTP-200 but entry-less feed for a requested language parsed to 0 rows and counted as complete →catalog_refresh_zimcould atomically replace a fuller multi-language cache with the entry-less subset and silently drop that language (unlike the exception path, it did not self-preserve).Fix: Treat 0 rows for a requested language as INCOMPLETE (
return 2), counting rows per language viabefore = len(rows)aroundparse()(fires on a truly empty feed, not on cross-language duplicate URLs). Preserves #57's invariant (a partial fetch never replaces a fuller cache) and the bootstrap-empty-cache exception (stdout still carries fetched rows). Docstring updated.Test:
test-catalog-cache.sh+§8b/§8c with a fixture 200/empty feed (EMPTY_LANGS) — asserts the empty-feed refresh exits non-zero and the prior fuller cache stays byte-identical (English never dropped). 4 assertions fail without the fix (34/4), all pass with it (38/0). No real network.#96 — test: strengthen #81 loop-lock coverage
What was broken:
test-loop-lock.sh§7 grep'd8>&-over the function body and matched the explanatory# 8>&- : …comment line too — vacuous: deleting the operative redirect still passed. Andservices/*.shdaemon spawns had zero functional coverage.Fix:
setsid.*8>&-/timeout .*bash .*8>&-).ensure_servicesservice spawn (fake long-lived daemon, holdloop.lockon fd 8 likerun_locked, assert a freshflock -nacquires it afterward) — mirrors how §6 drives_va_start_web.Test: Against a mutant that drops the operative
8>&-(comment kept), the OLD bare grep passed (vacuous) while both new assertions (structural §7 + functional §6b) fail; against the shippedloop.sh, 22/22 pass. Offline/stubbed; never runsloop.sh installor touches the crontab.loop.shitself unchanged.Learnings baked into
docs/knowledge/gotchas.mdunder three distinct anchors (esc-not-onclick-safe-121,catalog-empty-feed-95,grep-operative-not-comment-96).Fixes #121
Fixes #95
Fixes #96
🤖 Generated with Claude Code