Skip to content

fix: polish & hardening — notify inbox data-id (#121), catalog empty-feed (#95), loop-lock test (#96)#122

Merged
mvalancy merged 2 commits into
devfrom
fix/polish-121-95-96
Jul 18, 2026
Merged

fix: polish & hardening — notify inbox data-id (#121), catalog empty-feed (#95), loop-lock test (#96)#122
mvalancy merged 2 commits into
devfrom
fix/polish-121-95-96

Conversation

@mvalancy

Copy link
Copy Markdown
Owner

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.sh production 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:

  • Notification dismiss/restore buttons → data-notif-act + data-id + a delegated click handler (_onNotifAction) attached to the persistent panel element (survives the innerHTML swap in setNotifFilter, GC'd on close). No id is interpolated into inline JS.
  • dl-card mini cancel/retry (web-ui/index.html ~3901) → the file's existing onclick="fn(this)" + data-* idiom (dlMiniAction(this), mirrors triggerRequestFromEl(this)). No id/type in inline JS.
  • Fixed the misleading // … esc() — XSS-safe comment 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-id present, onclick null); (b) a '+<img onerror> id neither executes (window.__xss undefined) 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 completecatalog_refresh_zim could 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 via before = len(rows) around parse() (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'd 8>&- over the function body and matched the explanatory # 8>&- : … comment line too — vacuous: deleting the operative redirect still passed. And services/*.sh daemon spawns had zero functional coverage.
Fix:

  • §7 now strips comment lines and anchors to the operative spawn line (setsid.*8>&- / timeout .*bash .*8>&-).
  • New §6b functionally drives the real ensure_services service spawn (fake long-lived daemon, hold loop.lock on fd 8 like run_locked, assert a fresh flock -n acquires 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 shipped loop.sh, 22/22 pass. Offline/stubbed; never runs loop.sh install or touches the crontab. loop.sh itself unchanged.

Learnings baked into docs/knowledge/gotchas.md under 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

mvalancy and others added 2 commits July 18, 2026 10:51
…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>
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