CVE-2024-32472
Description
excalidraw is an open source virtual hand-drawn style whiteboard. A stored XSS vulnerability in Excalidraw's web embeddable component. This allows arbitrary JavaScript to be run in the context of the domain where the editor is hosted. There were two vectors. One rendering untrusted string as iframe's srcdoc without properly sanitizing against HTML injection. Second by improperly sanitizing against attribute HTML injection. This in conjunction with allowing allow-same-origin sandbox flag (necessary for several embeds) resulted in the XSS. This vulnerability is fixed in 0.17.6 and 0.16.4.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
@excalidraw/excalidrawnpm | >= 0.16.0, < 0.16.4 | 0.16.4 |
@excalidraw/excalidrawnpm | >= 0.17.0, < 0.17.6 | 0.17.6 |
Patches
26be752e1b6d7fix: allow same origin for all necessary domains (#7889)
1 file changed · +73 −26
src/element/embeddable.ts+73 −26 modified@@ -18,7 +18,7 @@ type EmbeddedLink = | ({ aspectRatio: { w: number; h: number }; warning?: string; - sandbox?: { allowSameOrigin?: boolean }; + sandbox: { allowSameOrigin?: boolean }; } & ( | { type: "video" | "generic"; link: string } | { type: "document"; srcdoc: (theme: Theme) => string } @@ -63,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>`; }; @@ -78,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); @@ -100,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); @@ -115,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); @@ -126,16 +152,26 @@ 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)) { @@ -155,7 +191,7 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => { `<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: true }, + sandbox: { allowSameOrigin }, }; embeddedLinkCache.set(originalLink, ret); return ret; @@ -178,13 +214,19 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => { </style> `), 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 = ( @@ -259,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 => { @@ -331,13 +378,13 @@ export const embeddableURLValidator = ( if (url.match(domain)) { return true; } - } else if (validateHostname(url, domain)) { + } else if (matchHostname(url, domain)) { return true; } } return false; } } - return validateHostname(url, ALLOWED_DOMAINS); + return !!matchHostname(url, ALLOWED_DOMAINS); };
988f81911ca5fix: allow same origin for all necessary domains (#7889)
1 file changed · +78 −27
src/element/embeddable.ts+78 −27 modified@@ -19,7 +19,7 @@ type EmbeddedLink = | ({ aspectRatio: { w: number; h: number }; warning?: string; - sandbox?: { allowSameOrigin?: boolean }; + sandbox: { allowSameOrigin?: boolean }; } & ( | { type: "video" | "generic"; link: string } | { type: "document"; srcdoc: (theme: Theme) => string } @@ -67,7 +67,18 @@ const ALLOWED_DOMAINS = new Set([ "stackblitz.com", "val.town", "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) => { @@ -85,6 +96,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); @@ -107,8 +122,18 @@ 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); @@ -122,8 +147,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); @@ -133,16 +163,26 @@ 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)) { @@ -162,7 +202,7 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => { `<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: true }, + sandbox: { allowSameOrigin }, }; embeddedLinkCache.set(originalLink, ret); return ret; @@ -185,13 +225,19 @@ export const getEmbedLink = (link: string | null | undefined): EmbeddedLink => { </style> `), 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 isEmbeddableOrLabel = ( @@ -266,34 +312,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 => { @@ -342,13 +393,13 @@ export const embeddableURLValidator = ( if (url.match(domain)) { return true; } - } else if (validateHostname(url, domain)) { + } else if (matchHostname(url, domain)) { return true; } } return false; } } - return validateHostname(url, ALLOWED_DOMAINS); + return !!matchHostname(url, ALLOWED_DOMAINS); };
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-m64q-4jqh-f72fghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2024-32472ghsaADVISORY
- github.com/excalidraw/excalidraw/commit/6be752e1b6d776ccfbd3bb9eea17463cb264121dnvdWEB
- github.com/excalidraw/excalidraw/commit/988f81911ca58e3ca2583e0dd44a954dd00e09d0nvdWEB
- github.com/excalidraw/excalidraw/security/advisories/GHSA-m64q-4jqh-f72fnvdWEB
News mentions
0No linked articles in our index yet.