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}{' '} ); };