🔒️(frontend) fix props vulnerability in Interlinking
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.
This commit is contained in:
15
CHANGELOG.md
15
CHANGELOG.md
@@ -12,7 +12,13 @@ and this project adheres to
|
|||||||
- ✅(export) add PDF regression tests #1762
|
- ✅(export) add PDF regression tests #1762
|
||||||
- 📝(docs) Add language configuration documentation #1757
|
- 📝(docs) Add language configuration documentation #1757
|
||||||
- 🔒(helm) Set default security context #1750
|
- 🔒(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
|
### Fixed
|
||||||
|
|
||||||
@@ -24,12 +30,7 @@ and this project adheres to
|
|||||||
### Security
|
### Security
|
||||||
|
|
||||||
- 🔒️(backend) validate more strictly url used by cors-proxy endpoint #1768
|
- 🔒️(backend) validate more strictly url used by cors-proxy endpoint #1768
|
||||||
|
- 🔒️(frontend) fix props vulnerability in Interlinking #1792
|
||||||
### 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
|
|
||||||
|
|
||||||
## [4.3.0] - 2026-01-05
|
## [4.3.0] - 2026-01-05
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,7 @@
|
|||||||
"react-select": "5.10.2",
|
"react-select": "5.10.2",
|
||||||
"styled-components": "6.1.19",
|
"styled-components": "6.1.19",
|
||||||
"use-debounce": "10.0.6",
|
"use-debounce": "10.0.6",
|
||||||
|
"uuid": "13.0.0",
|
||||||
"y-protocols": "1.0.7",
|
"y-protocols": "1.0.7",
|
||||||
"yjs": "*",
|
"yjs": "*",
|
||||||
"zustand": "5.0.9"
|
"zustand": "5.0.9"
|
||||||
|
|||||||
@@ -1,21 +1,23 @@
|
|||||||
/* eslint-disable react-hooks/rules-of-hooks */
|
import {
|
||||||
|
PartialCustomInlineContentFromConfig,
|
||||||
|
StyleSchema,
|
||||||
|
} from '@blocknote/core';
|
||||||
import { createReactInlineContentSpec } from '@blocknote/react';
|
import { createReactInlineContentSpec } from '@blocknote/react';
|
||||||
|
import * as Sentry from '@sentry/nextjs';
|
||||||
import { useRouter } from 'next/router';
|
import { useRouter } from 'next/router';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { css } from 'styled-components';
|
import { css } from 'styled-components';
|
||||||
|
import { validate as uuidValidate } from 'uuid';
|
||||||
|
|
||||||
import { BoxButton, Text } from '@/components';
|
import { BoxButton, Text } from '@/components';
|
||||||
import { useCunninghamTheme } from '@/cunningham';
|
import { useCunninghamTheme } from '@/cunningham';
|
||||||
import SelectedPageIcon from '@/docs/doc-editor/assets/doc-selected.svg';
|
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(
|
export const InterlinkingLinkInlineContent = createReactInlineContentSpec(
|
||||||
{
|
{
|
||||||
type: 'interlinkingLinkInline',
|
type: 'interlinkingLinkInline',
|
||||||
propSchema: {
|
propSchema: {
|
||||||
url: {
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
docId: {
|
docId: {
|
||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
@@ -27,46 +29,97 @@ export const InterlinkingLinkInlineContent = createReactInlineContentSpec(
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
render: ({ editor, inlineContent, updateInlineContent }) => {
|
render: ({ editor, inlineContent, updateInlineContent }) => {
|
||||||
const { data: doc } = useDoc({ id: inlineContent.props.docId });
|
if (!inlineContent.props.docId) {
|
||||||
const isEditable = editor.isEditable;
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update the content title if the referenced doc title changes
|
* Should not happen
|
||||||
*/
|
*/
|
||||||
useEffect(() => {
|
if (!uuidValidate(inlineContent.props.docId)) {
|
||||||
if (
|
Sentry.captureException(
|
||||||
isEditable &&
|
new Error(`Invalid docId: ${inlineContent.props.docId}`),
|
||||||
doc?.title &&
|
{
|
||||||
doc.title !== inlineContent.props.title
|
extra: { info: 'InterlinkingLinkInlineContent' },
|
||||||
) {
|
},
|
||||||
updateInlineContent({
|
);
|
||||||
type: 'interlinkingLinkInline',
|
|
||||||
props: {
|
|
||||||
...inlineContent.props,
|
|
||||||
title: doc.title,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
updateInlineContent({
|
||||||
* ⚠️ When doing collaborative editing, doc?.title might be out of sync
|
type: 'interlinkingLinkInline',
|
||||||
* causing an infinite loop of updates.
|
props: {
|
||||||
* To prevent this, we only run this effect when doc?.title changes,
|
docId: '',
|
||||||
* not when inlineContent.props.title changes.
|
title: '',
|
||||||
*/
|
},
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
});
|
||||||
}, [doc?.title, isEditable]);
|
|
||||||
|
|
||||||
return <LinkSelected {...inlineContent.props} />;
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LinkSelected
|
||||||
|
docId={inlineContent.props.docId}
|
||||||
|
title={inlineContent.props.title}
|
||||||
|
isEditable={editor.isEditable}
|
||||||
|
updateInlineContent={updateInlineContent}
|
||||||
|
/>
|
||||||
|
);
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
interface LinkSelectedProps {
|
interface LinkSelectedProps {
|
||||||
url: string;
|
docId: string;
|
||||||
title: 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 { colorsTokens } = useCunninghamTheme();
|
||||||
|
|
||||||
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(title);
|
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(title);
|
||||||
@@ -74,7 +127,7 @@ const LinkSelected = ({ url, title }: LinkSelectedProps) => {
|
|||||||
|
|
||||||
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
void router.push(url);
|
void router.push(`/docs/${docId}/`);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -247,7 +247,6 @@ export const SearchPage = ({
|
|||||||
{
|
{
|
||||||
type: 'interlinkingLinkInline',
|
type: 'interlinkingLinkInline',
|
||||||
props: {
|
props: {
|
||||||
url: `/docs/${doc.id}`,
|
|
||||||
docId: doc.id,
|
docId: doc.id,
|
||||||
title: doc.title || untitledDocument,
|
title: doc.title || untitledDocument,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
import { ExternalHyperlink, TextRun } from 'docx';
|
import { ExternalHyperlink, TextRun } from 'docx';
|
||||||
|
|
||||||
|
import { getEmojiAndTitle } from '@/docs/doc-management';
|
||||||
|
|
||||||
import { DocsExporterDocx } from '../types';
|
import { DocsExporterDocx } from '../types';
|
||||||
|
|
||||||
export const inlineContentMappingInterlinkingLinkDocx: DocsExporterDocx['mappings']['inlineContentMapping']['interlinkingLinkInline'] =
|
export const inlineContentMappingInterlinkingLinkDocx: DocsExporterDocx['mappings']['inlineContentMapping']['interlinkingLinkInline'] =
|
||||||
(inline) => {
|
(inline) => {
|
||||||
|
if (!inline.props.docId) {
|
||||||
|
return new TextRun('');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(inline.props.title);
|
||||||
|
|
||||||
return new ExternalHyperlink({
|
return new ExternalHyperlink({
|
||||||
children: [
|
children: [
|
||||||
new TextRun({
|
new TextRun({
|
||||||
text: `📄${inline.props.title}`,
|
text: `${emoji || '📄'}${titleWithoutEmoji}`,
|
||||||
bold: true,
|
bold: true,
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
link: window.location.origin + inline.props.url,
|
link: window.location.origin + `/docs/${inline.props.docId}/`,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
|
import { getEmojiAndTitle } from '@/docs/doc-management';
|
||||||
|
|
||||||
import { DocsExporterODT } from '../types';
|
import { DocsExporterODT } from '../types';
|
||||||
|
|
||||||
export const inlineContentMappingInterlinkingLinkODT: DocsExporterODT['mappings']['inlineContentMapping']['interlinkingLinkInline'] =
|
export const inlineContentMappingInterlinkingLinkODT: DocsExporterODT['mappings']['inlineContentMapping']['interlinkingLinkInline'] =
|
||||||
(inline) => {
|
(inline) => {
|
||||||
const url = window.location.origin + inline.props.url;
|
if (!inline.props.docId) {
|
||||||
const title = inline.props.title;
|
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
|
// Create ODT hyperlink using React.createElement to avoid TypeScript JSX namespace issues
|
||||||
// Uses the same structure as BlockNote's default link mapping
|
// Uses the same structure as BlockNote's default link mapping
|
||||||
@@ -18,6 +24,6 @@ export const inlineContentMappingInterlinkingLinkODT: DocsExporterODT['mappings'
|
|||||||
xlinkShow: 'replace',
|
xlinkShow: 'replace',
|
||||||
xlinkHref: url,
|
xlinkHref: url,
|
||||||
},
|
},
|
||||||
`📄${title}`,
|
`${emoji || '📄'}${titleWithoutEmoji}`,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,21 +1,29 @@
|
|||||||
import { Image, Link, Text } from '@react-pdf/renderer';
|
import { Image, Link, Text } from '@react-pdf/renderer';
|
||||||
|
|
||||||
|
import { getEmojiAndTitle } from '@/docs/doc-management';
|
||||||
|
|
||||||
import DocSelectedIcon from '../assets/doc-selected.png';
|
import DocSelectedIcon from '../assets/doc-selected.png';
|
||||||
import { DocsExporterPDF } from '../types';
|
import { DocsExporterPDF } from '../types';
|
||||||
|
|
||||||
export const inlineContentMappingInterlinkingLinkPDF: DocsExporterPDF['mappings']['inlineContentMapping']['interlinkingLinkInline'] =
|
export const inlineContentMappingInterlinkingLinkPDF: DocsExporterPDF['mappings']['inlineContentMapping']['interlinkingLinkInline'] =
|
||||||
(inline) => {
|
(inline) => {
|
||||||
|
if (!inline.props.docId) {
|
||||||
|
return <></>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { emoji, titleWithoutEmoji } = getEmojiAndTitle(inline.props.title);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
src={window.location.origin + inline.props.url}
|
src={window.location.origin + `/docs/${inline.props.docId}/`}
|
||||||
style={{
|
style={{
|
||||||
textDecoration: 'none',
|
textDecoration: 'none',
|
||||||
color: 'black',
|
color: 'black',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{' '}
|
{' '}
|
||||||
<Image src={DocSelectedIcon.src} />{' '}
|
{emoji || <Image src={DocSelectedIcon.src} />}{' '}
|
||||||
<Text>{inline.props.title}</Text>{' '}
|
<Text>{titleWithoutEmoji}</Text>{' '}
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user