Compare commits

...

6 Commits

Author SHA1 Message Date
dwelle
1863da4405 v0.16.4 2024-04-17 22:31:06 +02:00
David Luzar
6be752e1b6 fix: allow same origin for all necessary domains (#7889) 2024-04-17 22:30:16 +02:00
dwelle
7ba029807a v0.16.3 2024-04-12 20:18:51 +02:00
dwelle
b4d9ad4f3c fix: parse embeddable srcdoc urls strictly & escape attribute url html 2024-04-12 20:18:02 +02:00
David Luzar
3ae8af5a13 v0.16.2 2024-04-12 20:15:53 +02:00
David Luzar
053ca9058a fix: Gist embed allowing unsafe html (#7883) 2024-04-12 15:14:18 +02:00
4 changed files with 120 additions and 72 deletions

View File

@ -894,7 +894,11 @@ class App extends React.Component<AppProps, AppState> {
title="Excalidraw Embedded Content"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen={true}
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-presentation allow-downloads"
sandbox={`${
embedLink?.sandbox?.allowSameOrigin
? "allow-same-origin"
: ""
} allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-presentation allow-downloads`}
/>
)}
</div>

View File

@ -1,11 +1,15 @@
import { sanitizeUrl } from "@braintree/sanitize-url";
export const sanitizeHTMLAttribute = (html: string) => {
return html.replace(/"/g, "&quot;");
};
export const normalizeLink = (link: string) => {
link = link.trim();
if (!link) {
return link;
}
return sanitizeUrl(link);
return sanitizeUrl(sanitizeHTMLAttribute(link));
};
export const isLocalLink = (link: string | null) => {

View File

@ -12,11 +12,13 @@ import {
NonDeletedExcalidrawElement,
Theme,
} from "./types";
import { sanitizeHTMLAttribute } from "../data/url";
type EmbeddedLink =
| ({
aspectRatio: { w: number; h: number };
warning?: string;
sandbox: { allowSameOrigin?: boolean };
} & (
| { type: "video" | "generic"; link: string }
| { type: "document"; srcdoc: (theme: Theme) => string }
@ -28,20 +30,21 @@ const embeddedLinkCache = new Map<string, EmbeddedLink>();
const RE_YOUTUBE =
/^(?:http(?:s)?:\/\/)?(?:www\.)?youtu(?:be\.com|\.be)\/(embed\/|watch\?v=|shorts\/|playlist\?list=|embed\/videoseries\?list=)?([a-zA-Z0-9_-]+)(?:\?t=|&t=|\?start=|&start=)?([a-zA-Z0-9_-]+)?[^\s]*$/;
const RE_VIMEO =
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
/^(?:http(?:s)?:\/\/)?(?:(?:w){3}\.)?(?:player\.)?vimeo\.com\/(?:video\/)?([^?\s]+)(?:\?.*)?$/;
const RE_FIGMA = /^https:\/\/(?:www\.)?figma\.com/;
const RE_GH_GIST = /^https:\/\/gist\.github\.com/;
const RE_GH_GIST = /^https:\/\/gist\.github\.com\/([\w_-]+)\/([\w_-]+)/;
const RE_GH_GIST_EMBED =
/^<script[\s\S]*?\ssrc=["'](https:\/\/gist.github.com\/.*?)\.js["']/i;
/^<script[\s\S]*?\ssrc=["'](https:\/\/gist\.github\.com\/.*?)\.js["']/i;
// not anchored to start to allow <blockquote> twitter embeds
const RE_TWITTER = /(?:http(?:s)?:\/\/)?(?:(?:w){3}.)?twitter.com/;
const RE_TWITTER =
/(?:https?:\/\/)?(?:(?:w){3}\.)?(?:twitter|x)\.com\/[^/]+\/status\/(\d+)/;
const RE_TWITTER_EMBED =
/^<blockquote[\s\S]*?\shref=["'](https:\/\/twitter.com\/[^"']*)/i;
/^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:twitter|x)\.com\/[^"']*)/i;
const RE_VALTOWN =
/^https:\/\/(?:www\.)?val.town\/(v|embed)\/[a-zA-Z_$][0-9a-zA-Z_$]+\.[a-zA-Z_$][0-9a-zA-Z_$]+/;
/^https:\/\/(?:www\.)?val\.town\/(v|embed)\/[a-zA-Z_$][0-9a-zA-Z_$]+\.[a-zA-Z_$][0-9a-zA-Z_$]+/;
const RE_GENERIC_EMBED =
/^<(?:iframe|blockquote)[\s\S]*?\s(?:src|href)=["']([^"']*)["'][\s\S]*?>$/i;
@ -60,6 +63,18 @@ const ALLOWED_DOMAINS = new Set([
"val.town",
]);
const ALLOW_SAME_ORIGIN = new Set([
"youtube.com",
"youtu.be",
"vimeo.com",
"player.vimeo.com",
"figma.com",
"twitter.com",
"x.com",
"*.simplepdf.eu",
"stackblitz.com",
]);
const createSrcDoc = (body: string) => {
return `<html><body>${body}</body></html>`;
};
@ -75,6 +90,10 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
const originalLink = link;
const allowSameOrigin = ALLOW_SAME_ORIGIN.has(
matchHostname(link, ALLOW_SAME_ORIGIN) || "",
);
let type: "video" | "generic" = "generic";
let aspectRatio = { w: 560, h: 840 };
const ytLink = link.match(RE_YOUTUBE);
@ -97,8 +116,13 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
break;
}
aspectRatio = isPortrait ? { w: 315, h: 560 } : { w: 560, h: 315 };
embeddedLinkCache.set(originalLink, { link, aspectRatio, type });
return { link, aspectRatio, type };
embeddedLinkCache.set(originalLink, {
link,
aspectRatio,
type,
sandbox: { allowSameOrigin },
});
return { link, aspectRatio, type, sandbox: { allowSameOrigin } };
}
const vimeoLink = link.match(RE_VIMEO);
@ -112,8 +136,13 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
aspectRatio = { w: 560, h: 315 };
//warning deliberately ommited so it is displayed only once per link
//same link next time will be served from cache
embeddedLinkCache.set(originalLink, { link, aspectRatio, type });
return { link, aspectRatio, type, warning };
embeddedLinkCache.set(originalLink, {
link,
aspectRatio,
type,
sandbox: { allowSameOrigin },
});
return { link, aspectRatio, type, warning, sandbox: { allowSameOrigin } };
}
const figmaLink = link.match(RE_FIGMA);
@ -123,75 +152,81 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
link,
)}`;
aspectRatio = { w: 550, h: 550 };
embeddedLinkCache.set(originalLink, { link, aspectRatio, type });
return { link, aspectRatio, type };
embeddedLinkCache.set(originalLink, {
link,
aspectRatio,
type,
sandbox: { allowSameOrigin },
});
return { link, aspectRatio, type, sandbox: { allowSameOrigin } };
}
const valLink = link.match(RE_VALTOWN);
if (valLink) {
link =
valLink[1] === "embed" ? valLink[0] : valLink[0].replace("/v", "/embed");
embeddedLinkCache.set(originalLink, { link, aspectRatio, type });
return { link, aspectRatio, type };
embeddedLinkCache.set(originalLink, {
link,
aspectRatio,
type,
sandbox: { allowSameOrigin },
});
return { link, aspectRatio, type, sandbox: { allowSameOrigin } };
}
if (RE_TWITTER.test(link)) {
let ret: EmbeddedLink;
// assume embed code
if (/<blockquote/.test(link)) {
const srcDoc = createSrcDoc(link);
ret = {
type: "document",
srcdoc: () => srcDoc,
aspectRatio: { w: 480, h: 480 },
};
// assume regular tweet url
} else {
ret = {
type: "document",
srcdoc: (theme: string) =>
createSrcDoc(
`<blockquote class="twitter-tweet" data-dnt="true" data-theme="${theme}"><a href="${link}"></a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>`,
),
aspectRatio: { w: 480, h: 480 },
};
}
const postId = link.match(RE_TWITTER)![1];
// the embed srcdoc still supports twitter.com domain only.
// Note that we don't attempt to parse the username as it can consist of
// non-latin1 characters, and the username in the url can be set to anything
// without affecting the embed.
const safeURL = sanitizeHTMLAttribute(
`https://twitter.com/x/status/${postId}`,
);
const ret: EmbeddedLink = {
type: "document",
srcdoc: (theme: string) =>
createSrcDoc(
`<blockquote class="twitter-tweet" data-dnt="true" data-theme="${theme}"><a href="${safeURL}"></a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>`,
),
aspectRatio: { w: 480, h: 480 },
sandbox: { allowSameOrigin },
};
embeddedLinkCache.set(originalLink, ret);
return ret;
}
if (RE_GH_GIST.test(link)) {
let ret: EmbeddedLink;
// assume embed code
if (/<script>/.test(link)) {
const srcDoc = createSrcDoc(link);
ret = {
type: "document",
srcdoc: () => srcDoc,
aspectRatio: { w: 550, h: 720 },
};
// assume regular url
} else {
ret = {
type: "document",
srcdoc: () =>
createSrcDoc(`
<script src="${link}.js"></script>
const [, user, gistId] = link.match(RE_GH_GIST)!;
const safeURL = sanitizeHTMLAttribute(
`https://gist.github.com/${user}/${gistId}`,
);
const ret: EmbeddedLink = {
type: "document",
srcdoc: () =>
createSrcDoc(`
<script src="${safeURL}.js"></script>
<style type="text/css">
* { margin: 0px; }
table, .gist { height: 100%; }
.gist .gist-file { height: calc(100vh - 2px); padding: 0px; display: grid; grid-template-rows: 1fr auto; }
</style>
`),
aspectRatio: { w: 550, h: 720 },
};
}
aspectRatio: { w: 550, h: 720 },
sandbox: { allowSameOrigin },
};
embeddedLinkCache.set(link, ret);
return ret;
}
embeddedLinkCache.set(link, { link, aspectRatio, type });
return { link, aspectRatio, type };
embeddedLinkCache.set(link, {
link,
aspectRatio,
type,
sandbox: { allowSameOrigin },
});
return { link, aspectRatio, type, sandbox: { allowSameOrigin } };
};
export const isEmbeddableOrFrameLabel = (
@ -266,34 +301,39 @@ export const actionSetEmbeddableAsActiveTool = register({
},
});
const validateHostname = (
const matchHostname = (
url: string,
/** using a Set assumes it already contains normalized bare domains */
allowedHostnames: Set<string> | string,
): boolean => {
): string | null => {
try {
const { hostname } = new URL(url);
const bareDomain = hostname.replace(/^www\./, "");
const bareDomainWithFirstSubdomainWildcarded = bareDomain.replace(
/^([^.]+)/,
"*",
);
if (allowedHostnames instanceof Set) {
return (
ALLOWED_DOMAINS.has(bareDomain) ||
ALLOWED_DOMAINS.has(bareDomainWithFirstSubdomainWildcarded)
if (ALLOWED_DOMAINS.has(bareDomain)) {
return bareDomain;
}
const bareDomainWithFirstSubdomainWildcarded = bareDomain.replace(
/^([^.]+)/,
"*",
);
if (ALLOWED_DOMAINS.has(bareDomainWithFirstSubdomainWildcarded)) {
return bareDomainWithFirstSubdomainWildcarded;
}
return null;
}
if (bareDomain === allowedHostnames.replace(/^www\./, "")) {
return true;
const bareAllowedHostname = allowedHostnames.replace(/^www\./, "");
if (bareDomain === bareAllowedHostname) {
return bareAllowedHostname;
}
} catch (error) {
// ignore
}
return false;
return null;
};
export const extractSrc = (htmlString: string): string => {
@ -338,7 +378,7 @@ export const embeddableURLValidator = (
if (url.match(domain)) {
return true;
}
} else if (validateHostname(url, domain)) {
} else if (matchHostname(url, domain)) {
return true;
}
}
@ -346,5 +386,5 @@ export const embeddableURLValidator = (
}
}
return validateHostname(url, ALLOWED_DOMAINS);
return !!matchHostname(url, ALLOWED_DOMAINS);
};

View File

@ -1,6 +1,6 @@
{
"name": "@excalidraw/excalidraw",
"version": "0.16.0",
"version": "0.16.4",
"main": "main.js",
"types": "types/packages/excalidraw/index.d.ts",
"files": [