From 96a6fb4f32e5dd992bc6bf7119acde5058e289a6 Mon Sep 17 00:00:00 2001 From: Jan Gregor Emge-Triebel Date: Mon, 3 Aug 2026 18:44:59 +0200 Subject: [PATCH 1/4] Audit the v-html sites: 9 live bindings become 4, all sanitising Five card/search components regex-stripped tags and passed the result to v-html. `/<[^<>]+>/g` cannot match a tag containing < or >, so removing an inner tag reassembled a working one: src=x onerror=alert(1)> survived as and executed. Those five now use helpers/getPlainText.ts and {{ }}, removing the sink. It sanitises with ALLOWED_TAGS: [] and RETURN_DOM_FRAGMENT, then reads textContent, so it parses rather than pattern-matches. A plain {{ }} swap with the old regex would have shipped a visible bug. These Directus fields carry entities (für, ") that the regex never decoded -- it did not need to, because v-html let the browser do it. Interpolating that directly would print für on every umlaut. ProfileCreationMainInfos and ProfileCreationDone passed CMS rich text to v-html with no filtering at all, and cannot use {{ }}: intro_text carries the brand-colour span. They now sanitise with DOMPurify, which preserves that markup byte-identically. Two commented-out v-html bindings in PodcastPlayer.vue are deleted. They called require(), which does not resolve under Vite ESM. This document had described them as fine build-time SVG inlining; they were dead. getMetaInfo.ts keeps the same regex on purpose: it writes to a attribute, not innerHTML, and Nuxt escapes attribute values -- verified against a description containing a raw quote. Verified in a browser, since these lists are client-rendered: 5 pages, 30 descriptions, zero entities in rendered text, zero child elements, zero hydration warnings. Lint warnings 134 -> 129. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JkKMceYAzAYLrSyC42FWTf --- docs/dependency-upgrade-plan.md | 145 ++++++++++++++---- nuxt-app/components/ConferenceCard.vue | 9 +- nuxt-app/components/MeetupCard.vue | 9 +- nuxt-app/components/PickOfTheDayListItem.vue | 10 +- nuxt-app/components/PodcastPlayer.vue | 13 -- nuxt-app/components/ProfileCreationDone.vue | 10 +- .../components/ProfileCreationMainInfos.vue | 8 +- nuxt-app/components/SearchResultCard.vue | 16 +- nuxt-app/components/SpeakerListItem.vue | 9 +- nuxt-app/helpers/getPlainText.ts | 30 ++++ nuxt-app/test/getPlainText.test.ts | 49 ++++++ 11 files changed, 237 insertions(+), 71 deletions(-) create mode 100644 nuxt-app/helpers/getPlainText.ts create mode 100644 nuxt-app/test/getPlainText.test.ts diff --git a/docs/dependency-upgrade-plan.md b/docs/dependency-upgrade-plan.md index 5190afa7..75f60656 100644 --- a/docs/dependency-upgrade-plan.md +++ b/docs/dependency-upgrade-plan.md @@ -1296,6 +1296,115 @@ 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" > -