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 82e409e3..5b95b70d 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 cb701fc9..a0dcf896 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 5dc9a2be..be5145dd 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 5ef163cf..47428e75 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 afb8c0e7..7da97f08 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 4ffadf08..ef9d3d9a 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 c2d204b7..87165dfa 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}{' '} ); };