Embedding Social Media Posts

What Is This

You can embed social media content in a web application by either:

  • Asking the users for the URL or post ID of the post and trying to embed the content by using an API provided by the platform
  • Allowing the users to insert the embed code HTML
  • Or by using a third-party service like Embed Social.

The first option above doesn't always work and the third option costs money, so I am going to try to allow users to add their own embed code, and I am going to try to do it safely by using an HTML parser on the client and server to make sure that the embed code that the user includes is not malicious. The

Styling Implications:

It is probably best to make the embedded content appear hidden - give the initial content an opacity equal to 0 - until the actual content is loaded probably in an <iframe>.

X Posts

Content Security Policy Implications

How To Embed a Tweet

  1. Go to x.com.
  2. Click on the three dots button in the upper right hand corner of the tweet.
  3. Click on the Embed Post button.
  4. Select the Embedded Post display option:
  5. Embedded Post Option
  6. Click on the Copy Code button.
Heights of Posts To Prevent Cumulative Layout Shift
Post Type: / Device: Mobile Tablet Desktop
Video Post 762px 762px 564px
Image Post 740px 740px 564px
Poll Post 428px 428px 420px
Normal Post 301px 301px 304px

Examples

Video Post

Image Post

Poll Post

Normal Post

TikTok Posts

Content Security Policy Implications

How To Embed a TikTok Post

  1. Navigate to the TikTok post you want to embed on TikTok.com.
  2. Click on the Share button.
  3. Click on Copy Link.

Examples

@scout2015

Scramble up ur name & I’ll try to guess it😍❤️ #foryoupage #petsoftiktok #aesthetic

♬ original sound - tiff

Instagram Posts

Content Security Policy Implications

How To Embed an Instagram Post

  1. Go to the instagram post that you want to embed.
  2. Click on the three dots in the upper right hand corner of the post .
  3. Click Embed.
  4. Click Copy embed code.

Examples

Reddit Comments

Content Security Policy Implications

How To Embed a Reddit Comment

  1. Click on the Share button below a comment.
  2. Click on the Embed option in the pop up menu.
  3. Click on the Copy Code button on the resulting page.

Styling

It is difficult to tell what the min-height of a Reddit comment should be on different devices. You probably need to try to get the size of the embed when calling exportDOM on the client, set a property in the lexical state, and interpolate from there.

Examples

Comment
byu/Thor1noak from discussion
infrance

Conclusion

Make the changes to the Content Security Policy and Permissions Policy described above. Instead of allowing the user to embed <script> tags when inserting embed codes from the various social media platforms, you should send the user generated embed code to the backend, purify the embed code using DOMPurify (remove all script tags and make sure that the elements in the embed code resemble the elements in the embed code seen above), and return the purified embed code. You can see the code for parsing the user-generated embed codes for the various platforms below.
You should load the embed widgets for all the platforms on initial page load, e.g:

<script nonce="<%=locals.jsNonce%>" defer src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
<script nonce="<%=locals.jsNonce%>" defer src="https://www.tiktok.com/embed.js"></script>
<script nonce="<%=locals.jsNonce%>" defer src="https://www.instagram.com/embed.js"></script>
<script nonce="<%=locals.jsNonce%>" defer charset="UTF-8">
  (() => {
    const rs = !function(){}; // replace with contents of reddit embed script
    const redditFunction = () => rs;
    redditFunction();
    document.addEventListener('EMBED_REDDIT',() => {
      redditFunction();
    })
  })();
</script>

and then you should try to process embed codes whenever new content is loaded with custom events or other JavaScript (like the EMBED_REDDIT custom event shown above or the script snippet shown below).

/* Social Media Events */
// On New Content Loaded
const twitterEmbeds = Array.from(document.querySelectorAll<HTMLElement>('blockquote.twitter-tweet'));
if (twitterEmbeds.length&&(window as any).twttr&&(window as any).twttr.widgets&&(window as any).twttr.widgets.load) (window as any).twttr.widgets.load();
// On New Content Loaded
const tiktokEmbeds = Array.from(document.querySelectorAll<HTMLElement>('blockquote.tiktok-embed'));
if (tiktokEmbeds.length&&(window as any).tiktokEmbed&&(window as any).tiktokEmbed.lib&&(window as any).tiktokEmbed.lib.render) (window as any).tiktokEmbed.lib.render(tiktokEmbeds);
// On New Content Loaded
const instagramEmbeds = Array.from(document.querySelectorAll<HTMLElement>('blockquote.instagram-media'));
if (instagramEmbeds.length&&(window as any).instgrm&&(window as any).instgrm.Embeds&&(window as any).instgrm.Embeds.process) (window as any).instgrm.Embeds.process();
// On New Content Loaded
const redditEmbeds = Array.from(document.querySelectorAll<HTMLElement>('blockquote.reddit-embed-bq'));
if (redditEmbeds.length) window.dispatchEvent(new CustomEvent("EMBED_REDDIT"));

