Skip to content

Audit the v-html sites: 9 live bindings → 4, all sanitising - #237

Merged
Jan0707 merged 4 commits into
mainfrom
infrastructure-improvements
Aug 4, 2026
Merged

Audit the v-html sites: 9 live bindings → 4, all sanitising#237
Jan0707 merged 4 commits into
mainfrom
infrastructure-improvements

Conversation

@Jan0707

@Jan0707 Jan0707 commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Came out of the isomorphic-dompurify revert. That prompted "does the server need jsdom at all", and looking at the sinks instead of the dependency found something worth fixing.

The defect

Five components stripped tags with a regex, then passed the result to v-html:

props.speaker.description.replace(/<[^<>]+>/g, '')

That pattern cannot match a tag containing < or >, so removing an inner tag reassembles a working one:

input after strip → v-html
<img<a> src=x onerror=alert(1)> <img src=x onerror=alert(1)> executes
<svg<a> onload=alert(1)> <svg onload=alert(1)> executes

And two components — ProfileCreationMainInfos, ProfileCreationDone — passed CMS rich text to v-html with no filtering at all. Worse than the regex ones, which at least tried.

Reachability, stated plainly: every input is CMS-authored, so exploiting this needs Directus write access or a tampered Algolia index. Defence in depth, not an open door. The strongest argument for fixing it was never the exploit — it's that four components were pushing plain text through an HTML sink for no reason.

Result

component before after
InnerHtml.vue, NewsTicker.vue DOMPurify.sanitize unchanged ✅
ProfileCreationMainInfos.vue, ProfileCreationDone.vue raw prop → v-html DOMPurify.sanitize
MeetupCard, SpeakerListItem, ConferenceCard, PickOfTheDayListItem, SearchResultCard (5 branches) regex → v-html getPlainText{{ }}sink removed
PodcastPlayer.vue ×2 commented-out dead code deleted

Two corrections to my own earlier plan

The count was wrong. The plan said "eleven bindings" and called the two in PodcastPlayer.vue fine build-time SVG inlining. They're commented-out dead code calling require(), which wouldn't resolve under Vite ESM. Nine were live — which is what ESLint's nine vue/no-v-html warnings had been saying all along.

"Just use {{ }}" would have shipped a visible bug. These fields are WYSIWYG HTML containing entities — f&uuml;r, Bauk&auml;sten, &quot;Moin&quot;. The regex never decoded them; it didn't have to, because the value went to v-html and the browser decoded it. Swapping to {{ }} with the same regex would have printed f&uuml;r on every German umlaut on the site.

approach entities &amp; bypass payload
regex + v-html (before) ✅ browser decodes live tag
regex + {{ }} (naive fix) f&uuml;r ✅ inert
getPlainText + {{ }} (shipped) für & ✅ inert

helpers/getPlainText.ts sanitises with ALLOWED_TAGS: [] + RETURN_DOM_FRAGMENT, then reads textContent — genuine text, every entity decoded. It parses instead of pattern-matching, which is the whole point.

Deliberately not in 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.

Why the ProfileCreation pair kept v-html

They can't use {{ }}intro_text contains <strong>programmier.<span style="color: #cfff00;">bar</span></strong>, so interpolation destroys the brand colour. DOMPurify's default profile preserves it byte-identically; verified offline and confirmed in the rendered page.

Also notable: three sibling components (ProfileCreationEmojis, ProfileCreationInterests, ProfileCreationDetails) already render the same singleton's fields with {{ }} and identical CSS classes. These two were inconsistent outliers, not a deliberate choice.

Verification

Unit tests lock in the bypass so the regex can't return unnoticed — test/getPlainText.test.ts, 5 cases including <img<a> src=x onerror=…>, <svg<a> onload=…> and the <math><mtext><script> mXSS vector.

Rendered output needed a browser, because SpeakerList, PickOfTheDayList and SearchResultCard are client-rendered — SSR HTML shows nothing for them. Five pages: 30 descriptions rendered, 0 HTML entities in rendered text, 0 child elements inside any description, 0 hydration warnings. That last one matters — getPlainText runs under jsdom server-side and the real DOM client-side, so a parsing difference would surface as a hydration mismatch.

