From 1edd2f2062c0c565a49894f6a24c471135f5410e Mon Sep 17 00:00:00 2001 From: Jan Gregor Emge-Triebel Date: Tue, 4 Aug 2026 11:08:20 +0200 Subject: [PATCH 1/8] Stop logging auth payloads; stop prerendering image URLs Two small unrelated fixes, both from the post-plan backlog. getCurrentUser() logged the result of directus.refresh(), whose payload type carries access_token and refresh_token, and the readMe() result, which carries the account's email and role. Both calls run in the browser, so both went to the visitor's console. registerNewUser logged the created user, and login-callback.vue logged the user object again. Whether the token fields are actually populated depends on the auth mode: the app uses authentication('session'), where Directus keeps the token in an httpOnly cookie, and I could not verify the session-mode response shape without authenticating. So the confirmed leak is the user object; the token exposure is unconfirmed but is what the payload type describes, and would become real if the app ever switched to json mode. Either way there is no reason to print any of it. Error logs now pass through toLogMessage(), since the SDK rejects with a RequestError carrying the raw Response and its request. Separately, nitro.prerender.ignore: ['/_ipx'] stops the crawler resizing every variant it finds. Measured on a full local build: without with result exit 1 exit 0 duration 193s 29s _ipx files 1475 (~100MB) 0 prerendered html 44 44 The failure is `ERROR terminated` from undici -- connection exhaustion -- hitting prerender.failOnError. It is intermittent: an earlier run of the same commit passed in 193s, which is why the plan recorded this as always failing. Nothing consumes those files: Vercel serves images through _vercel/image and does not prerender. Verified /_ipx still serves at runtime, returning a real 468x312 PNG. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JkKMceYAzAYLrSyC42FWTf --- nuxt-app/composables/useDirectus.ts | 36 ++++++++++++++++++----------- nuxt-app/nuxt.config.ts | 5 ++++ nuxt-app/pages/login-callback.vue | 2 -- 3 files changed, 27 insertions(+), 16 deletions(-) diff --git a/nuxt-app/composables/useDirectus.ts b/nuxt-app/composables/useDirectus.ts index 33ad9b63..f8a87bd2 100644 --- a/nuxt-app/composables/useDirectus.ts +++ b/nuxt-app/composables/useDirectus.ts @@ -34,6 +34,16 @@ import { anonymizeAndHashIP } from './../helpers/' const collectionWithTagsName = ['members', 'speakers', 'podcasts', 'meetups', 'picks_of_the_day'] as const type CollectionWithTagsName = (typeof collectionWithTagsName)[number] export type Tag = { name: string; count: number } + +/** + * Reduces an error to the part that is safe to print in a browser console. + * + * The SDK rejects with a `RequestError` carrying the raw `Response` and the request that produced it, + * so logging the object itself can put request details in front of the user. + */ +function toLogMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} type DirectusTag = { tag: { id: string; name: string } } export function useDirectus() { @@ -894,25 +904,23 @@ export function useDirectus() { async function getCurrentUser() { try { - console.log('Refreshing auth', await directus.refresh()) - - const user = await directus.with(rest({ credentials: 'include' })).request(readMe()) - console.log('Current user', user) - - // Maybe add some persistence etc. here? - return user - } catch (e: unknown) { - console.log('Error while reading current user', e) + // Do not log these. `refresh()` resolves to the SDK's auth payload, whose fields include + // `access_token` and `refresh_token`, and `readMe()` to the account's email and role — + // and both of these calls run in the browser. + await directus.refresh() + + return await directus.with(rest({ credentials: 'include' })).request(readMe()) + } catch (error: unknown) { + console.error('Error while reading current user:', toLogMessage(error)) } } async function registerNewUser(email: string, password: string) { try { - const result = await directus.request(createUser({ email, password })) - console.log(result) - } catch (e: unknown) { - console.error('Error while registering new user', e) - return e + await directus.request(createUser({ email, password })) + } catch (error: unknown) { + console.error('Error while registering new user:', toLogMessage(error)) + return error } } diff --git a/nuxt-app/nuxt.config.ts b/nuxt-app/nuxt.config.ts index 26b78e88..3ef0c93a 100644 --- a/nuxt-app/nuxt.config.ts +++ b/nuxt-app/nuxt.config.ts @@ -199,6 +199,11 @@ export default defineNuxtConfig({ nitro: { prerender: { failOnError: true, + // Don't follow image URLs. The crawler would otherwise resize every `` variant it + // finds — 1476 files and ~100 MB from 44 routes — and hammer the CMS while doing it. Nothing + // consumes them: Vercel serves images through `_vercel/image` and does not prerender, and + // `/_ipx` still works at runtime because the handler stays in the server bundle. + ignore: ['/_ipx'], }, externals: { // Do not remove: Pinia 4 ships only its bundler build, so externalising it leaves Vue's diff --git a/nuxt-app/pages/login-callback.vue b/nuxt-app/pages/login-callback.vue index 5c0be794..b54efab8 100644 --- a/nuxt-app/pages/login-callback.vue +++ b/nuxt-app/pages/login-callback.vue @@ -42,8 +42,6 @@ const loginPage: ComputedRef = computed(() => pag onMounted(async () => { clientSideUser.value = await directus.getCurrentUser() - - console.log('User', clientSideUser.value) }) // Set loading screen From f03ffa90e59a8a2ba4b600101ce99d91f67d1de5 Mon Sep 17 00:00:00 2001 From: Jan Gregor Emge-Triebel Date: Tue, 4 Aug 2026 11:45:16 +0200 Subject: [PATCH 2/8] Address review: only skip the image crawl when a server will serve /_ipx The reviewer was right and I had verified the wrong thing. I checked that /_ipx still resolves at runtime, which is true for `nuxt build` -- but `nuxt generate` produces no server, so there the crawler's output IS what serves those URLs. The prerendered HTML is full of them: 73 references in index.html, 253 in podcast/index.html, 143 in meetup/index.html. Ignoring the prefix unconditionally would have shipped a static site whose every optimised image 404s. The ignore now applies only when nitro.static is false. That is the framework's own signal: nuxi generate is nuxi build with `nitro: { static: ctx.args.prerender }`, confirmed in @nuxt/cli. Verified both paths this time: npm run build exit 0, 29s, 0 _ipx files, 44 html, server present npm run generate exit 1, 809s, 6180 _ipx files, 414 html, server absent generate's failure is pre-existing and unrelated -- the crawler follows two schemeless URLs in CMS speaker records as relative paths: [404] Page not found: /hall-of-fame/www.linkedin.com/in/mariekilg/ marie-kilg linkedin_url = "www.linkedin.com/in/mariekilg/" andre-neubauer twitter_url = "twitter.com/devpg" Those are also live user-facing bugs: production serves the hrefs verbatim, so both links resolve against the current page and return 404 instead of going to LinkedIn or Twitter. Logged, not fixed here. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JkKMceYAzAYLrSyC42FWTf --- nuxt-app/nuxt.config.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/nuxt-app/nuxt.config.ts b/nuxt-app/nuxt.config.ts index 3ef0c93a..5890f3a6 100644 --- a/nuxt-app/nuxt.config.ts +++ b/nuxt-app/nuxt.config.ts @@ -112,6 +112,19 @@ export default defineNuxtConfig({ return } + // Keep image URLs out of the prerender crawl. Following them makes the crawler resize + // every `` variant it finds — 1476 files and ~100 MB from 44 routes — and + // exhaust connections to the CMS, which fails the build via `failOnError`. + // + // Only where a server serves `/_ipx` at runtime. `nuxt generate` sets `nitro.static` + // and produces no server, so there the crawler's output *is* what serves those URLs and + // skipping it would 404 every optimised image on the site. + if (!nitroConfig.static) { + nitroConfig.prerender ??= {} + nitroConfig.prerender.ignore ??= [] + nitroConfig.prerender.ignore.push('/_ipx') + } + // Lets CI build without the CMS being reachable. Skips route discovery only — the // bundle is still built in full, and deploys never set it. if (process.env.SKIP_PRERENDER_ROUTE_DISCOVERY === 'true') { @@ -199,11 +212,6 @@ export default defineNuxtConfig({ nitro: { prerender: { failOnError: true, - // Don't follow image URLs. The crawler would otherwise resize every `` variant it - // finds — 1476 files and ~100 MB from 44 routes — and hammer the CMS while doing it. Nothing - // consumes them: Vercel serves images through `_vercel/image` and does not prerender, and - // `/_ipx` still works at runtime because the handler stays in the server bundle. - ignore: ['/_ipx'], }, externals: { // Do not remove: Pinia 4 ships only its bundler build, so externalising it leaves Vue's From 2e2a79c47ba55dfc5dff47b3c48f36d49ecab667 Mon Sep 17 00:00:00 2001 From: Jan Gregor Emge-Triebel Date: Tue, 4 Aug 2026 12:03:14 +0200 Subject: [PATCH 3/8] Normalise CMS-entered external links so they cannot resolve into our site A schemeless value in an href is resolved against the current page, so `www.linkedin.com/in/mariekilg/` became `/hall-of-fame/www.linkedin.com/in/mariekilg/` -- a 404 on our own domain for the visitor, and a build failure for `npm run generate`, whose crawler follows it. normalizeExternalUrl() runs every platform URL through one chokepoint, `platformList` in IndividualPlatforms.vue, which already funnels all eight fields for both speakers and members. The CMS holds three shapes, and they need different answers: https://www.linkedin.com/in/x absolute -> unchanged www.linkedin.com/in/x schemeless URL -> https:// prefixed @jSchaback a handle, not a URL -> link omitted A handle is dropped rather than guessed at: deriving a profile URL from it needs to know the platform, and a wrong guess is a link that looks fine and goes nowhere. The existing `.filter(platform => platform.url)` already drops the entry, so no icon is rendered. One case is deliberately not solved: a bare username containing a dot (`t.muelleer`) is indistinguishable from a bare domain (`example.com`), which is a legitimate website_url. It becomes https://t.muelleer and fails to resolve. The point is that a bad link now leaves our domain and fails as somebody else's problem instead of rendering a 404 that looks like ours. Verified on the four previously affected speaker pages: 0 schemeless hrefs, the handle link gone, the username link absolute. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JkKMceYAzAYLrSyC42FWTf --- nuxt-app/components/IndividualPlatforms.vue | 18 +++--- nuxt-app/helpers/index.ts | 1 + nuxt-app/helpers/normalizeExternalUrl.ts | 43 +++++++++++++ nuxt-app/test/normalizeExternalUrl.test.ts | 70 +++++++++++++++++++++ 4 files changed, 123 insertions(+), 9 deletions(-) create mode 100644 nuxt-app/helpers/normalizeExternalUrl.ts create mode 100644 nuxt-app/test/normalizeExternalUrl.test.ts diff --git a/nuxt-app/components/IndividualPlatforms.vue b/nuxt-app/components/IndividualPlatforms.vue index 0137c7ca..08c242b5 100644 --- a/nuxt-app/components/IndividualPlatforms.vue +++ b/nuxt-app/components/IndividualPlatforms.vue @@ -49,7 +49,7 @@ import { OPEN_SPEAKER_LINKEDIN_EVENT_ID, OPEN_SPEAKER_BLUESKY_EVENT_ID, } from '../config'; -import { trackGoal } from '../helpers' +import { normalizeExternalUrl, trackGoal } from '../helpers' import { computed } from 'vue'; type Scope = 'speaker' | 'member' @@ -112,49 +112,49 @@ const platformList = computed(() => { { name: 'Bluesky', icon: BLUESKY_ICON, - url: props.platforms.bluesky_url, + url: normalizeExternalUrl(props.platforms.bluesky_url), eventId: BLUESKY_EVENT_ID, }, { name: 'Mastodon', icon: MastodonIcon, - url: props.platforms.mastodon_url, + url: normalizeExternalUrl(props.platforms.mastodon_url), eventId: MASTODON_EVENT_ID, }, { name: 'LinkedIn', icon: LINKEDIN_ICON, - url: props.platforms.linkedin_url, + url: normalizeExternalUrl(props.platforms.linkedin_url), eventId: LINKEDIN_EVENT_ID, }, { name: 'Instagram', icon: INSTAGRAM_ICON, - url: props.platforms.instagram_url, + url: normalizeExternalUrl(props.platforms.instagram_url), eventId: INSTAGRAM_EVENT_ID, }, { name: 'GitHub', icon: GITHUB_ICON, - url: props.platforms.github_url, + url: normalizeExternalUrl(props.platforms.github_url), eventId: GITHUB_EVENT_ID, }, { name: 'YouTube', icon: YOUTUBE_ICON, - url: props.platforms.youtube_url, + url: normalizeExternalUrl(props.platforms.youtube_url), eventId: YOUTUBE_EVENT_ID, }, { name: 'Website', icon: WEBSITE_ICON, - url: props.platforms.website_url, + url: normalizeExternalUrl(props.platforms.website_url), eventId: WEBSITE_EVENT_ID, }, { name: 'Twitter', icon: TWITTER_ICON, - url: props.platforms.twitter_url, + url: normalizeExternalUrl(props.platforms.twitter_url), eventId: TWITTER_EVENT_ID, }, ].filter((platform) => platform.url) as { diff --git a/nuxt-app/helpers/index.ts b/nuxt-app/helpers/index.ts index f408a8fc..68f63389 100644 --- a/nuxt-app/helpers/index.ts +++ b/nuxt-app/helpers/index.ts @@ -3,6 +3,7 @@ export * from './formatAudioTimestamp' export * from './getCookie' export * from './getHashCode' export * from './getMetaInfo' +export * from './normalizeExternalUrl' export * from './getTrimmedString' export * from './parseCmsDate' export * from './resolveNewsLink' diff --git a/nuxt-app/helpers/normalizeExternalUrl.ts b/nuxt-app/helpers/normalizeExternalUrl.ts new file mode 100644 index 00000000..7b68eb05 --- /dev/null +++ b/nuxt-app/helpers/normalizeExternalUrl.ts @@ -0,0 +1,43 @@ +/** + * Makes a CMS-entered link safe to put in an `href`, or returns `undefined` if it cannot be. + * + * Editors sometimes leave the scheme off — `www.linkedin.com/in/someone` rather than + * `https://www.linkedin.com/in/someone`. A browser resolves that against the current page, so the + * link lands on `/hall-of-fame/www.linkedin.com/in/someone` and 404s on our own domain. The prerender + * crawler follows it too, which fails `npm run generate`. + * + * Values that cannot be a host — `@jSchaback` — are dropped rather than guessed at: turning a handle + * into a profile URL needs to know the platform, and a wrong guess is a link that looks fine and goes + * nowhere. Callers should omit the link entirely when this returns `undefined`. + * + * A bare username that happens to contain a dot (`t.muelleer`) is indistinguishable from a bare domain + * (`example.com`), which is a legitimate `website_url`, so it still becomes `https://t.muelleer` and + * fails to resolve. That is deliberately not solved here — the point is that a bad link leaves our + * domain and fails as somebody else's problem, instead of rendering a 404 that looks like ours. + * + * @param url The raw value from the CMS. + * + * @returns An absolute URL, or `undefined` when the value is not usable as one. + */ +export function normalizeExternalUrl(url: string | null | undefined): string | undefined { + const trimmed = url?.trim() + + if (!trimmed) { + return undefined + } + + // Already absolute (`https:`, `mailto:`), protocol-relative, or deliberately site-internal. + if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed) || trimmed.startsWith('//') || trimmed.startsWith('/')) { + return trimmed + } + + // What precedes the first `/`, `?` or `#` has to work as a host. A dot is the cheapest test that + // separates `twitter.com/devpg` from a bare username, and it rejects a leading `@` outright. + const host = trimmed.split(/[/?#]/)[0] + + if (!host || host.startsWith('@') || !host.includes('.')) { + return undefined + } + + return `https://${trimmed}` +} diff --git a/nuxt-app/test/normalizeExternalUrl.test.ts b/nuxt-app/test/normalizeExternalUrl.test.ts new file mode 100644 index 00000000..fbdebf32 --- /dev/null +++ b/nuxt-app/test/normalizeExternalUrl.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from 'vitest' +import { normalizeExternalUrl } from '../helpers/normalizeExternalUrl' + +// The cases named after real CMS records are the reason this exists. A schemeless value in an `href` +// is resolved against the current page, so `www.linkedin.com/in/x` became +// `/hall-of-fame/www.linkedin.com/in/x` — a 404 on our own domain for the visitor, and a build failure +// for `npm run generate`, whose crawler follows it. + +describe('normalizeExternalUrl', () => { + it('leaves an absolute URL alone', () => { + expect(normalizeExternalUrl('https://www.linkedin.com/in/claudiaplattner')).toBe( + 'https://www.linkedin.com/in/claudiaplattner' + ) + expect(normalizeExternalUrl('http://example.com')).toBe('http://example.com') + expect(normalizeExternalUrl('mailto:hallo@programmier.bar')).toBe('mailto:hallo@programmier.bar') + expect(normalizeExternalUrl('//cdn.example.com/x')).toBe('//cdn.example.com/x') + }) + + it('adds the missing scheme to a schemeless URL', () => { + // Both of these were live in the CMS and 404'd on our own domain. + expect(normalizeExternalUrl('www.linkedin.com/in/mariekilg/')).toBe('https://www.linkedin.com/in/mariekilg/') + expect(normalizeExternalUrl('twitter.com/devpg')).toBe('https://twitter.com/devpg') + expect(normalizeExternalUrl('example.com')).toBe('https://example.com') + expect(normalizeExternalUrl('sub.example.co.uk/path?a=1#b')).toBe('https://sub.example.co.uk/path?a=1#b') + }) + + it('drops a handle, because no correct URL can be derived from it', () => { + // Real CMS value. `https://@jSchaback` would leave our domain but still go nowhere, and + // guessing `https://twitter.com/jSchaback` requires knowing the platform. + expect(normalizeExternalUrl('@jSchaback')).toBeUndefined() + expect(normalizeExternalUrl('@user@mastodon.social')).toBeUndefined() + }) + + it('drops a value that cannot be a host', () => { + expect(normalizeExternalUrl('justausername')).toBeUndefined() + expect(normalizeExternalUrl('/relative-with-no-host')).toBe('/relative-with-no-host') + }) + + it('handles absent and whitespace-only input', () => { + expect(normalizeExternalUrl(undefined)).toBeUndefined() + expect(normalizeExternalUrl(null)).toBeUndefined() + expect(normalizeExternalUrl('')).toBeUndefined() + expect(normalizeExternalUrl(' ')).toBeUndefined() + }) + + it('trims surrounding whitespace before deciding', () => { + expect(normalizeExternalUrl(' twitter.com/devpg ')).toBe('https://twitter.com/devpg') + expect(normalizeExternalUrl(' https://example.com ')).toBe('https://example.com') + }) + + it('never returns a value that a browser would resolve against the current page', () => { + // The property that actually matters: whatever comes back is either absolute, root-relative, or + // nothing at all. A bare `foo.com/bar` in an href is what caused the original bug. + for (const input of [ + 'www.linkedin.com/in/mariekilg/', + 'twitter.com/devpg', + 't.muelleer', + '@jSchaback', + 'justausername', + 'https://example.com', + 'mailto:a@b.de', + '', + ]) { + const out = normalizeExternalUrl(input) + if (out !== undefined) { + expect(out, `input ${JSON.stringify(input)}`).toMatch(/^([a-z][a-z0-9+.-]*:|\/)/i) + } + } + }) +}) From 19b99e409fda30c4e9003fa3255245a21c42aaca Mon Sep 17 00:00:00 2001 From: Jan Gregor Emge-Triebel Date: Tue, 4 Aug 2026 12:21:57 +0200 Subject: [PATCH 4/8] Resolve social handles by platform, and fix schemeless links in rich text normalizeExternalUrl takes a kind: 'web' (default), 'twitter', 'instagram', 'linkedin', 'github', 'youtube', 'bluesky' or 'mastodon'. Knowing the platform is what makes a handle resolvable -- and it settles a case the previous version could not: @jSchaback twitter -> https://twitter.com/jSchaback t.muelleer instagram -> https://www.instagram.com/t.muelleer `t.muelleer` reads like a hostname but is a valid Instagram username, and only the field it came from can say which was meant. Bluesky makes the same point more sharply: its handles *are* domains. Mastodon splits user from instance (`@a@b.social` -> https://b.social/@a) and is dropped when the instance is missing, since it cannot be guessed. YouTube re-adds the `@` its URLs require; the others strip it. A second surface turned up while verifying: `npm run generate` still failed on a schemeless href *inside* CMS rich text, which the platform fields never see: speakers | stefan-tilkov | description | href="innoq.com/de/staff/..." So sanitizeHtml now normalises href and src in an afterSanitizeAttributes hook. That runs after DOMPurify has removed unsafe URLs, so a javascript: href is gone before the hook sees the node and only approved values are rewritten -- covered by a test. Root-relative links are left alone, so internal links in rich text keep working. Verified on a real build: all three values now absolute, 0 schemeless hrefs on the three affected speaker pages, and the internal /podcast/... link in stefan-tilkov's biography untouched. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JkKMceYAzAYLrSyC42FWTf --- nuxt-app/components/IndividualPlatforms.vue | 16 +- nuxt-app/helpers/normalizeExternalUrl.ts | 105 +++++++++--- nuxt-app/helpers/sanitize.ts | 34 ++++ nuxt-app/test/normalizeExternalUrl.test.ts | 170 ++++++++++++++------ nuxt-app/test/sanitize.test.ts | 37 +++++ 5 files changed, 284 insertions(+), 78 deletions(-) diff --git a/nuxt-app/components/IndividualPlatforms.vue b/nuxt-app/components/IndividualPlatforms.vue index 08c242b5..c7f483d1 100644 --- a/nuxt-app/components/IndividualPlatforms.vue +++ b/nuxt-app/components/IndividualPlatforms.vue @@ -112,49 +112,49 @@ const platformList = computed(() => { { name: 'Bluesky', icon: BLUESKY_ICON, - url: normalizeExternalUrl(props.platforms.bluesky_url), + url: normalizeExternalUrl(props.platforms.bluesky_url, 'bluesky'), eventId: BLUESKY_EVENT_ID, }, { name: 'Mastodon', icon: MastodonIcon, - url: normalizeExternalUrl(props.platforms.mastodon_url), + url: normalizeExternalUrl(props.platforms.mastodon_url, 'mastodon'), eventId: MASTODON_EVENT_ID, }, { name: 'LinkedIn', icon: LINKEDIN_ICON, - url: normalizeExternalUrl(props.platforms.linkedin_url), + url: normalizeExternalUrl(props.platforms.linkedin_url, 'linkedin'), eventId: LINKEDIN_EVENT_ID, }, { name: 'Instagram', icon: INSTAGRAM_ICON, - url: normalizeExternalUrl(props.platforms.instagram_url), + url: normalizeExternalUrl(props.platforms.instagram_url, 'instagram'), eventId: INSTAGRAM_EVENT_ID, }, { name: 'GitHub', icon: GITHUB_ICON, - url: normalizeExternalUrl(props.platforms.github_url), + url: normalizeExternalUrl(props.platforms.github_url, 'github'), eventId: GITHUB_EVENT_ID, }, { name: 'YouTube', icon: YOUTUBE_ICON, - url: normalizeExternalUrl(props.platforms.youtube_url), + url: normalizeExternalUrl(props.platforms.youtube_url, 'youtube'), eventId: YOUTUBE_EVENT_ID, }, { name: 'Website', icon: WEBSITE_ICON, - url: normalizeExternalUrl(props.platforms.website_url), + url: normalizeExternalUrl(props.platforms.website_url, 'web'), eventId: WEBSITE_EVENT_ID, }, { name: 'Twitter', icon: TWITTER_ICON, - url: normalizeExternalUrl(props.platforms.twitter_url), + url: normalizeExternalUrl(props.platforms.twitter_url, 'twitter'), eventId: TWITTER_EVENT_ID, }, ].filter((platform) => platform.url) as { diff --git a/nuxt-app/helpers/normalizeExternalUrl.ts b/nuxt-app/helpers/normalizeExternalUrl.ts index 7b68eb05..8d45a404 100644 --- a/nuxt-app/helpers/normalizeExternalUrl.ts +++ b/nuxt-app/helpers/normalizeExternalUrl.ts @@ -1,25 +1,85 @@ +/** + * The kind of link a CMS field is meant to hold. + * + * Anything other than `web` can also accept a bare handle, because that is what editors tend to type + * into a social field. Knowing the platform is what makes a handle resolvable: `t.muelleer` is a + * perfectly good Instagram username but reads like a hostname, and only the field it came from can + * settle which was meant. + */ +export type ExternalUrlKind = + 'web' | 'twitter' | 'instagram' | 'linkedin' | 'github' | 'youtube' | 'bluesky' | 'mastodon' + +interface PlatformRules { + /** Hosts that mean "this is already a URL, just missing its scheme". */ + hosts: string[] + /** Builds a profile URL from a bare handle, or returns undefined if it cannot. */ + profileUrl: ((handle: string) => string | undefined) | null +} + +const PLATFORMS: Record = { + // A website field has no handle form — a value with no host is simply unusable. + web: { hosts: [], profileUrl: null }, + twitter: { + hosts: ['twitter.com', 'www.twitter.com', 'x.com', 'www.x.com'], + profileUrl: (handle) => `https://twitter.com/${handle}`, + }, + instagram: { + hosts: ['instagram.com', 'www.instagram.com'], + profileUrl: (handle) => `https://www.instagram.com/${handle}`, + }, + linkedin: { + // Personal profiles live under /in/. Company pages use /company/ and are always pasted as a + // full URL, so they take the absolute path through this function. + hosts: ['linkedin.com', 'www.linkedin.com'], + profileUrl: (handle) => `https://www.linkedin.com/in/${handle}`, + }, + github: { + hosts: ['github.com', 'www.github.com'], + profileUrl: (handle) => `https://github.com/${handle}`, + }, + youtube: { + // YouTube handles are @-prefixed in URLs, unlike the others. + hosts: ['youtube.com', 'www.youtube.com', 'youtu.be'], + profileUrl: (handle) => `https://www.youtube.com/@${handle}`, + }, + bluesky: { + // Bluesky handles are themselves domains (`someone.bsky.social`), so a dot means nothing here. + hosts: ['bsky.app', 'www.bsky.app'], + profileUrl: (handle) => `https://bsky.app/profile/${handle}`, + }, + mastodon: { + // Federated, so the instance is part of the handle: `@user@instance.social`. + hosts: [], + profileUrl: (handle) => { + const [user, instance] = handle.split('@') + return user && instance ? `https://${instance}/@${user}` : undefined + }, + }, +} + /** * Makes a CMS-entered link safe to put in an `href`, or returns `undefined` if it cannot be. * - * Editors sometimes leave the scheme off — `www.linkedin.com/in/someone` rather than - * `https://www.linkedin.com/in/someone`. A browser resolves that against the current page, so the - * link lands on `/hall-of-fame/www.linkedin.com/in/someone` and 404s on our own domain. The prerender + * Editors leave the scheme off — `www.linkedin.com/in/someone` rather than + * `https://www.linkedin.com/in/someone`. A browser resolves that against the current page, so the link + * lands on `/hall-of-fame/www.linkedin.com/in/someone` and 404s on our own domain. The prerender * crawler follows it too, which fails `npm run generate`. * - * Values that cannot be a host — `@jSchaback` — are dropped rather than guessed at: turning a handle - * into a profile URL needs to know the platform, and a wrong guess is a link that looks fine and goes - * nowhere. Callers should omit the link entirely when this returns `undefined`. + * They also paste bare handles into social fields. With `kind` set, those become real profile URLs; + * with `kind` left at `web`, a value that cannot be a host is dropped rather than guessed at. * - * A bare username that happens to contain a dot (`t.muelleer`) is indistinguishable from a bare domain - * (`example.com`), which is a legitimate `website_url`, so it still becomes `https://t.muelleer` and - * fails to resolve. That is deliberately not solved here — the point is that a bad link leaves our - * domain and fails as somebody else's problem, instead of rendering a 404 that looks like ours. + * Whatever comes back is absolute, root-relative, or `undefined` — never something a browser would + * resolve against the current page. Callers should omit the link entirely for `undefined`. * * @param url The raw value from the CMS. + * @param kind Which field it came from. Defaults to `web`. * - * @returns An absolute URL, or `undefined` when the value is not usable as one. + * @returns A URL safe to use as an `href`, or `undefined`. */ -export function normalizeExternalUrl(url: string | null | undefined): string | undefined { +export function normalizeExternalUrl( + url: string | null | undefined, + kind: ExternalUrlKind = 'web' +): string | undefined { const trimmed = url?.trim() if (!trimmed) { @@ -31,13 +91,22 @@ export function normalizeExternalUrl(url: string | null | undefined): string | u return trimmed } - // What precedes the first `/`, `?` or `#` has to work as a host. A dot is the cheapest test that - // separates `twitter.com/devpg` from a bare username, and it rejects a leading `@` outright. - const host = trimmed.split(/[/?#]/)[0] + const rules = PLATFORMS[kind] + const host = (trimmed.split(/[/?#]/)[0] ?? '').toLowerCase() - if (!host || host.startsWith('@') || !host.includes('.')) { - return undefined + // A URL missing only its scheme: either it names one of the platform's own hosts, or it has a path + // after something host-shaped, which a handle never does. + if (rules.hosts.includes(host) || (host.includes('.') && trimmed.includes('/'))) { + return `https://${trimmed}` + } + + if (rules.profileUrl) { + // The `@` is how people write handles; it is not part of the URL for any platform except + // Mastodon, where it separates user from instance, and YouTube, which re-adds its own. + const handle = kind === 'mastodon' ? trimmed.replace(/^@/, '') : trimmed.replace(/^@+/, '') + return handle ? rules.profileUrl(handle) : undefined } - return `https://${trimmed}` + // `web`: no handle form, so the value has to stand up as a host on its own. + return host.includes('.') ? `https://${trimmed}` : undefined } diff --git a/nuxt-app/helpers/sanitize.ts b/nuxt-app/helpers/sanitize.ts index b8d52a08..463ed0eb 100644 --- a/nuxt-app/helpers/sanitize.ts +++ b/nuxt-app/helpers/sanitize.ts @@ -1,4 +1,5 @@ import DOMPurify from 'isomorphic-dompurify' +import { normalizeExternalUrl } from './normalizeExternalUrl' // The single place that decides how untrusted CMS text is made safe to render. Every `v-html` binding // and every plain-text excerpt in the app goes through one of these, so a policy change — tightening @@ -8,6 +9,39 @@ import DOMPurify from 'isomorphic-dompurify' // pulling isomorphic-dompurify in through it would instantiate jsdom for consumers that only wanted a // date helper. Import this module directly. +// Editors also leave the scheme off links *inside* rich text, not just in the dedicated URL fields: +// `href="innoq.com/de/staff/stefan-tilkov/"` in a speaker biography resolves against the current page +// and 404s on our own domain, and the prerender crawler follows it and fails the build. +// +// This runs in `afterSanitizeAttributes`, so DOMPurify has already removed anything unsafe — a +// `javascript:` href is gone before this sees the node, and only values it approved are rewritten. +// Root-relative links are left alone, so internal links in rich text keep working. +DOMPurify.addHook('afterSanitizeAttributes', (node) => { + if (typeof (node as Element).getAttribute !== 'function') { + return + } + + const element = node as Element + + for (const attribute of ['href', 'src']) { + const value = element.getAttribute(attribute) + + if (!value) { + continue + } + + const normalized = normalizeExternalUrl(value) + + if (normalized === undefined) { + // Not usable as a URL at all — better a non-clickable link than one that navigates into + // our own site and 404s. + element.removeAttribute(attribute) + } else if (normalized !== value) { + element.setAttribute(attribute, normalized) + } + } +}) + /** * Sanitises CMS rich text for rendering as HTML. * diff --git a/nuxt-app/test/normalizeExternalUrl.test.ts b/nuxt-app/test/normalizeExternalUrl.test.ts index fbdebf32..34eaf923 100644 --- a/nuxt-app/test/normalizeExternalUrl.test.ts +++ b/nuxt-app/test/normalizeExternalUrl.test.ts @@ -1,70 +1,136 @@ import { describe, expect, it } from 'vitest' -import { normalizeExternalUrl } from '../helpers/normalizeExternalUrl' +import { normalizeExternalUrl, type ExternalUrlKind } from '../helpers/normalizeExternalUrl' // The cases named after real CMS records are the reason this exists. A schemeless value in an `href` // is resolved against the current page, so `www.linkedin.com/in/x` became // `/hall-of-fame/www.linkedin.com/in/x` — a 404 on our own domain for the visitor, and a build failure // for `npm run generate`, whose crawler follows it. +const ALL_KINDS: ExternalUrlKind[] = [ + 'web', + 'twitter', + 'instagram', + 'linkedin', + 'github', + 'youtube', + 'bluesky', + 'mastodon', +] + describe('normalizeExternalUrl', () => { - it('leaves an absolute URL alone', () => { - expect(normalizeExternalUrl('https://www.linkedin.com/in/claudiaplattner')).toBe( - 'https://www.linkedin.com/in/claudiaplattner' - ) - expect(normalizeExternalUrl('http://example.com')).toBe('http://example.com') - expect(normalizeExternalUrl('mailto:hallo@programmier.bar')).toBe('mailto:hallo@programmier.bar') - expect(normalizeExternalUrl('//cdn.example.com/x')).toBe('//cdn.example.com/x') - }) + describe('regardless of kind', () => { + it('leaves an absolute URL alone', () => { + for (const kind of ALL_KINDS) { + expect(normalizeExternalUrl('https://www.linkedin.com/in/claudiaplattner', kind)).toBe( + 'https://www.linkedin.com/in/claudiaplattner' + ) + expect(normalizeExternalUrl('http://example.com', kind)).toBe('http://example.com') + expect(normalizeExternalUrl('mailto:hallo@programmier.bar', kind)).toBe('mailto:hallo@programmier.bar') + expect(normalizeExternalUrl('//cdn.example.com/x', kind)).toBe('//cdn.example.com/x') + } + }) - it('adds the missing scheme to a schemeless URL', () => { - // Both of these were live in the CMS and 404'd on our own domain. - expect(normalizeExternalUrl('www.linkedin.com/in/mariekilg/')).toBe('https://www.linkedin.com/in/mariekilg/') - expect(normalizeExternalUrl('twitter.com/devpg')).toBe('https://twitter.com/devpg') - expect(normalizeExternalUrl('example.com')).toBe('https://example.com') - expect(normalizeExternalUrl('sub.example.co.uk/path?a=1#b')).toBe('https://sub.example.co.uk/path?a=1#b') - }) + it('adds the missing scheme to a schemeless URL', () => { + // Both were live in the CMS and 404'd on our own domain. + expect(normalizeExternalUrl('www.linkedin.com/in/mariekilg/', 'linkedin')).toBe( + 'https://www.linkedin.com/in/mariekilg/' + ) + expect(normalizeExternalUrl('twitter.com/devpg', 'twitter')).toBe('https://twitter.com/devpg') + }) - it('drops a handle, because no correct URL can be derived from it', () => { - // Real CMS value. `https://@jSchaback` would leave our domain but still go nowhere, and - // guessing `https://twitter.com/jSchaback` requires knowing the platform. - expect(normalizeExternalUrl('@jSchaback')).toBeUndefined() - expect(normalizeExternalUrl('@user@mastodon.social')).toBeUndefined() - }) + it('handles absent and whitespace-only input', () => { + for (const kind of ALL_KINDS) { + expect(normalizeExternalUrl(undefined, kind)).toBeUndefined() + expect(normalizeExternalUrl(null, kind)).toBeUndefined() + expect(normalizeExternalUrl('', kind)).toBeUndefined() + expect(normalizeExternalUrl(' ', kind)).toBeUndefined() + } + }) - it('drops a value that cannot be a host', () => { - expect(normalizeExternalUrl('justausername')).toBeUndefined() - expect(normalizeExternalUrl('/relative-with-no-host')).toBe('/relative-with-no-host') - }) + it('trims before deciding', () => { + expect(normalizeExternalUrl(' twitter.com/devpg ', 'twitter')).toBe('https://twitter.com/devpg') + expect(normalizeExternalUrl(' @jSchaback ', 'twitter')).toBe('https://twitter.com/jSchaback') + }) - it('handles absent and whitespace-only input', () => { - expect(normalizeExternalUrl(undefined)).toBeUndefined() - expect(normalizeExternalUrl(null)).toBeUndefined() - expect(normalizeExternalUrl('')).toBeUndefined() - expect(normalizeExternalUrl(' ')).toBeUndefined() + it('never returns something a browser resolves against the current page', () => { + // The property that actually matters. + const inputs = ['www.linkedin.com/in/x/', 'twitter.com/devpg', 't.muelleer', '@jSchaback', 'plainname', ''] + for (const kind of ALL_KINDS) { + for (const input of inputs) { + const out = normalizeExternalUrl(input, kind) + if (out !== undefined) { + expect(out, `${kind} / ${JSON.stringify(input)}`).toMatch(/^([a-z][a-z0-9+.-]*:|\/)/i) + } + } + } + }) }) - it('trims surrounding whitespace before deciding', () => { - expect(normalizeExternalUrl(' twitter.com/devpg ')).toBe('https://twitter.com/devpg') - expect(normalizeExternalUrl(' https://example.com ')).toBe('https://example.com') + describe("kind 'web' (the default)", () => { + it('accepts a bare host', () => { + expect(normalizeExternalUrl('example.com')).toBe('https://example.com') + expect(normalizeExternalUrl('sub.example.co.uk/path?a=1#b')).toBe('https://sub.example.co.uk/path?a=1#b') + }) + + it('drops a value that cannot be a host, because a website field has no handle form', () => { + expect(normalizeExternalUrl('justausername')).toBeUndefined() + expect(normalizeExternalUrl('@someone')).toBeUndefined() + }) + + it('is what you get when kind is omitted', () => { + expect(normalizeExternalUrl('example.com')).toBe(normalizeExternalUrl('example.com', 'web')) + }) }) - it('never returns a value that a browser would resolve against the current page', () => { - // The property that actually matters: whatever comes back is either absolute, root-relative, or - // nothing at all. A bare `foo.com/bar` in an href is what caused the original bug. - for (const input of [ - 'www.linkedin.com/in/mariekilg/', - 'twitter.com/devpg', - 't.muelleer', - '@jSchaback', - 'justausername', - 'https://example.com', - 'mailto:a@b.de', - '', - ]) { - const out = normalizeExternalUrl(input) - if (out !== undefined) { - expect(out, `input ${JSON.stringify(input)}`).toMatch(/^([a-z][a-z0-9+.-]*:|\/)/i) - } - } + describe('social handles', () => { + it('resolves the two handles that are actually in the CMS', () => { + // johannes-schaback | twitter_url = "@jSchaback" + expect(normalizeExternalUrl('@jSchaback', 'twitter')).toBe('https://twitter.com/jSchaback') + // tobias-m-mueller | instagram_url = "t.muelleer" — a dot is legal in an Instagram username, + // which is exactly why the kind is needed to tell it apart from a hostname. + expect(normalizeExternalUrl('t.muelleer', 'instagram')).toBe('https://www.instagram.com/t.muelleer') + }) + + it('builds profile URLs per platform', () => { + expect(normalizeExternalUrl('devpg', 'twitter')).toBe('https://twitter.com/devpg') + expect(normalizeExternalUrl('someone', 'instagram')).toBe('https://www.instagram.com/someone') + expect(normalizeExternalUrl('mariekilg', 'linkedin')).toBe('https://www.linkedin.com/in/mariekilg') + expect(normalizeExternalUrl('octocat', 'github')).toBe('https://github.com/octocat') + expect(normalizeExternalUrl('programmierbar', 'youtube')).toBe('https://www.youtube.com/@programmierbar') + }) + + it('strips a leading @ where the platform does not use one', () => { + expect(normalizeExternalUrl('@octocat', 'github')).toBe('https://github.com/octocat') + expect(normalizeExternalUrl('@someone', 'instagram')).toBe('https://www.instagram.com/someone') + }) + + it('re-adds the @ that YouTube handles require', () => { + expect(normalizeExternalUrl('@programmierbar', 'youtube')).toBe('https://www.youtube.com/@programmierbar') + }) + + it('treats a Bluesky handle as a handle, not a host', () => { + // Bluesky handles are domains, so the dot heuristic used for `web` would be wrong here. + expect(normalizeExternalUrl('programmier.bar', 'bluesky')).toBe('https://bsky.app/profile/programmier.bar') + expect(normalizeExternalUrl('someone.bsky.social', 'bluesky')).toBe( + 'https://bsky.app/profile/someone.bsky.social' + ) + }) + + it('splits a Mastodon handle into instance and user', () => { + expect(normalizeExternalUrl('@podcast@social.programmier.bar', 'mastodon')).toBe( + 'https://social.programmier.bar/@podcast' + ) + }) + + it('drops a Mastodon handle with no instance, since the server cannot be guessed', () => { + expect(normalizeExternalUrl('@podcast', 'mastodon')).toBeUndefined() + expect(normalizeExternalUrl('podcast', 'mastodon')).toBeUndefined() + }) + + it("recognises the platform's own host rather than treating it as a handle", () => { + expect(normalizeExternalUrl('x.com/devpg', 'twitter')).toBe('https://x.com/devpg') + expect(normalizeExternalUrl('youtu.be/abc123', 'youtube')).toBe('https://youtu.be/abc123') + expect(normalizeExternalUrl('bsky.app/profile/someone', 'bluesky')).toBe('https://bsky.app/profile/someone') + }) }) }) diff --git a/nuxt-app/test/sanitize.test.ts b/nuxt-app/test/sanitize.test.ts index 091fb38d..f00eb95d 100644 --- a/nuxt-app/test/sanitize.test.ts +++ b/nuxt-app/test/sanitize.test.ts @@ -112,3 +112,40 @@ describe('getPlainText', () => { expect(getPlainText('
sichtbar
')).toBe('sichtbar') }) }) + +describe('links inside rich text', () => { + it('absolutises a schemeless href, which would otherwise 404 on our own domain', () => { + // Real CMS value, in speaker stefan-tilkov's biography. As `href="innoq.com/..."` a browser + // resolves it against the current page: /hall-of-fame/innoq.com/de/staff/stefan-tilkov/. + expect(sanitizeHtml('

Profil

')).toBe( + '

Profil

' + ) + }) + + it('leaves absolute and root-relative links alone', () => { + expect(sanitizeHtml('x')).toBe('x') + expect(sanitizeHtml('intern')).toBe('intern') + expect(sanitizeHtml('mail')).toBe( + 'mail' + ) + }) + + it('drops an href that cannot be a URL rather than leaving it relative', () => { + expect(sanitizeHtml('x')).toBe('x') + }) + + it('still removes unsafe URLs — the hook must not reintroduce them', () => { + for (const payload of [ + 'x', + 'x', + 'x', + ]) { + expect(dangerousTagsIn(sanitizeHtml(payload))).toEqual([]) + expect(sanitizeHtml(payload)).not.toMatch(/javascript:/i) + } + }) + + it('normalises src as well as href', () => { + expect(sanitizeHtml('')).toBe('') + }) +}) From 10fcaaa1c1f9334cf07a237e93080b0078235079 Mon Sep 17 00:00:00 2001 From: Jan Gregor Emge-Triebel Date: Tue, 4 Aug 2026 13:05:43 +0200 Subject: [PATCH 5/8] Drop the static-generation script `npm run generate` cannot finish against this CMS. A static build has to prerender every variant -- 6183 downloads and resizes -- and the CMS degrades under it until ipx returns 500 and failOnError ends the build. Measured response times late in a run: 169s, 194s, 210s, 245s, 292s. Nothing depended on it. No workflow or Vercel config invokes it, Vercel runs nuxt build, and images there are served by _vercel/image rather than ipx. It was already failing on main, just earlier, on schemeless links. AGENTS.md now records the absence and why, so the script is not reinstated as an oversight. The `nitro.static` guard in nuxt.config.ts stays. `npx nuxi generate` still works without the script, sets nitro.static, and produces no server -- so without the guard it would silently 404 every optimised image rather than fail loudly. Comment updated to say that, since the obvious reading now is that the guard is dead code. Also drops two comment references to the removed script; the reason the URL normalisation matters is the visitor-facing 404, which stands on its own. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JkKMceYAzAYLrSyC42FWTf --- AGENTS.md | 6 +++++- nuxt-app/helpers/normalizeExternalUrl.ts | 4 ++-- nuxt-app/nuxt.config.ts | 7 ++++--- nuxt-app/package.json | 1 - nuxt-app/test/normalizeExternalUrl.test.ts | 3 +-- 5 files changed, 12 insertions(+), 9 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 125b4c01..a3afe170 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -44,11 +44,15 @@ website/ ```bash npm run dev # Development server npm run build # Production build -npm run generate # Static site generation npm run eslint # Lint with auto-fix npm run prettier # Format code ``` +There is deliberately no static-generation script. See +[the upgrade plan](docs/dependency-upgrade-plan.md) for why: a static build has to prerender every +`` variant, which is ~6200 downloads and resizes against the CMS, and it does not finish. +Nothing deployed used it — Vercel runs `nuxt build` and serves images through `_vercel/image`. + ### Directus CMS (run from `directus-cms/`) ```bash diff --git a/nuxt-app/helpers/normalizeExternalUrl.ts b/nuxt-app/helpers/normalizeExternalUrl.ts index 8d45a404..aac659d5 100644 --- a/nuxt-app/helpers/normalizeExternalUrl.ts +++ b/nuxt-app/helpers/normalizeExternalUrl.ts @@ -62,8 +62,8 @@ const PLATFORMS: Record = { * * Editors leave the scheme off — `www.linkedin.com/in/someone` rather than * `https://www.linkedin.com/in/someone`. A browser resolves that against the current page, so the link - * lands on `/hall-of-fame/www.linkedin.com/in/someone` and 404s on our own domain. The prerender - * crawler follows it too, which fails `npm run generate`. + * lands on `/hall-of-fame/www.linkedin.com/in/someone` and shows the visitor a 404 on our own domain + * instead of taking them to the profile. * * They also paste bare handles into social fields. With `kind` set, those become real profile URLs; * with `kind` left at `web`, a value that cannot be a host is dropped rather than guessed at. diff --git a/nuxt-app/nuxt.config.ts b/nuxt-app/nuxt.config.ts index 5890f3a6..12beed9c 100644 --- a/nuxt-app/nuxt.config.ts +++ b/nuxt-app/nuxt.config.ts @@ -116,9 +116,10 @@ export default defineNuxtConfig({ // every `` variant it finds — 1476 files and ~100 MB from 44 routes — and // exhaust connections to the CMS, which fails the build via `failOnError`. // - // Only where a server serves `/_ipx` at runtime. `nuxt generate` sets `nitro.static` - // and produces no server, so there the crawler's output *is* what serves those URLs and - // skipping it would 404 every optimised image on the site. + // Keep the `static` guard even though there is no `generate` script any more: `npx nuxi + // generate` still works, sets `nitro.static`, and produces no server. There the crawler's + // output *is* what serves these URLs, so skipping it would silently 404 every optimised + // image instead of failing loudly. if (!nitroConfig.static) { nitroConfig.prerender ??= {} nitroConfig.prerender.ignore ??= [] diff --git a/nuxt-app/package.json b/nuxt-app/package.json index c76bda28..b72e81b8 100644 --- a/nuxt-app/package.json +++ b/nuxt-app/package.json @@ -8,7 +8,6 @@ "scripts": { "build": "nuxt build", "dev": "nuxt dev", - "generate": "nuxt generate", "preview": "nuxt preview", "postinstall": "nuxt prepare", "eslint": "eslint --fix .", diff --git a/nuxt-app/test/normalizeExternalUrl.test.ts b/nuxt-app/test/normalizeExternalUrl.test.ts index 34eaf923..57e4803c 100644 --- a/nuxt-app/test/normalizeExternalUrl.test.ts +++ b/nuxt-app/test/normalizeExternalUrl.test.ts @@ -3,8 +3,7 @@ import { normalizeExternalUrl, type ExternalUrlKind } from '../helpers/normalize // The cases named after real CMS records are the reason this exists. A schemeless value in an `href` // is resolved against the current page, so `www.linkedin.com/in/x` became -// `/hall-of-fame/www.linkedin.com/in/x` — a 404 on our own domain for the visitor, and a build failure -// for `npm run generate`, whose crawler follows it. +// `/hall-of-fame/www.linkedin.com/in/x` — a 404 on our own domain instead of the profile. const ALL_KINDS: ExternalUrlKind[] = [ 'web', From e2eef86c419419152f187d5bf459c97719450090 Mon Sep 17 00:00:00 2001 From: Jan Gregor Emge-Triebel Date: Tue, 4 Aug 2026 13:10:09 +0200 Subject: [PATCH 6/8] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- nuxt-app/helpers/normalizeExternalUrl.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/nuxt-app/helpers/normalizeExternalUrl.ts b/nuxt-app/helpers/normalizeExternalUrl.ts index aac659d5..25cd5002 100644 --- a/nuxt-app/helpers/normalizeExternalUrl.ts +++ b/nuxt-app/helpers/normalizeExternalUrl.ts @@ -86,8 +86,18 @@ export function normalizeExternalUrl( return undefined } - // Already absolute (`https:`, `mailto:`), protocol-relative, or deliberately site-internal. - if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed) || trimmed.startsWith('//') || trimmed.startsWith('/')) { + // Already absolute (http(s):, mailto:, tel:), protocol-relative, or deliberately site-internal. + const schemeMatch = trimmed.match(/^([a-z][a-z0-9+.-]*):/i) + if (schemeMatch) { + const scheme = schemeMatch[1].toLowerCase() + if (scheme === 'http' || scheme === 'https' || scheme === 'mailto' || scheme === 'tel') { + return trimmed + } + + return undefined + } + + if (trimmed.startsWith('//') || trimmed.startsWith('/')) { return trimmed } From 1284a5f1b0ed3fca2e273f606102058ee2b324c5 Mon Sep 17 00:00:00 2001 From: Jan Gregor Emge-Triebel Date: Tue, 4 Aug 2026 13:10:35 +0200 Subject: [PATCH 7/8] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- nuxt-app/test/normalizeExternalUrl.test.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/nuxt-app/test/normalizeExternalUrl.test.ts b/nuxt-app/test/normalizeExternalUrl.test.ts index 57e4803c..2f709144 100644 --- a/nuxt-app/test/normalizeExternalUrl.test.ts +++ b/nuxt-app/test/normalizeExternalUrl.test.ts @@ -29,6 +29,14 @@ describe('normalizeExternalUrl', () => { } }) + it('drops dangerous schemes even when they are absolute', () => { + for (const kind of ALL_KINDS) { + expect(normalizeExternalUrl('javascript:alert(1)', kind)).toBeUndefined() + expect(normalizeExternalUrl('JaVaScRiPt:alert(1)', kind)).toBeUndefined() + expect(normalizeExternalUrl('vbscript:msgbox(1)', kind)).toBeUndefined() + } + }) + it('adds the missing scheme to a schemeless URL', () => { // Both were live in the CMS and 404'd on our own domain. expect(normalizeExternalUrl('www.linkedin.com/in/mariekilg/', 'linkedin')).toBe( From 4054be96b4a3c90c082ed63fd525eb00b2d8c849 Mon Sep 17 00:00:00 2001 From: Jan Gregor Emge-Triebel Date: Tue, 4 Aug 2026 13:18:07 +0200 Subject: [PATCH 8/8] Fix the typecheck regression from the scheme allowlist The allowlist itself is a real improvement and stays: a `javascript:` value in a CMS field is now dropped instead of returned unchanged. It just did not compile here. helpers/normalizeExternalUrl.ts(92,24): error TS2532: Object is possibly 'undefined' Typecheck regression: 264 errors, baseline is 263 (+1) `match()[1]` is `string | undefined` under noUncheckedIndexedAccess, which is Nuxt 4's default and which Phase 4 deliberately kept. Rewritten as two regex tests, so there is no array index to narrow and the semantics are unchanged. Also closes a latent regression the allowlist introduced in combination with the sanitize hook. The hook ran normalizeExternalUrl over every href and src, so once non-allowlisted schemes started returning undefined it would have stripped inline `data:` images, which DOMPurify legitimately permits. The hook now only fills in a *missing* scheme and leaves existing ones to DOMPurify's URI policy. No live content is affected -- 880 rich-text values scanned, none with a scheme outside the allowlist -- so this is prevention. Both behaviours now have tests, including the host-with-a-port case (`example.com:8080/x`), which is indistinguishable from a scheme and is dropped. That is a documented decision rather than an accident. Re-verified after the change: the schemeless rich-text link, both handles, and 0 schemeless hrefs across the three affected pages. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01JkKMceYAzAYLrSyC42FWTf --- nuxt-app/helpers/normalizeExternalUrl.ts | 18 +++++++++++------- nuxt-app/helpers/sanitize.ts | 7 +++++++ nuxt-app/test/normalizeExternalUrl.test.ts | 6 ++++++ nuxt-app/test/sanitize.test.ts | 8 ++++++++ 4 files changed, 32 insertions(+), 7 deletions(-) diff --git a/nuxt-app/helpers/normalizeExternalUrl.ts b/nuxt-app/helpers/normalizeExternalUrl.ts index 25cd5002..50398e0f 100644 --- a/nuxt-app/helpers/normalizeExternalUrl.ts +++ b/nuxt-app/helpers/normalizeExternalUrl.ts @@ -86,17 +86,21 @@ export function normalizeExternalUrl( return undefined } - // Already absolute (http(s):, mailto:, tel:), protocol-relative, or deliberately site-internal. - const schemeMatch = trimmed.match(/^([a-z][a-z0-9+.-]*):/i) - if (schemeMatch) { - const scheme = schemeMatch[1].toLowerCase() - if (scheme === 'http' || scheme === 'https' || scheme === 'mailto' || scheme === 'tel') { - return trimmed - } + // The only schemes worth putting in an href from a CMS field. + if (/^(https?|mailto|tel):/i.test(trimmed)) { + return trimmed + } + // Any other scheme — `javascript:`, `vbscript:`, `data:` — is dropped rather than passed through. + // This also drops a host with an explicit port (`example.com:8080/x`), which is indistinguishable + // from a scheme here; dropping the link is the safe side of that ambiguity, and no CMS value has + // one. Written as two tests rather than a capture group because `match()[1]` is + // `string | undefined` under `noUncheckedIndexedAccess`. + if (/^[a-z][a-z0-9+.-]*:/i.test(trimmed)) { return undefined } + // Protocol-relative, or deliberately site-internal. if (trimmed.startsWith('//') || trimmed.startsWith('/')) { return trimmed } diff --git a/nuxt-app/helpers/sanitize.ts b/nuxt-app/helpers/sanitize.ts index 463ed0eb..7868ab41 100644 --- a/nuxt-app/helpers/sanitize.ts +++ b/nuxt-app/helpers/sanitize.ts @@ -30,6 +30,13 @@ DOMPurify.addHook('afterSanitizeAttributes', (node) => { continue } + // Only fill in a *missing* scheme. Anything that already has one has been through DOMPurify's + // own URI policy, which allows things a CMS text field should not have to justify — an inline + // `data:` image being the obvious one. Second-guessing it here would strip those. + if (/^[a-z][a-z0-9+.-]*:/i.test(value)) { + continue + } + const normalized = normalizeExternalUrl(value) if (normalized === undefined) { diff --git a/nuxt-app/test/normalizeExternalUrl.test.ts b/nuxt-app/test/normalizeExternalUrl.test.ts index 2f709144..a9ca4923 100644 --- a/nuxt-app/test/normalizeExternalUrl.test.ts +++ b/nuxt-app/test/normalizeExternalUrl.test.ts @@ -37,6 +37,12 @@ describe('normalizeExternalUrl', () => { } }) + it('drops a host with an explicit port, which cannot be told apart from a scheme', () => { + // Documented limitation rather than an accident: dropping is the safe side of the + // ambiguity, and no CMS value currently has a port. + expect(normalizeExternalUrl('example.com:8080/path')).toBeUndefined() + }) + it('adds the missing scheme to a schemeless URL', () => { // Both were live in the CMS and 404'd on our own domain. expect(normalizeExternalUrl('www.linkedin.com/in/mariekilg/', 'linkedin')).toBe( diff --git a/nuxt-app/test/sanitize.test.ts b/nuxt-app/test/sanitize.test.ts index f00eb95d..be52b8b7 100644 --- a/nuxt-app/test/sanitize.test.ts +++ b/nuxt-app/test/sanitize.test.ts @@ -145,6 +145,14 @@ describe('links inside rich text', () => { } }) + it('leaves an inline data: image alone, deferring to DOMPurify on existing schemes', () => { + // The hook only fills in a missing scheme. DOMPurify permits `data:` images, and vetting + // schemes here as well would strip them. + const dataUri = + '' + expect(sanitizeHtml(dataUri)).toContain('data:image/png;base64,') + }) + it('normalises src as well as href', () => { expect(sanitizeHtml('')).toBe('') })