Parsing Embed Codes

Below are some scripts that you can use to ensure that the embed code that the user pastes into a <textarea> or some other editor is safe to use in the website (protecting against XSS).

TikTok

export function validateTikTokEmbed(s:string) {
  const ALLOWED_TAGS = [
    "BLOCKQUOTE",
    "SECTION",
    "A"
  ];
  const ALLOWED_ATTR = [
    "class",
    "cite",
    "data-video-id",
    "style",
    "target",
    "title",
    "href"
  ];
  const hrefRegex = /https:\/\/(www\.|)tiktok\.com\//;

  // Check the HTML first manually
  const dom = new JSDOM(s);
  dom.window.document.querySelectorAll('script')
  .forEach((s) => s.remove());
  const bodyChildren = Array.from(dom.window.document.body.children);
  if (bodyChildren.length>1) {
    throw new Error("TikTok embed should be wrapped in <blockquote> child.");
  }
  const blockquote = bodyChildren[0];
  if (
    blockquote.nodeName!=="BLOCKQUOTE" || 
    blockquote.className!=="tiktok-embed" ||
    !!!/https:\/\/(www\.|)tiktok.com\/.*\/video\/\d+/.test(String(blockquote.getAttribute('cite'))) || 
    !!!Number.isInteger(Number(blockquote.getAttribute('data-video-id'))) 
  ) {
    throw new Error("TikTok embed should be wrapped in <blockquote> child. This blockquote child should have a classname equal to \"tiktok-embed\". It should have a cite attribute that matches /https:\/\/(www.|)tiktok.com\/.*\/video\/\d+/. It should have an integer data-video-id attribute.");
  }
  const blockquoteChildren = Array.from(blockquote.children);
  if (blockquoteChildren.length>1) {
    throw new Error("TikTok embed HTML should be blockquote > 1 section > all other elements. ");
  }
  const section = blockquoteChildren[0];
  if (section.nodeName!=='SECTION'||section.className!=='') {
    throw new Error("TikTok embed HTML should be blockquote > 1 section > all other elements. Section Element should not have a class name.");
  }
  const sectionDescendants = Array.from(section.querySelectorAll('*'));
  for (let child of sectionDescendants) {
    if (child.tagName!=='A'&&child.tagName!=='P') {
      throw new Error("The descendants of the section element in a tiktok embed should either by and anchor tag or a paragraph.");
    }
    if (child.tagName==="A") {
      if (child.getAttribute('target')!=='_blank') throw new Error("The 'target' attribute of all anchor tags in the TikTok embed should be '_blank'.");
      if (!!!hrefRegex.test(String(child.getAttribute('href')))) {
        throw new Error("All links in the TikTok embed should have an href attribute that matches: /https:\/\/(www.|)tiktok.com\//.");
      }
    }
  }
  // Now Sanitive the HTML
  const newHTML = blockquote.outerHTML;
  const DOMPurify = createDOMPurify(dom.window);
  const finalHTML = DOMPurify.sanitize(newHTML,{
    ALLOWED_TAGS,
    ALLOWED_ATTR,
    ALLOW_DATA_ATTR: true
  });
  return finalHTML;
}

Reddit

export function validateRedditEmbed(s:string) {
  const ALLOWED_TAGS = [
    "BLOCKQUOTE",
    "A",
    "BR"
  ];
  const ALLOWED_ATTR =  [
    "class",
    "data-embed-height",
    "href"
  ];
  const hrefRegex = /https:\/\/(www\.|)reddit\.com\/.*/

  const dom = new JSDOM(s);
  dom.window.document.querySelectorAll('script')
  .forEach((s) => s.remove());
  const bodyChildren = dom.window.document.body.children;
  if (bodyChildren.length>1) { 
    throw new Error("Reddit embed should be wrapped in <blockquote> child.");
  }
  const blockquote = bodyChildren[0];
  if ( 
    blockquote.nodeName!=="BLOCKQUOTE" || 
    blockquote.className!=="reddit-embed-bq" ||
    !!!Number.isInteger(Number(blockquote.getAttribute('data-embed-height')))
  ) {
    throw new Error("Reddit embed should be wrapped in <blockquote> child. This blockquote child should have a classname equal to \"reddit-embed-bq\". It should have a data-embed-height attribute that is an integer.");
  }
  // This should be the max depth
  const blockquoteChildren = Array.from(blockquote.children);
  for (let child of blockquoteChildren) {
    if ((child.tagName!=='A'&&child.tagName!=='BR')||(Array.from(child.children).length>0)) {
      throw new Error("The children of the blockquote in the Reddit embed should be either anchor tags or line breaks and they should not have any children themselves.")
    }
    if (child.tagName==="A") {
      if (!!!hrefRegex.test(String(child.getAttribute('href')))) {
        throw new Error("Invalid href attribute for anchor tag inside Reddit embed.");
      }
    }
  }
  const newHTML = blockquote.outerHTML;
  const minHeight = Number(String(blockquote.getAttribute('data-embed-height')));
  const DOMPurify = createDOMPurify(dom.window);
  const finalHTML = DOMPurify.sanitize(newHTML,{
    ALLOWED_TAGS,
    ALLOWED_ATTR,
    ALLOW_DATA_ATTR: true
  });
  return {html: finalHTML, minHeight };
}