Two of my checks failed before the code did, both the check's fault:

  • It flagged /konferenz for Web &amp; AI Edition 2026 — that's Vue correctly escaping the literal & the helper now produces, and the browser decodes it back. I was reading source HTML where I should have read rendered text.
  • It reported "0 descriptions — none ✓" for /hall-of-fame and /pick-of-the-day and called it a pass. Those components aren't on those pages at all; they're on the detail routes. A zero count now fails.
gate before after
lint 0 errors, 134 warnings 0 errors, 129 — five fewer vue/no-v-html
test 52/52 57/57
ratchet 263 263
build exit 0, 31.3 MB exit 0, 31.3 MB
jsdom in client bundle absent, confirmed

Deliberate non-changes

helpers/getMetaInfo.ts:48 keeps the regex. It's the only other use of the pattern, and it is not an innerHTML sink — output goes to <meta content="...">. Checked rather than assumed: one CMS description has a raw " in its first 160 chars, and production renders it as &quot;, so Nuxt escapes attribute values. A surviving <img onerror=…> is inert there. Fixing it would also drag isomorphic-dompurify into the helpers barrel for no security gain.

Three files left prettier-dirty exactly as they already were on main (SpeakerListItem, ConferenceCard, SearchResultCard). Formatting SearchResultCard alone would reindent its entire template from 2 to 4 spaces, which has no business in this PR.

🤖 Generated with Claude Code

https://claude.ai/code/session_01JkKMceYAzAYLrSyC42FWTf

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: <img<a> src=x onerror=alert(1)>
survived as <img src=x onerror=alert(1)> 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&uuml;r, &quot;) that the regex
never decoded -- it did not need to, because v-html let the browser do
it. Interpolating that directly would print f&uuml;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 <meta
content> 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JkKMceYAzAYLrSyC42FWTf
Copilot AI lite review requested due to automatic review settings August 3, 2026 16:45
@vercel

vercel Bot commented Aug 3, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
programmierbar-website Ready Ready Preview Aug 4, 2026 7:23am

Request Review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 96a6fb4f32

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

}>()

// CMS rich text, so it keeps its markup — but it must be sanitised before reaching v-html.
const sanitizedIntroText = computed(() => DOMPurify.sanitize(props.introText))

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Route rich text through the shared sanitizer

When the sanitization policy or dependency needs to change, this component and ProfileCreationDone.vue will not inherit updates made to the existing repository-wide abstraction in InnerHtml.vue; both newly duplicate its exact computed(() => DOMPurify.sanitize(...)) implementation. Render these fields through InnerHtml or extract a shared sanitization helper so the policy remains centralized.

AGENTS.md reference: AGENTS.md:L69-L73

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed, and acted on in 34f5fc3. This is the strongest comment on the PR.

You're right that I created the duplication, and right that InnerHtml.vue already was the abstraction. I'd considered rendering these fields through InnerHtml and rejected it for a reason that stands — it carries its own .inner-html styling (lime links, font-black on <strong>, whitespace-pre-line) which would visibly change the ProfileCreation design. But I stopped there instead of taking your second option, and the AGENTS.md rule you cite applies regardless of which one I picked:

Never duplicate logic across modules. If a pattern … is used in more than one place, extract it into a shared abstraction with a clear, generic name.

So there is now one module that owns the policy, helpers/sanitize.ts:

export for
sanitizeHtml default policy, anything bound to v-html
sanitizeInlineHtml as above but forbids <p>, for the news ticker's single scrolling line
getPlainText real plain text, for {{ }}

I went further than the comment asked, because the half-measure was worse. Fixing only my two additions would have left InnerHtml.vue and NewsTicker.vue still calling DOMPurify.sanitize inline — two of four policies centralised, which is the appearance of the rule rather than the rule. DOMPurify now appears in exactly one file:

$ grep -rn "DOMPurify" components/ helpers/ pages/ composables/ | grep -v helpers/sanitize.ts
(no matches)

NewsTicker's { FORBID_TAGS: ['p'] } became sanitizeInlineHtml rather than a config parameter — naming the intent keeps DOMPurify options from leaking back out to callers, which is the thing that made this drift in the first place.

The tests now cover the policy rather than only its callers: rich markup survives (including the brand-colour <span>), the ticker still loses <p> while keeping its text, and no payload leaves a live tag. The danger check is itself asserted against unsanitised input, so those assertions can't pass vacuously — my first attempt at it did, by matching onerror in text where it was inert content rather than an attribute.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Audits and hardens v-html usage in the Nuxt app by removing unnecessary HTML sinks (switching to plain interpolation with decoded plain text) and ensuring remaining sinks sanitize CMS-authored rich text. Adds a dedicated getPlainText helper plus unit tests to prevent reintroducing regex-based tag stripping.

Changes:

  • Added helpers/getPlainText.ts (DOMPurify-based plain-text extraction with entity decoding) and test/getPlainText.test.ts.
  • Replaced regex + v-html excerpt rendering with getPlainText + {{ }} in multiple card/list components.
  • Added DOMPurify sanitization for the remaining rich-text v-html bindings and removed dead commented-out v-html code in PodcastPlayer.vue.

Reviewed changes

Copilot reviewed 11 out of 11 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
nuxt-app/helpers/getPlainText.ts New helper to convert CMS rich text into decoded plain text for safe interpolation.
nuxt-app/test/getPlainText.test.ts New unit tests covering entity decoding and regex-bypass payloads.
nuxt-app/components/MeetupCard.vue Removes v-html excerpt sink; renders getPlainText(...) via interpolation.
nuxt-app/components/ConferenceCard.vue Removes v-html excerpt sink; renders getPlainText(...) via interpolation.
nuxt-app/components/SpeakerListItem.vue Removes v-html excerpt sink; renders getPlainText(...) via interpolation.
nuxt-app/components/PickOfTheDayListItem.vue Removes v-html excerpt sink; renders getPlainText(...) via interpolation.
nuxt-app/components/SearchResultCard.vue Removes v-html excerpt sink and uses getPlainText(...) across type branches.
nuxt-app/components/ProfileCreationMainInfos.vue Keeps v-html but sanitizes CMS rich text before binding.
nuxt-app/components/ProfileCreationDone.vue Keeps v-html but sanitizes CMS rich text before binding.
nuxt-app/components/PodcastPlayer.vue Deletes commented-out dead v-html + require() SVG inlining.
docs/dependency-upgrade-plan.md Documents the audit outcome and rationale in the upgrade plan.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 33 to 37
import type { PropType } from 'vue'
import { computed, defineComponent } from 'vue'
import { getPlainText } from '~/helpers/getPlainText'
import type { ConferenceItem, MeetupItem } from '../types';
import LinkButton from './LinkButton.vue'

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removed in 34f5fc3MeetupItem was indeed unused, and the trailing semicolon is gone with it.

One correction to the reasoning though: it does not fail linting here. Verified:

$ npx eslint components/ConferenceCard.vue
  36:31  warning  'MeetupItem' is defined but never used  @typescript-eslint/no-unused-vars
✖ 1 problem (0 errors, 1 warning)

no-unused-vars is deliberately set to warn in eslint.config.mjs, and CI gates on errors — that was a considered choice when the flat config landed, because switching @nuxt/eslint-config@1 on as-is turned 0 errors into 121 across a pre-existing backlog. So this was already there, already reported, and not blocking.

Worth fixing anyway since the line was in my diff: warnings 129 → 128.

Comment on lines 1 to 5
<script setup lang="ts">
import ProfilePicture from '~/components/ProfilePicture.vue'
import { useProfileCreationStore } from '~/composables/useProfileCreationStore'
import DOMPurify from 'isomorphic-dompurify'
import { computed, ref, watch } from 'vue'

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This one is not correct, and it's worth being precise because it claims a compile/runtime error.

