Compare commits

..

4 Commits

Author SHA1 Message Date
dwelle
0dbd2a3931 v0.17.6 2024-04-17 21:57:24 +02:00
David Luzar
988f81911c fix: allow same origin for all necessary domains (#7889) 2024-04-17 21:56:52 +02:00
dwelle
db4770ed83 v0.17.5 2024-04-12 20:11:07 +02:00
dwelle
58338e54c9 fix: parse embeddable srcdoc urls strictly & escape attribute url html 2024-04-12 20:08:04 +02:00
3 changed files with 105 additions and 38 deletions

View File

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

View File

@@ -13,12 +13,13 @@ import {
NonDeletedExcalidrawElement, NonDeletedExcalidrawElement,
Theme, Theme,
} from "./types"; } from "./types";
import { sanitizeHTMLAttribute } from "../data/url";
type EmbeddedLink = type EmbeddedLink =
| ({ | ({
aspectRatio: { w: number; h: number }; aspectRatio: { w: number; h: number };
warning?: string; warning?: string;
sandbox?: { allowSameOrigin?: boolean }; sandbox: { allowSameOrigin?: boolean };
} & ( } & (
| { type: "video" | "generic"; link: string } | { type: "video" | "generic"; link: string }
| { type: "document"; srcdoc: (theme: Theme) => string } | { type: "document"; srcdoc: (theme: Theme) => string }
@@ -34,12 +35,13 @@ 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_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 = const RE_GH_GIST_EMBED =
/https?:\/\/gist\.github\.com\/([\w_-]+)\/([\w_-]+)\.js["']/i; /^<script[\s\S]*?\ssrc=["'](https:\/\/gist\.github\.com\/.*?)\.js["']/i;
// not anchored to start to allow <blockquote> twitter embeds // not anchored to start to allow <blockquote> twitter embeds
const RE_TWITTER = /(?:https?:\/\/)?(?:(?:w){3}\.)?(?:twitter|x)\.com/; const RE_TWITTER =
/(?:https?:\/\/)?(?:(?:w){3}\.)?(?:twitter|x)\.com\/[^/]+\/status\/(\d+)/;
const RE_TWITTER_EMBED = const RE_TWITTER_EMBED =
/^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:twitter|x)\.com\/[^"']*)/i; /^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:twitter|x)\.com\/[^"']*)/i;
@@ -65,7 +67,18 @@ const ALLOWED_DOMAINS = new Set([
"stackblitz.com", "stackblitz.com",
"val.town", "val.town",
"giphy.com", "giphy.com",
"dddice.com", ]);
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) => { const createSrcDoc = (body: string) => {
@@ -83,6 +96,10 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
const originalLink = link; const originalLink = link;
const allowSameOrigin = ALLOW_SAME_ORIGIN.has(
matchHostname(link, ALLOW_SAME_ORIGIN) || "",
);
let type: "video" | "generic" = "generic"; let type: "video" | "generic" = "generic";
let aspectRatio = { w: 560, h: 840 }; let aspectRatio = { w: 560, h: 840 };
const ytLink = link.match(RE_YOUTUBE); const ytLink = link.match(RE_YOUTUBE);
@@ -105,8 +122,18 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
break; break;
} }
aspectRatio = isPortrait ? { w: 315, h: 560 } : { w: 560, h: 315 }; aspectRatio = isPortrait ? { w: 315, h: 560 } : { w: 560, h: 315 };
embeddedLinkCache.set(originalLink, { link, aspectRatio, type }); embeddedLinkCache.set(originalLink, {
return { link, aspectRatio, type }; link,
aspectRatio,
type,
sandbox: { allowSameOrigin },
});
return {
link,
aspectRatio,
type,
sandbox: { allowSameOrigin },
};
} }
const vimeoLink = link.match(RE_VIMEO); const vimeoLink = link.match(RE_VIMEO);
@@ -120,8 +147,13 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
aspectRatio = { w: 560, h: 315 }; aspectRatio = { w: 560, h: 315 };
//warning deliberately ommited so it is displayed only once per link //warning deliberately ommited so it is displayed only once per link
//same link next time will be served from cache //same link next time will be served from cache
embeddedLinkCache.set(originalLink, { link, aspectRatio, type }); embeddedLinkCache.set(originalLink, {
return { link, aspectRatio, type, warning }; link,
aspectRatio,
type,
sandbox: { allowSameOrigin },
});
return { link, aspectRatio, type, warning, sandbox: { allowSameOrigin } };
} }
const figmaLink = link.match(RE_FIGMA); const figmaLink = link.match(RE_FIGMA);
@@ -131,41 +163,61 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
link, link,
)}`; )}`;
aspectRatio = { w: 550, h: 550 }; aspectRatio = { w: 550, h: 550 };
embeddedLinkCache.set(originalLink, { link, aspectRatio, type }); embeddedLinkCache.set(originalLink, {
return { link, aspectRatio, type }; link,
aspectRatio,
type,
sandbox: { allowSameOrigin },
});
return { link, aspectRatio, type, sandbox: { allowSameOrigin } };
} }
const valLink = link.match(RE_VALTOWN); const valLink = link.match(RE_VALTOWN);
if (valLink) { if (valLink) {
link = link =
valLink[1] === "embed" ? valLink[0] : valLink[0].replace("/v", "/embed"); valLink[1] === "embed" ? valLink[0] : valLink[0].replace("/v", "/embed");
embeddedLinkCache.set(originalLink, { link, aspectRatio, type }); embeddedLinkCache.set(originalLink, {
return { link, aspectRatio, type }; link,
aspectRatio,
type,
sandbox: { allowSameOrigin },
});
return { link, aspectRatio, type, sandbox: { allowSameOrigin } };
} }
if (RE_TWITTER.test(link)) { if (RE_TWITTER.test(link)) {
// the embed srcdoc still supports twitter.com domain only const postId = link.match(RE_TWITTER)![1];
link = link.replace(/\bx.com\b/, "twitter.com"); // 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 = { const ret: EmbeddedLink = {
type: "document", type: "document",
srcdoc: (theme: string) => srcdoc: (theme: string) =>
createSrcDoc( 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>`, `<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 }, aspectRatio: { w: 480, h: 480 },
sandbox: { allowSameOrigin: true }, sandbox: { allowSameOrigin },
}; };
embeddedLinkCache.set(originalLink, ret); embeddedLinkCache.set(originalLink, ret);
return ret; return ret;
} }
if (RE_GH_GIST.test(link)) { if (RE_GH_GIST.test(link)) {
const [, user, gistId] = link.match(RE_GH_GIST)!;
const safeURL = sanitizeHTMLAttribute(
`https://gist.github.com/${user}/${gistId}`,
);
const ret: EmbeddedLink = { const ret: EmbeddedLink = {
type: "document", type: "document",
srcdoc: () => srcdoc: () =>
createSrcDoc(` createSrcDoc(`
<script src="${link}.js"></script> <script src="${safeURL}.js"></script>
<style type="text/css"> <style type="text/css">
* { margin: 0px; } * { margin: 0px; }
table, .gist { height: 100%; } table, .gist { height: 100%; }
@@ -173,13 +225,19 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => {
</style> </style>
`), `),
aspectRatio: { w: 550, h: 720 }, aspectRatio: { w: 550, h: 720 },
sandbox: { allowSameOrigin },
}; };
embeddedLinkCache.set(link, ret); embeddedLinkCache.set(link, ret);
return ret; return ret;
} }
embeddedLinkCache.set(link, { link, aspectRatio, type }); embeddedLinkCache.set(link, {
return { link, aspectRatio, type }; link,
aspectRatio,
type,
sandbox: { allowSameOrigin },
});
return { link, aspectRatio, type, sandbox: { allowSameOrigin } };
}; };
export const isEmbeddableOrLabel = ( export const isEmbeddableOrLabel = (
@@ -254,34 +312,39 @@ export const actionSetEmbeddableAsActiveTool = register({
}, },
}); });
const validateHostname = ( const matchHostname = (
url: string, url: string,
/** using a Set assumes it already contains normalized bare domains */ /** using a Set assumes it already contains normalized bare domains */
allowedHostnames: Set<string> | string, allowedHostnames: Set<string> | string,
): boolean => { ): string | null => {
try { try {
const { hostname } = new URL(url); const { hostname } = new URL(url);
const bareDomain = hostname.replace(/^www\./, ""); const bareDomain = hostname.replace(/^www\./, "");
const bareDomainWithFirstSubdomainWildcarded = bareDomain.replace(
/^([^.]+)/,
"*",
);
if (allowedHostnames instanceof Set) { if (allowedHostnames instanceof Set) {
return ( if (ALLOWED_DOMAINS.has(bareDomain)) {
ALLOWED_DOMAINS.has(bareDomain) || return bareDomain;
ALLOWED_DOMAINS.has(bareDomainWithFirstSubdomainWildcarded) }
const bareDomainWithFirstSubdomainWildcarded = bareDomain.replace(
/^([^.]+)/,
"*",
); );
if (ALLOWED_DOMAINS.has(bareDomainWithFirstSubdomainWildcarded)) {
return bareDomainWithFirstSubdomainWildcarded;
}
return null;
} }
if (bareDomain === allowedHostnames.replace(/^www\./, "")) { const bareAllowedHostname = allowedHostnames.replace(/^www\./, "");
return true; if (bareDomain === bareAllowedHostname) {
return bareAllowedHostname;
} }
} catch (error) { } catch (error) {
// ignore // ignore
} }
return false; return null;
}; };
export const extractSrc = (htmlString: string): string => { export const extractSrc = (htmlString: string): string => {
@@ -291,8 +354,8 @@ export const extractSrc = (htmlString: string): string => {
} }
const gistMatch = htmlString.match(RE_GH_GIST_EMBED); const gistMatch = htmlString.match(RE_GH_GIST_EMBED);
if (gistMatch && gistMatch.length === 3) { if (gistMatch && gistMatch.length === 2) {
return `https://gist.github.com/${gistMatch[1]}/${gistMatch[2]}`; return gistMatch[1];
} }
if (RE_GIPHY.test(htmlString)) { if (RE_GIPHY.test(htmlString)) {
@@ -330,7 +393,7 @@ export const embeddableURLValidator = (
if (url.match(domain)) { if (url.match(domain)) {
return true; return true;
} }
} else if (validateHostname(url, domain)) { } else if (matchHostname(url, domain)) {
return true; return true;
} }
} }
@@ -338,5 +401,5 @@ export const embeddableURLValidator = (
} }
} }
return validateHostname(url, ALLOWED_DOMAINS); return !!matchHostname(url, ALLOWED_DOMAINS);
}; };

View File

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