Twitter

export function validateTwitterEmbed(s:string) {
  const ALLOWED_TAGS = [
    "BLOCKQUOTE",
    "P",
    "BR",
    "A"
  ];
  const ALLOWED_ATTR =  [
      "class",
      "lang",
      "dir",
      "href"
  ];
  const dom = new JSDOM(s);
  dom.window.document.querySelectorAll('script')
  .forEach((s) => s.remove());
  const bodyChildren = dom.window.document.body.children;
  if (bodyChildren.length>1) {
    throw new Error("Twitter embed should be wrapped in <blockquote> child.");
  }
  const blockquote = bodyChildren[0];
  if (
    blockquote.nodeName!=="BLOCKQUOTE" || 
    blockquote.className!=="twitter-tweet"
  ) {
    throw new Error("Twitter embed should be wrapped in <blockquote> child. This blockquote child should have a classname equal to \"twitter-tweet\".");
  }
  const blockquoteChildren = Array.from(blockquote.children);
  if (
    blockquoteChildren.length!==2 ||
    blockquoteChildren[0].tagName!=='P' ||
    blockquoteChildren[1].tagName!=='A' ||
    Array.from(blockquoteChildren[1].children).length>0 ||
    !!!/https:\/\/(www\.|)(twitter|x)\.com\/.*\/status\/\d+\?.*/.test(String(blockquoteChildren[1].getAttribute('href'))) ||
    (blockquoteChildren[0].getAttribute('dir')!=='ltr'&&blockquoteChildren[0].getAttribute('dir')!=='rtl') ||
    !!!blockquoteChildren[0].getAttribute('lang')
  ) {
    throw new Error("Twitter Embed bloclquote element should have two children. The first should be a paragraph and the second should be an anchor tag. The anchor tag should have no children and it should have an href attribute that matches /https:\/\/(www.|)(twitter|x).com\/.*\/status\/\d+\?.*/. The first paragraph should have appropriate lang and dir attributes.")
  }
  Array.from(blockquoteChildren[0].children)
  .forEach((child) => {
    if (child.tagName!=='A'&&child.tagName!=='BR') {
      throw new Error("The children of blockquote > p for the twitter implementation should only be anchor tags and newlines.")
    }
    if (Array.from(child.children).length>1) {
      throw new Error("The children of blockquote > p for the twitter implementation should not have children themselves.");
    }
    if (child.tagName==='A') {
      if (
        !!!/https:\/\/t\.co\/.*/.test(String(child.getAttribute('href'))) && 
        !!!/https:\/\/(twitter|x)\.com\/.*/.test(String(child.getAttribute('href')))
      ) {
        throw new Error("Twitter embed: Invalid twitter anchor tag href.");
      }
    }
  })
  const newHTML = blockquote.outerHTML;
  const DOMPurify = createDOMPurify(dom.window);
  const finalHTML = DOMPurify.sanitize(newHTML,{
    ALLOWED_TAGS,
    ALLOWED_ATTR,
    ALLOW_DATA_ATTR: false
  });
  return finalHTML;
}

Instagram