storeToRefs is auto-imported by @pinia/nuxt, which the app has in modules. From the generated types:

.nuxt/imports.d.ts:73
export { defineStore, acceptHMRUpdate, usePinia, storeToRefs } from '@pinia/nuxt/dist/runtime/composables'

.nuxt/types/imports.d.ts:96
const storeToRefs: typeof import('@pinia/nuxt/dist/runtime/composables').storeToRefs

So no import is needed. Corroborated three ways: build exits 0, vue-tsc reports no new errors (ratchet steady at 263, and a missing identifier would be an error not a warning), and the page renders correctly on the deployed preview — mainInfos is populated and the intro text shows with its brand-colour span intact.

Two other things worth noting: the line is pre-existing and untouched by this PR — I added a DOMPurify import above it and changed defineProps to const props = defineProps, neither of which removes anything. And the premise that "other ProfileCreation components import it explicitly" doesn't hold either; the ones that use it rely on the same auto-import.

No change made.

Re-ran the rendered-output check against the Vercel preview, not just a
local node .output/server, since that runtime is where isomorphic-dompurify
broke in Phase 5. Three of four pages clean.

The podcast detail page logs one hydration mismatch there. It is not this
change: production, which does not have it, logs the identical warning on
the same page. Logged as its own follow-up, with the detail that makes it
findable -- it appears on the preview and on production but not on a local
build of the same commit, so it tracks the deployment environment rather
than the code.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JkKMceYAzAYLrSyC42FWTf
Copilot AI review requested due to automatic review settings August 3, 2026 16:50
@Jan0707

Jan0707 commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Verification — including one finding that is not this PR

All checks green. Smoke 18/18 in 17.0s, clean.

I then re-ran the rendered-output check against the Vercel preview, not just the local node .output/server — that runtime is where isomorphic-dompurify broke in Phase 5, and getPlainText depends on jsdom there.

page descriptions entities child elements hydration
/meetup 10 0 0 0 ✅
/konferenz 3 0 0 0 ✅
/suche (search: typescript) 10 0 0 0 ✅
/podcast/deep-dive-24-… 6 0 0 1 ⚠️

The hydration warning is pre-existing, and I checked rather than assumed

Hydration completed but contains mismatches.

Production does not have this change and logs the identical warning on the same page:

environment has this change hydration warnings
local node .output/server yes 0
Vercel preview yes 1
production (main) no 1

So it tracks the deployment environment, not the code. That pattern is also the useful part of the finding: something renders differently between the ISR-cached HTML and the client, which points at time- or cache-dependent output rather than markup. useNow.ts and the toLocaleDateString calls on that page are the first places to look.

Logged as its own follow-up in the plan (a44577a) rather than fixed here — it is unrelated to v-html, and chasing it inside this PR would mix two changes. Reproducing it in dev mode will get Vue to name the offending element; production builds only emit the terse message.

Why I bothered checking the preview separately

The local run and the preview run disagreed, and only one of them reflects what users get. Had I stopped at the local 0 hydration warnings, I would have reported this PR as fully clean and quietly shipped past a real (if pre-existing) defect on the exact page this PR touches — which would have made the next person's bisect point straight at getPlainText.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.

Suppressed comments (1)

nuxt-app/components/ConferenceCard.vue:36

  • MeetupItem is imported but not used in this component, and the statement also has a trailing semicolon (the repo generally uses no semicolons). Removing the unused type import keeps the module tidy and avoids unused-import lint warnings if/when they’re tightened.
import type { PropType } from 'vue'
import { computed, defineComponent } from 'vue'
import { getPlainText } from '~/helpers/getPlainText'
import type { ConferenceItem, MeetupItem } from '../types';

My first note pointed at useNow.ts and date formatting. Both are wrong:
useNow is not used on that page and exists to prevent this, and ISR
staleness is ruled out because the cached and freshly rendered HTML are
byte-identical.

