VYPR
Medium severity6.1NVD Advisory· Published Apr 17, 2024· Updated Apr 15, 2026

CVE-2024-32472

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.

PackageAffected versionsPatched versions
@excalidraw/excalidrawnpm
>= 0.16.0, < 0.16.40.16.4
@excalidraw/excalidrawnpm
>= 0.17.0, < 0.17.60.17.6

Patches

2
6be752e1b6d7

fix: allow same origin for all necessary domains (#7889)

https://github.com/excalidraw/excalidrawDavid LuzarApr 13, 2024via ghsa
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);
     };
    
988f81911ca5

fix: allow same origin for all necessary domains (#7889)

https://github.com/excalidraw/excalidrawDavid LuzarApr 13, 2024via ghsa
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

News mentions

0

No linked articles in our index yet.