export function validateInstagramEmbed(s:string) {
  const ALLOWED_TAGS = [
    "BLOCKQUOTE",
    "DIV",
    "svg",
    "g",
    "path",
    "A",
    "P"
  ];
  const ALLOWED_ATTR =  [
      "class",
      "lang",
      "dir",
      "href"
  ];
  const gAttributesSet = new Set([
    "stroke",
    "stroke-width",
    "fill",
    "fill-rule",
    "transform"
  ]);
  const dom = new JSDOM(s);
  dom.window.document.querySelectorAll('script')
  .forEach((s) => s.remove());
  const bodyChildren = dom.window.document.body.children;
  if (bodyChildren.length>1) {
    throw new Error("Instagram embed should be wrapped in <blockquote> child.");
  }
  const blockquote = bodyChildren[0];
  if (
    blockquote.nodeName!=="BLOCKQUOTE" || 
    blockquote.className!=="instagram-media" || 
    !!!blockquote.hasAttribute("data-instgrm-captioned") ||
    !!!/https:\/\/www.instagram.com\/.*/.test(String(blockquote.getAttribute("data-instgrm-permalink"))) ||
    !!!blockquote.hasAttribute("data-instgrm-version")
  ) {
    throw new Error("Instagram embed should be wrapped in <blockquote> child. This blockquote child should have a classname equal to \"instagram-media\". Instagram embed should have the data-instgrm captioned and data-instgrm-version attributes, and the data-instagram-permalink attribute should match /https:\/\/www.instagram.com\/.*/.");
  }
  const blockquoteChildren = Array.from(blockquote.children);
  if (blockquoteChildren.length!==1||blockquoteChildren[0].nodeName!=='DIV'||!!!blockquoteChildren[0].hasAttribute('style')) {
    throw new Error("Instagram embed blcokquote wrapper should only have one <div> child with the style attribute.");
  }
  const divWrapper = blockquoteChildren[0];
  const divChildren = Array.from(divWrapper.children);
  if (divChildren[0].nodeName!=='A'||divChildren[1].nodeName!=='P') {

  }
  const anchorWrapper = divChildren[0];
  const anchorWrapperDescendents = Array.from(anchorWrapper.querySelectorAll('*'));
  anchorWrapperDescendents.forEach((child) => {
    switch (child.tagName) {
      case "DIV":
        // Children can be div | svg
        // can have style attribute
        const attribs = child.attributes;
        const attributeArray = [];
        for (let i = 0; i < attribs.length; i++) {
          const tempAt = attribs.item(i);
          if (tempAt) attributeArray.push(tempAt.name);
        }
        if (attributeArray.length>1||(attributeArray.length===1&&attributeArray[0]!=='style')) {
          throw new Error("DIV element in instagram embed can only have style attribute.")
        }
        break;
      case "svg":
          if (
            !!!child.hasAttribute("width") ||
            !!!child.hasAttribute("height") ||
            !!!child.hasAttribute("viewBox") ||
            !!!child.hasAttribute("version") ||
            !!!child.hasAttribute("xmlns") ||
            !!!child.hasAttribute("xmlns:xlink") ||
            Array.from(child.children).length!==1 ||
            Array.from(child.children)[0].tagName!=='g'
          ) {
            throw new Error("svg attribute in instagram embed does not have valid attributes or the correct number of children or its child is not a g element.")
          }
        break;  
      case "g":
        const HasUnrecognizedDescendants = Boolean(Array.from(child.querySelectorAll('*')).filter((el) => el.tagName!=='g'&&el.tagName!=='path').length>1);
        const HasMoreThanOneChild = Array.from(child.children).length>1;
        if (HasMoreThanOneChild||HasUnrecognizedDescendants) {
          throw new Error("g tag in Instagram embed has more than one child or has a descendant with a tagName that does not match the pattren.")
        }
        const attrs = child.attributes;
        const attributes = [];
        for (let i = 0; i < attrs.length; i++) {
          const tempAt = attrs.item(i);
          if (tempAt) attributes.push(tempAt.name);
        }
        for (let attr of attributes) {
          if (!!!gAttributesSet.has(attr)) {
            throw new Error("Unrecognized g attribute for instagram embed.");
          }
        }

        break;
      case "path":
        if (Array.from(child.children).length>0||!!!child.hasAttribute('d')) {
          throw new Error('path element for instagram embed should have no children and have a d attribute.');
        }
        break;
      default:
        throw new Error("Invalid Instagram embed structure. Unrecognized child tagName in anchor wrapper.")
    }
  })
  const endP = divChildren[1];
  const endPChildren = Array.from(endP.children);
  if (endPChildren.length!==1||endPChildren[0].nodeName!=='A' ||
    !!!/https:\/\/www.instagram.com\/.*/.test(String(endPChildren[0].getAttribute('href'))) || 
    !!!endPChildren[0].hasAttribute('style') || 
    endPChildren[0].getAttribute('target')!=='_blank' ||
    Array.from(endPChildren[0].children).length>0
  ) {
    throw new Error("The ending paragraph of the div wrapper should have one anchor tag child that has appropraiet attributes.");