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
Note: You need to pay attention to cumulative layout shift when embedding social media posts. For the lexical implementation, you need to specify a min-height property for the wrapper <div> element. You should also add the .flex-row.justify-center classes to the wrapper div as well.
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>.
You need to add a nonce value to the <script> elements for each of the social media embed scripts - you have to do this because you are using the strict-dynamic directive for script-src.
X Posts
Content Security Policy Implications
- script-src needs to include https://platform.twitter.com/widgets.js in order for twitter embedding to work.
- frame-src needs to include https://platform.twitter.com for the iframe included in the twitter embedding to work.
How To Embed a Tweet
- Go to x.com.
- Click on the three dots button in the upper right hand corner of the tweet.
- Click on the Embed Post button.
- Select the Embedded Post display option:
- Click on the Copy Code button.
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
A melanistic leopard
— Science girl (@gunsnrosesgirl3) July 2, 2024
pic.twitter.com/tXTSGhqVbL
Image Post
A Cheetah and her Cheetos pic.twitter.com/QMzIVctaGh
— Nature is Amazing ☘️ (@AMAZlNGNATURE) June 15, 2024
Poll Post
Which party do you now plan to vote for on the 4th of July? #electionpoll
— Philip Pickering🏴🇬🇧🏴🚜 (@philip_pbm339) June 27, 2024
Normal Post
Educate yourself about things. Study hard what interests you the most. Don't worry about what others think of you, that's none of your business. Train your mind to think, doubt, and question. That's how you grow.
— Prof. Feynman (@ProfFeynman) July 2, 2024
TikTok Posts
Content Security Policy Implications
- script-src should include https://www.tiktok.com/embed.js.
- frame-src should include https://www.tiktok.com/embed.
- style-src should include https://lf16-tiktok-web.tiktokcdn-us.com/obj/tiktok-web-tx/tiktok/falcon/embed/embed_lib_v1.0.12.css.
- The Permissions-Policy header to allow for (self "https://www.tiktok.com") (at least).
How To Embed a TikTok Post
- Navigate to the TikTok post you want to embed on TikTok.com.
- Click on the Share button.
- Click on Copy Link.
For TikTok videos, it is probably best to ask the user for the URL of the TikTok video and use the oEmbed API to generate the appropriate HTML. TikTok posts should have a min-height of 750px to prevent cumulative layout shift.
Examples
@scout2015 Scramble up ur name & I’ll try to guess it😍❤️ #foryoupage #petsoftiktok #aesthetic
♬ original sound - tiff
Instagram Posts
Content Security Policy Implications
- frame-src should include https://www.instagram.com/.
How To Embed an Instagram Post
- Go to the instagram post that you want to embed.
- Click on the three dots in the upper right hand corner of the post .
- Click Embed.
- Click Copy embed code.
The min-height of the wrapper <div> should be 615px on desktop and 540 px on mobile.
Examples
Reddit Comments
Content Security Policy Implications
- script-src should allow for https://embed.reddit.com/widgets.js.
- frame-src should allow for https://embed.reddit.com/.
How To Embed a Reddit Comment
- Click on the Share button below a comment.
- Click on the Embed option in the pop up menu.
- 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;
}
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 };
}
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;
}
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.");