diff --git a/docs/dependency-upgrade-plan.md b/docs/dependency-upgrade-plan.md index 5190afa7..7f34eb48 100644 --- a/docs/dependency-upgrade-plan.md +++ b/docs/dependency-upgrade-plan.md @@ -1296,6 +1296,121 @@ workaround to try is `nitro.externals.inline: ['html-encoding-sniffer']`, so rol import at build time. Not attempted here: an unnecessary upgrade did not justify a second inline workaround, and the better answer is the follow-up asking whether the server needs jsdom at all. +## The `v-html` audit — done 2026-08-03 + +Not a dependency upgrade, but it came out of one: the `isomorphic-dompurify` revert prompted the +question "does the server need jsdom at all", and looking at the *sinks* instead of the dependency +found something worth fixing. Run ahead of `stripe` for the same reason as the comment audit — that +item waits on a colleague, this one did not. + +**Result: nine live bindings became four, and all four sanitise.** + +| component | before | after | +| --- | --- | --- | +| `InnerHtml.vue` | `DOMPurify.sanitize` | unchanged ✅ | +| `NewsTicker.vue` | `DOMPurify.sanitize` | unchanged ✅ | +| `ProfileCreationMainInfos.vue` | **raw prop → `v-html`** | `DOMPurify.sanitize` ✅ | +| `ProfileCreationDone.vue` | **raw prop → `v-html`** | `DOMPurify.sanitize` ✅ | +| `MeetupCard.vue` | regex strip → `v-html` | `getPlainText` → `{{ }}` — sink removed | +| `SpeakerListItem.vue` | regex strip → `v-html` | `getPlainText` → `{{ }}` — sink removed | +| `ConferenceCard.vue` | regex strip → `v-html` | `getPlainText` → `{{ }}` — sink removed | +| `PickOfTheDayListItem.vue` | regex strip → `v-html` | `getPlainText` → `{{ }}` — sink removed | +| `SearchResultCard.vue` (5 branches) | regex strip → `v-html` | `getPlainText` → `{{ }}` — sink removed | +| `PodcastPlayer.vue` ×2 | commented-out dead code | deleted | + +### Two corrections to this document's own earlier notes + +**The count was wrong.** This document said "eleven bindings" and described the two in +`PodcastPlayer.vue` as build-time SVG inlining that was "fine". They are **commented-out dead code** +calling `require()`, which would not even resolve under Vite's ESM pipeline. Nine bindings were live, +which is what ESLint's nine `vue/no-v-html` warnings had been saying all along. Deleted rather than +described. + +**"Just use `{{ }}`" was too glib**, and would have shipped a visible bug. These Directus fields are +WYSIWYG HTML containing entities — `für`, `Baukästen`, `"Moin"`. The old regex +never decoded them; it did not have to, because the value went on to `v-html` and the *browser* +decoded them. Swapping to `{{ }}` with the same regex would have rendered `für` literally on +every German umlaut on the site. + +So the swap needed real text, not tag-stripped HTML: + +| approach | entities | `&` | ` src=x onerror=…>` | +| --- | --- | --- | --- | +| regex + `v-html` (before) | ✅ browser decodes | ✅ | ❌ **live tag** | +| regex + `{{ }}` (the naive fix) | ❌ shows `für` | ❌ | ✅ inert | +| `getPlainText` + `{{ }}` (shipped) | ✅ `für` | ✅ `&` | ✅ inert | + +`helpers/getPlainText.ts` sanitises with `ALLOWED_TAGS: []` and `RETURN_DOM_FRAGMENT`, then reads +`textContent`. That returns genuine text with every entity decoded, which is safe for `{{ }}` and must +never be handed to `v-html`. It parses instead of pattern-matching, which is the whole point: a regex +cannot match a tag containing `<` or `>`, so ` src=x onerror=alert(1)>` survived +`/<[^<>]+>/g` as a working tag. + +It is deliberately **not** re-exported from `helpers/index.ts`. That barrel is imported by server +routes, and pulling `isomorphic-dompurify` through it would instantiate jsdom for consumers that only +wanted a date helper. + +### The two ProfileCreation components were worse than the regex ones + +They passed CMS rich text to `v-html` with **no filtering at all** — the regex sites at least tried. +They also cannot use `{{ }}`: `intro_text` contains `programmier.bar`, +so interpolation would destroy the brand colour. DOMPurify's default profile preserves that markup +byte-identically, verified before the change and confirmed in the rendered page afterwards. + +Three sibling components — `ProfileCreationEmojis`, `ProfileCreationInterests`, +`ProfileCreationDetails` — already render the same singleton's fields with `{{ }}` and identical CSS +classes. So these two were inconsistent outliers rather than a deliberate choice. + +### Verification + +Unit tests cover the bypass payloads, so the regex cannot come back unnoticed: `test/getPlainText.test.ts`, +5 cases, including ` src=x onerror=alert(1)>`, ` onload=…>` and the `` | `alert(1)` | inert — `innerHTML` never runs injected ` diff --git a/nuxt-app/components/PodcastPlayer.vue b/nuxt-app/components/PodcastPlayer.vue index 6fb0ae30..c6839d20 100644 --- a/nuxt-app/components/PodcastPlayer.vue +++ b/nuxt-app/components/PodcastPlayer.vue @@ -50,13 +50,6 @@ :class="isExpanded ? 'pointer-events-none invisible opacity-0' : 'delay-200 duration-500'" :style="isExpanded ? 'transition: visibility 0s .15s, opacity .15s' : undefined" > -