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
6 changed files with 121 additions and 96 deletions
+6 -2
View File
@@ -894,7 +894,11 @@ class App extends React.Component<AppProps, AppState> {
title="Excalidraw Embedded Content" title="Excalidraw Embedded Content"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen={true} 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> </div>
@@ -1086,7 +1090,7 @@ class App extends React.Component<AppProps, AppState> {
cursor: CURSOR_TYPE.MOVE, cursor: CURSOR_TYPE.MOVE,
pointerEvents: this.state.viewModeEnabled pointerEvents: this.state.viewModeEnabled
? POINTER_EVENTS.disabled ? POINTER_EVENTS.disabled
: POINTER_EVENTS.enabled, : POINTER_EVENTS.inheritFromUI,
}} }}
onPointerDown={(event) => this.handleCanvasPointerDown(event)} onPointerDown={(event) => this.handleCanvasPointerDown(event)}
onWheel={(event) => this.handleWheel(event)} onWheel={(event) => this.handleWheel(event)}
+5 -1
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, "&quot;");
};
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) => {
+109 -69
View File
@@ -12,11 +12,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 };
} & ( } & (
| { type: "video" | "generic"; link: string } | { type: "video" | "generic"; link: string }
| { type: "document"; srcdoc: (theme: Theme) => string } | { type: "document"; srcdoc: (theme: Theme) => string }
@@ -28,20 +30,21 @@ const embeddedLinkCache = new Map<string, EmbeddedLink>();
const RE_YOUTUBE = 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]*$/; /^(?: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 = 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 =
/^<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 // 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 = const RE_TWITTER_EMBED =
/^<blockquote[\s\S]*?\shref=["'](https:\/\/twitter.com\/[^"']*)/i; /^<blockquote[\s\S]*?\shref=["'](https?:\/\/(?:twitter|x)\.com\/[^"']*)/i;
const RE_VALTOWN = 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 = const RE_GENERIC_EMBED =
/^<(?:iframe|blockquote)[\s\S]*?\s(?:src|href)=["']([^"']*)["'][\s\S]*?>$/i; /^<(?:iframe|blockquote)[\s\S]*?\s(?:src|href)=["']([^"']*)["'][\s\S]*?>$/i;
@@ -60,6 +63,18 @@ const ALLOWED_DOMAINS = new Set([
"val.town", "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) => { const createSrcDoc = (body: string) => {
return `<html><body>${body}</body></html>`; return `<html><body>${body}</body></html>`;
}; };
@@ -75,6 +90,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);
@@ -97,8 +116,13 @@ 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);
@@ -112,8 +136,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);
@@ -123,75 +152,81 @@ 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)) {
let ret: EmbeddedLink; const postId = link.match(RE_TWITTER)![1];
// assume embed code // the embed srcdoc still supports twitter.com domain only.
if (/<blockquote/.test(link)) { // Note that we don't attempt to parse the username as it can consist of
const srcDoc = createSrcDoc(link); // non-latin1 characters, and the username in the url can be set to anything
ret = { // without affecting the embed.
type: "document", const safeURL = sanitizeHTMLAttribute(
srcdoc: () => srcDoc, `https://twitter.com/x/status/${postId}`,
aspectRatio: { w: 480, h: 480 }, );
};
// assume regular tweet url const ret: EmbeddedLink = {
} else { type: "document",
ret = { srcdoc: (theme: string) =>
type: "document", createSrcDoc(
srcdoc: (theme: string) => `<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>`,
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 },
), sandbox: { allowSameOrigin },
aspectRatio: { w: 480, h: 480 }, };
};
}
embeddedLinkCache.set(originalLink, ret); embeddedLinkCache.set(originalLink, ret);
return ret; return ret;
} }
if (RE_GH_GIST.test(link)) { if (RE_GH_GIST.test(link)) {
let ret: EmbeddedLink; const [, user, gistId] = link.match(RE_GH_GIST)!;
// assume embed code const safeURL = sanitizeHTMLAttribute(
if (/<script>/.test(link)) { `https://gist.github.com/${user}/${gistId}`,
const srcDoc = createSrcDoc(link); );
ret = { const ret: EmbeddedLink = {
type: "document", type: "document",
srcdoc: () => srcDoc, srcdoc: () =>
aspectRatio: { w: 550, h: 720 }, createSrcDoc(`
}; <script src="${safeURL}.js"></script>
// assume regular url
} else {
ret = {
type: "document",
srcdoc: () =>
createSrcDoc(`
<script src="${link}.js"></script>
<style type="text/css"> <style type="text/css">
* { margin: 0px; } * { margin: 0px; }
table, .gist { height: 100%; } table, .gist { height: 100%; }
.gist .gist-file { height: calc(100vh - 2px); padding: 0px; display: grid; grid-template-rows: 1fr auto; } .gist .gist-file { height: calc(100vh - 2px); padding: 0px; display: grid; grid-template-rows: 1fr auto; }
</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 isEmbeddableOrFrameLabel = ( export const isEmbeddableOrFrameLabel = (
@@ -266,34 +301,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 => {
@@ -338,7 +378,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;
} }
} }
@@ -346,5 +386,5 @@ export const embeddableURLValidator = (
} }
} }
return validateHostname(url, ALLOWED_DOMAINS); return !!matchHostname(url, ALLOWED_DOMAINS);
}; };
-16
View File
@@ -11,22 +11,6 @@ The change should be grouped under one of the below section and must contain PR
Please add the latest change on the top under the correct section. Please add the latest change on the top under the correct section.
--> -->
## 0.16.1 (2023-09-21)
## Excalidraw Library
**_This section lists the updates made to the excalidraw library and will not affect the integration._**
### Fixes
- More eye-droper fixes [#7019](https://github.com/excalidraw/excalidraw/pull/7019)
### Refactor
- Move excalidraw-app outside src [#6987](https://github.com/excalidraw/excalidraw/pull/6987)
---
## 0.16.0 (2023-09-19) ## 0.16.0 (2023-09-19)
- Support creating containers, linear elements, text containers, labelled arrows and arrow bindings programatically [#6546](https://github.com/excalidraw/excalidraw/pull/6546) - Support creating containers, linear elements, text containers, labelled arrows and arrow bindings programatically [#6546](https://github.com/excalidraw/excalidraw/pull/6546)
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@excalidraw/excalidraw", "name": "@excalidraw/excalidraw",
"version": "0.16.1", "version": "0.16.4",
"main": "main.js", "main": "main.js",
"types": "types/packages/excalidraw/index.d.ts", "types": "types/packages/excalidraw/index.d.ts",
"files": [ "files": [
-7
View File
@@ -22,12 +22,5 @@ const polyfill = () => {
configurable: true, configurable: true,
}); });
} }
if (!Element.prototype.replaceChildren) {
Element.prototype.replaceChildren = function (...nodes) {
this.innerHTML = "";
this.append(...nodes);
};
}
}; };
export default polyfill; export default polyfill;