Records what is ruled out so nobody repeats it, including a
<template><!----></template> that looked conclusive but appears on every
page -- an artefact of comparing innerHTML, since browsers put template
content in .content.

Dev mode did not reproduce cleanly: it failed to serve the page chunk, so
the client rendered the error page against a real server render and every
warning was an artefact of that.

Also logs two real bugs found on the way:

  - useWeightedRandomSelection seeds off an hourly bucket and renders on
    four ISR-cached pages, so a client in the next hour bucket selects
    different testimonials than the cached HTML. A genuine latent instance
    of this same class, just not on the page that warns.
  - useLoadingScreen keeps isLoading in a module-scope ref, which on the
    server is shared across concurrent requests. LoadingScreen is the
    first child of <main>, where the divergence appears. Worth fixing
    either way -- SSR state belongs in useState.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JkKMceYAzAYLrSyC42FWTf

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 11 out of 11 changed files in this pull request and generated no new comments.

Codex was right, and it cites a rule this repo already states: AGENTS.md
says never duplicate logic across modules and extract shared abstractions.
The two ProfileCreation components each grew their own
computed(() => DOMPurify.sanitize(...)), duplicating InnerHtml.vue, which
would have made three places to edit for one policy change.

helpers/sanitize.ts is now the only module that touches DOMPurify:

  sanitizeHtml        default policy, for v-html
  sanitizeInlineHtml  as above but forbids <p>, for the news ticker
  getPlainText        real plain text, for {{ }}

getPlainText moved here from its own file, and InnerHtml and NewsTicker
were routed through it too -- otherwise the abstraction would be
half-done, with two of four policies still inline.

Also drops the unused MeetupItem type import Copilot flagged in
ConferenceCard.vue, which was one of the no-unused-vars warnings (129 ->
128). It was pre-existing, but the line is already in this diff.

Tests cover the policy itself now, not just its callers: rich markup
survives (including the brand-colour span), the ticker still loses <p>,
and no payload leaves a live tag. The danger check is itself tested
against unsanitised input so the assertions cannot pass vacuously.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JkKMceYAzAYLrSyC42FWTf

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 13 out of 13 changed files in this pull request and generated no new comments.

Suppressed comments (2)

docs/dependency-upgrade-plan.md:1366

  • This section says the bypass payloads are covered by test/getPlainText.test.ts, but the tests added in this PR are in nuxt-app/test/sanitize.test.ts (i.e. test/sanitize.test.ts relative to nuxt-app). Update the filename so the verification pointer is accurate.
Unit tests cover the bypass payloads, so the regex cannot come back unnoticed: `test/getPlainText.test.ts`,

docs/dependency-upgrade-plan.md:1343

  • The document references helpers/getPlainText.ts, but the implementation added in this PR lives in helpers/sanitize.ts (the getPlainText export). This makes the audit notes hard to follow and points to a non-existent file.

This issue also appears on line 1366 of the same file.

`helpers/getPlainText.ts` sanitises with `ALLOWED_TAGS: []` and `RETURN_DOM_FRAGMENT`, then reads

@Jan0707
Jan0707 merged commit f81da60 into main Aug 4, 2026
6 checks passed
Jan0707 added a commit that referenced this pull request Aug 4, 2026
Owed from #237 and #238. Part of this section had become actively wrong.

Retracts the claim that useWeightedRandomSelection was a latent
hydration-mismatch source. TestimonialSlider wraps its list in
<ClientOnly>, so testimonials are never server-rendered and the hourly
seed cannot participate in hydration. Keeps the correction rather than
deleting the claim, because the reasoning looked sound and the negative
control is what disproved it: with the client clock shifted past an hour
boundary the unfixed build produced zero warnings too.

Records the useLoadingScreen fix and, explicitly, that it is not claimed
as the mismatch fix -- so both leads are now eliminated and the item
stays open with the dev-mode-from-clean-.nuxt next step named.

Updates the v-html write-up for helpers/sanitize.ts, which is now the only
module touching DOMPurify.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JkKMceYAzAYLrSyC42FWTf
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.

2 participants