From e807237dbedbc189230296b81c3aeccc1c04fa77 Mon Sep 17 00:00:00 2001 From: Anthony LC Date: Tue, 13 Jan 2026 13:13:51 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=92=EF=B8=8F(frontend)=20fix=20props?= =?UTF-8?q?=20vulnerability=20in=20Interlinking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We were not properly sanitizing props passed to the InterlinkingLinkInlineContent component, which could lead to XSS attacks. This commit remove most of the props and only keep the necessary ones. --- CHANGELOG.md | 15 ++- src/frontend/apps/impress/package.json | 1 + .../InterlinkingLinkInlineContent.tsx | 121 +++++++++++++----- .../Interlinking/SearchPage.tsx | 1 - .../interlinkingLinkDocx.tsx | 12 +- .../interlinkingLinkODT.tsx | 12 +- .../interlinkingLinkPDF.tsx | 14 +- 7 files changed, 126 insertions(+), 50 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 82e409e39..5b95b70d6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,13 @@ and this project adheres to - ✅(export) add PDF regression tests #1762 - 📝(docs) Add language configuration documentation #1757 - 🔒(helm) Set default security context #1750 -- ✨(backend) use langfuse to monitor AI actions +- ✨(backend) use langfuse to monitor AI actions #1776 + +### Changed + +- ♿(frontend) improve accessibility: + - ♿(frontend) make html export accessible to screen reader users #1743 + - ♿(frontend) add missing label and fix Axes errors to improve a11y #1693 ### Fixed @@ -24,12 +30,7 @@ and this project adheres to ### Security - 🔒️(backend) validate more strictly url used by cors-proxy endpoint #1768 - -### Changed - -- ♿(frontend) improve accessibility: - - ♿(frontend) make html export accessible to screen reader users #1743 - - ♿(frontend) add missing label and fix Axes errors to improve a11y #1693 +- 🔒️(frontend) fix props vulnerability in Interlinking #1792 ## [4.3.0] - 2026-01-05 diff --git a/src/frontend/apps/impress/package.json b/src/frontend/apps/impress/package.json index cb701fc90..a0dcf8960 100644 --- a/src/frontend/apps/impress/package.json +++ b/src/frontend/apps/impress/package.json @@ -68,6 +68,7 @@ "react-select": "5.10.2", "styled-components": "6.1.19", "use-debounce": "10.0.6", + "uuid": "13.0.0", "y-protocols": "1.0.7", "yjs": "*", "zustand": "5.0.9" diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/InterlinkingLinkInlineContent.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/InterlinkingLinkInlineContent.tsx index 5dc9a2bed..be5145dda 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/InterlinkingLinkInlineContent.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/InterlinkingLinkInlineContent.tsx @@ -1,21 +1,23 @@ -/* eslint-disable react-hooks/rules-of-hooks */ +import { + PartialCustomInlineContentFromConfig, + StyleSchema, +} from '@blocknote/core'; import { createReactInlineContentSpec } from '@blocknote/react'; +import * as Sentry from '@sentry/nextjs'; import { useRouter } from 'next/router'; import { useEffect } from 'react'; import { css } from 'styled-components'; +import { validate as uuidValidate } from 'uuid'; import { BoxButton, Text } from '@/components'; import { useCunninghamTheme } from '@/cunningham'; import SelectedPageIcon from '@/docs/doc-editor/assets/doc-selected.svg'; -import { getEmojiAndTitle, useDoc } from '@/docs/doc-management'; +import { getEmojiAndTitle, useDoc } from '@/docs/doc-management/'; export const InterlinkingLinkInlineContent = createReactInlineContentSpec( { type: 'interlinkingLinkInline', propSchema: { - url: { - default: '', - }, docId: { default: '', }, @@ -27,46 +29,97 @@ export const InterlinkingLinkInlineContent = createReactInlineContentSpec( }, { render: ({ editor, inlineContent, updateInlineContent }) => { - const { data: doc } = useDoc({ id: inlineContent.props.docId }); - const isEditable = editor.isEditable; + if (!inlineContent.props.docId) { + return null; + } /** - * Update the content title if the referenced doc title changes + * Should not happen */ - useEffect(() => { - if ( - isEditable && - doc?.title && - doc.title !== inlineContent.props.title - ) { - updateInlineContent({ - type: 'interlinkingLinkInline', - props: { - ...inlineContent.props, - title: doc.title, - }, - }); - } + if (!uuidValidate(inlineContent.props.docId)) { + Sentry.captureException( + new Error(`Invalid docId: ${inlineContent.props.docId}`), + { + extra: { info: 'InterlinkingLinkInlineContent' }, + }, + ); - /** - * ⚠️ When doing collaborative editing, doc?.title might be out of sync - * causing an infinite loop of updates. - * To prevent this, we only run this effect when doc?.title changes, - * not when inlineContent.props.title changes. - */ - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [doc?.title, isEditable]); + updateInlineContent({ + type: 'interlinkingLinkInline', + props: { + docId: '', + title: '', + }, + }); - return ; + return null; + } + + return ( + + ); }, }, ); interface LinkSelectedProps { - url: string; + docId: string; title: string; + isEditable: boolean; + updateInlineContent: ( + update: PartialCustomInlineContentFromConfig< + { + readonly type: 'interlinkingLinkInline'; + readonly propSchema: { + readonly docId: { + readonly default: ''; + }; + readonly title: { + readonly default: ''; + }; + }; + readonly content: 'none'; + }, + StyleSchema + >, + ) => void; } -const LinkSelected = ({ url, title }: LinkSelectedProps) => { +export const LinkSelected = ({ + docId, + title, + isEditable, + updateInlineContent, +}: LinkSelectedProps) => { + const { data: doc } = useDoc({ id: docId }); + + /** + * Update the content title if the referenced doc title changes + */ + useEffect(() => { + if (isEditable && doc?.title && doc.title !== title) { + updateInlineContent({ + type: 'interlinkingLinkInline', + props: { + docId, + title: doc.title, + }, + }); + } + + /** + * ⚠️ When doing collaborative editing, doc?.title might be out of sync + * causing an infinite loop of updates. + * To prevent this, we only run this effect when doc?.title changes, + * not when inlineContent.props.title changes. + */ + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [doc?.title, docId, isEditable]); + const { colorsTokens } = useCunninghamTheme(); const { emoji, titleWithoutEmoji } = getEmojiAndTitle(title); @@ -74,7 +127,7 @@ const LinkSelected = ({ url, title }: LinkSelectedProps) => { const handleClick = (e: React.MouseEvent) => { e.preventDefault(); - void router.push(url); + void router.push(`/docs/${docId}/`); }; return ( diff --git a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/SearchPage.tsx b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/SearchPage.tsx index 5ef163cf1..47428e75b 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/SearchPage.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-editor/components/custom-inline-content/Interlinking/SearchPage.tsx @@ -247,7 +247,6 @@ export const SearchPage = ({ { type: 'interlinkingLinkInline', props: { - url: `/docs/${doc.id}`, docId: doc.id, title: doc.title || untitledDocument, }, diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/inline-content-mapping/interlinkingLinkDocx.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/inline-content-mapping/interlinkingLinkDocx.tsx index afb8c0e7d..7da97f087 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-export/inline-content-mapping/interlinkingLinkDocx.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-export/inline-content-mapping/interlinkingLinkDocx.tsx @@ -1,16 +1,24 @@ import { ExternalHyperlink, TextRun } from 'docx'; +import { getEmojiAndTitle } from '@/docs/doc-management'; + import { DocsExporterDocx } from '../types'; export const inlineContentMappingInterlinkingLinkDocx: DocsExporterDocx['mappings']['inlineContentMapping']['interlinkingLinkInline'] = (inline) => { + if (!inline.props.docId) { + return new TextRun(''); + } + + const { emoji, titleWithoutEmoji } = getEmojiAndTitle(inline.props.title); + return new ExternalHyperlink({ children: [ new TextRun({ - text: `📄${inline.props.title}`, + text: `${emoji || '📄'}${titleWithoutEmoji}`, bold: true, }), ], - link: window.location.origin + inline.props.url, + link: window.location.origin + `/docs/${inline.props.docId}/`, }); }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/inline-content-mapping/interlinkingLinkODT.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/inline-content-mapping/interlinkingLinkODT.tsx index 4ffadf08a..ef9d3d9ae 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-export/inline-content-mapping/interlinkingLinkODT.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-export/inline-content-mapping/interlinkingLinkODT.tsx @@ -1,11 +1,17 @@ import React from 'react'; +import { getEmojiAndTitle } from '@/docs/doc-management'; + import { DocsExporterODT } from '../types'; export const inlineContentMappingInterlinkingLinkODT: DocsExporterODT['mappings']['inlineContentMapping']['interlinkingLinkInline'] = (inline) => { - const url = window.location.origin + inline.props.url; - const title = inline.props.title; + if (!inline.props.docId) { + return null; + } + + const { emoji, titleWithoutEmoji } = getEmojiAndTitle(inline.props.title); + const url = window.location.origin + `/docs/${inline.props.docId}/`; // Create ODT hyperlink using React.createElement to avoid TypeScript JSX namespace issues // Uses the same structure as BlockNote's default link mapping @@ -18,6 +24,6 @@ export const inlineContentMappingInterlinkingLinkODT: DocsExporterODT['mappings' xlinkShow: 'replace', xlinkHref: url, }, - `📄${title}`, + `${emoji || '📄'}${titleWithoutEmoji}`, ); }; diff --git a/src/frontend/apps/impress/src/features/docs/doc-export/inline-content-mapping/interlinkingLinkPDF.tsx b/src/frontend/apps/impress/src/features/docs/doc-export/inline-content-mapping/interlinkingLinkPDF.tsx index c2d204b73..87165dfa9 100644 --- a/src/frontend/apps/impress/src/features/docs/doc-export/inline-content-mapping/interlinkingLinkPDF.tsx +++ b/src/frontend/apps/impress/src/features/docs/doc-export/inline-content-mapping/interlinkingLinkPDF.tsx @@ -1,21 +1,29 @@ import { Image, Link, Text } from '@react-pdf/renderer'; +import { getEmojiAndTitle } from '@/docs/doc-management'; + import DocSelectedIcon from '../assets/doc-selected.png'; import { DocsExporterPDF } from '../types'; export const inlineContentMappingInterlinkingLinkPDF: DocsExporterPDF['mappings']['inlineContentMapping']['interlinkingLinkInline'] = (inline) => { + if (!inline.props.docId) { + return <>; + } + + const { emoji, titleWithoutEmoji } = getEmojiAndTitle(inline.props.title); + return ( {' '} - {' '} - {inline.props.title}{' '} + {emoji || }{' '} + {titleWithoutEmoji}{' '} ); };