@@ -9,7 +15,7 @@ defineProps<{
diff --git a/nuxt-app/components/ProfileCreationMainInfos.vue b/nuxt-app/components/ProfileCreationMainInfos.vue
index 5cd1e7f3..41799dce 100644
--- a/nuxt-app/components/ProfileCreationMainInfos.vue
+++ b/nuxt-app/components/ProfileCreationMainInfos.vue
@@ -1,13 +1,17 @@
',
+ '',
+ '',
+ 'click',
+]
+
+// Inspect tags, not the whole string. A sanitised payload often leaves its remains as inert text —
+// `
src=x onerror=alert(1)>` reduces to the literal text ` src=x onerror=alert(1)>`, where
+// `onerror=` is content rather than an attribute and cannot execute. Searching the raw string for
+// "onerror" would flag that as dangerous when it is not.
+const dangerousTagsIn = (html: string): string[] =>
+ (html.match(/<[a-zA-Z][^>]*>/g) ?? []).filter(
+ (tag) =>
+ /^<(script|iframe|object|embed|style)\b/i.test(tag) ||
+ /\son\w+\s*=/i.test(tag) ||
+ /(href|src|action)\s*=\s*["']?\s*(javascript|vbscript|data):/i.test(tag)
+ )
+
+describe('the danger check used below', () => {
+ it('flags unsanitised payloads, so the assertions are not vacuous', () => {
+ expect(dangerousTagsIn('
')).toHaveLength(1)
+ expect(dangerousTagsIn('')).toHaveLength(1)
+ expect(dangerousTagsIn('x')).toHaveLength(1)
+ expect(dangerousTagsIn('harmless text
')).toHaveLength(0)
+ })
+})
+
+describe('sanitizeHtml', () => {
+ it('keeps the markup CMS editors legitimately produce', () => {
+ // Including the styled span that carries the brand colour in `profile_creation_page`.
+ const rich = 'Willkommen bei der programmier.bar
'
+ expect(sanitizeHtml(rich)).toBe(rich)
+ expect(sanitizeHtml('Titel
')).toBe('Titel
')
+ expect(sanitizeHtml('Link')).toBe('Link')
+ })
+
+ it('removes scripts, event handlers and unsafe URLs', () => {
+ for (const payload of XSS_PAYLOADS) {
+ expect(dangerousTagsIn(sanitizeHtml(payload))).toEqual([])
+ }
+ })
+
+ it('handles absent input', () => {
+ expect(sanitizeHtml('')).toBe('')
+ expect(sanitizeHtml(null)).toBe('')
+ expect(sanitizeHtml(undefined)).toBe('')
+ })
+})
+
+describe('sanitizeInlineHtml', () => {
+ it('drops paragraphs but keeps their text, so the ticker stays on one line', () => {
+ expect(sanitizeInlineHtml('Neue Folgen dienstags
')).toBe('Neue Folgen dienstags')
+ expect(sanitizeInlineHtml('eins
zwei
')).toBe('einszwei')
+ })
+
+ it('still allows inline markup', () => {
+ expect(sanitizeInlineHtml('wichtig')).toBe('wichtig')
+ })
+
+ it('removes the same attacks as the default policy', () => {
+ for (const payload of XSS_PAYLOADS) {
+ expect(dangerousTagsIn(sanitizeInlineHtml(payload))).toEqual([])
+ }
+ })
+})
+
+describe('getPlainText', () => {
+ it('returns empty string for empty input', () => {
+ expect(getPlainText('')).toBe('')
+ expect(getPlainText(null)).toBe('')
+ expect(getPlainText(undefined)).toBe('')
+ })
+
+ it('strips the markup Directus WYSIWYG fields actually contain', () => {
+ expect(getPlainText('Ein Text mit Link
')).toBe('Ein Text mit Link')
+ expect(getPlainText('Überschrift
')).toBe('Überschrift')
+ expect(getPlainText('Kein Markup')).toBe('Kein Markup')
+ })
+
+ it('decodes HTML entities, so German text is not garbled', () => {
+ // The regex this replaced left these encoded. That was invisible while the value went to
+ // `v-html` (the browser decoded them) and would have surfaced the moment it moved to `{{ }}`.
+ expect(getPlainText('für Baukästen
')).toBe('für Baukästen')
+ expect(getPlainText('"Moin"
')).toBe('"Moin"')
+ expect(getPlainText('begrüßen
')).toBe('begrüßen')
+ expect(getPlainText('Web & AI Edition 2025
')).toBe('Web & AI Edition 2025')
+ })
+
+ it('yields inert text for payloads that defeat regex tag-stripping', () => {
+ // `/<[^<>]+>/g` cannot match a tag containing `<` or `>`, so removing the inner tag
+ // reassembled a working one: `
src=x onerror=alert(1)>` became
+ // `
` and executed once handed to `v-html`.
+ for (const payload of [...XSS_PAYLOADS, 'x
']) {
+ expect(getPlainText(payload)).not.toMatch(/<[a-zA-Z]/)
+ }
+ })
+
+ it('keeps text content when unwrapping elements', () => {
+ expect(getPlainText('sichtbar
')).toBe('sichtbar')
+ })
+})