Skip to content

Commit

Permalink
Harden media embed element against XSS
Browse files Browse the repository at this point in the history
  • Loading branch information
12joan committed Jul 6, 2024
1 parent a59cdc0 commit 1bc0971
Show file tree
Hide file tree
Showing 3 changed files with 73 additions and 10 deletions.
6 changes: 6 additions & 0 deletions .changeset/selfish-dogs-buy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
"@udecode/plate-media": patch
---

- Explicitly prohibit `javascript:` protocol when parsing URLs in `useMediaState`.
- In the return value of `useMediaState`, rename `url` to `unsafeUrl` to indicate that it has not been sanitised.
37 changes: 37 additions & 0 deletions packages/media/src/media/useMediaState.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { type EmbedUrlParser, parseMediaUrl } from './useMediaState';

describe('parseMediaUrl', () => {
const parsersWithoutFallback: EmbedUrlParser[] = [
(url) => (url.startsWith('a') ? { id: 'A' } : undefined),
(url) => (url.endsWith('b') ? { id: 'B' } : undefined),
];

const parsersWithFallback: EmbedUrlParser[] = [
...parsersWithoutFallback,
() => ({ id: 'C' }),
];

it('returns undefined if no parsers match', () => {
const embed = parseMediaUrl('x', { urlParsers: parsersWithoutFallback });
expect(embed).toBeUndefined();
});

it('uses the first matching parser', () => {
const embed = parseMediaUrl('ab', { urlParsers: parsersWithoutFallback });
expect(embed?.id).toBe('A');
});

it('uses fallback parser if present', () => {
const embed = parseMediaUrl('javascript', {
urlParsers: parsersWithFallback,
});
expect(embed?.id).toBe('C');
});

it('does not allow javascript: URLs', () => {
const embed = parseMediaUrl('javascript:', {
urlParsers: parsersWithFallback,
});
expect(embed).toBeUndefined();
});
});
40 changes: 30 additions & 10 deletions packages/media/src/media/useMediaState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,9 @@ import { useElement } from '@udecode/plate-common';
import { useFocused, useReadOnly, useSelected } from 'slate-react';

import type { TMediaElement } from './types';

import { ELEMENT_MEDIA_EMBED, VIDEO_PROVIDERS } from '../media-embed';
import { ELEMENT_VIDEO } from '../video';
import { VIDEO_PROVIDERS, ELEMENT_MEDIA_EMBED} from '../media-embed';

export type EmbedUrlData = {
id?: string;
Expand All @@ -15,6 +16,30 @@ export type EmbedUrlData = {

export type EmbedUrlParser = (url: string) => EmbedUrlData | undefined;

export const parseMediaUrl = (
url: string,
{
urlParsers,
}: {
urlParsers: EmbedUrlParser[];
}
): EmbedUrlData | undefined => {
// Harden against XSS
try {
if (new URL(url).protocol === 'javascript:') {
return undefined;
}
} catch {}

for (const parser of urlParsers) {
const data = parser(url);

if (data) {
return data;
}
}
};

export const useMediaState = ({
urlParsers,
}: {
Expand All @@ -28,15 +53,10 @@ export const useMediaState = ({
const { align = 'left', id, isUpload, name, type, url } = element;

const embed = React.useMemo(() => {
if (!urlParsers || (type !== ELEMENT_VIDEO && type !== ELEMENT_MEDIA_EMBED)) return;
if (!urlParsers || (type !== ELEMENT_VIDEO && type !== ELEMENT_MEDIA_EMBED))
return;

for (const parser of urlParsers) {
const data = parser(url);

if (data) {
return data;
}
}
return parseMediaUrl(url, { urlParsers });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [urlParsers, url]);

Expand All @@ -56,6 +76,6 @@ export const useMediaState = ({
name,
readOnly,
selected,
url,
unsafeUrl: url,
};
};

0 comments on commit 1bc0971

Please sign in to comment.