🔒️(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:
@@ -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"
|
||||
|
||||
@@ -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 <LinkSelected {...inlineContent.props} />;
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<LinkSelected
|
||||
docId={inlineContent.props.docId}
|
||||
title={inlineContent.props.title}
|
||||
isEditable={editor.isEditable}
|
||||
updateInlineContent={updateInlineContent}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
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<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
void router.push(url);
|
||||
void router.push(`/docs/${docId}/`);
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -247,7 +247,6 @@ export const SearchPage = ({
|
||||
{
|
||||
type: 'interlinkingLinkInline',
|
||||
props: {
|
||||
url: `/docs/${doc.id}`,
|
||||
docId: doc.id,
|
||||
title: doc.title || untitledDocument,
|
||||
},
|
||||
|
||||
@@ -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}/`,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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 (
|
||||
<Link
|
||||
src={window.location.origin + inline.props.url}
|
||||
src={window.location.origin + `/docs/${inline.props.docId}/`}
|
||||
style={{
|
||||
textDecoration: 'none',
|
||||
color: 'black',
|
||||
}}
|
||||
>
|
||||
{' '}
|
||||
<Image src={DocSelectedIcon.src} />{' '}
|
||||
<Text>{inline.props.title}</Text>{' '}
|
||||
{emoji || <Image src={DocSelectedIcon.src} />}{' '}
|
||||
<Text>{titleWithoutEmoji}</Text>{' '}
|
||||
</Link>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user