Skip to content

Conversation

@harlan-zw
Copy link
Collaborator

@harlan-zw harlan-zw commented Jan 20, 2026

πŸ”— Linked issue

Resolves #335

❓ Type of change

  • πŸ“– Documentation (updates to the documentation or readme)
  • 🐞 Bug fix (a non-breaking change that fixes an issue)
  • πŸ‘Œ Enhancement (improving an existing functionality)
  • ✨ New feature (a non-breaking change that adds functionality)
  • 🧹 Chore (updates to the build process or auxiliary tools and libraries)
  • ⚠️ Breaking change (fix or feature that would cause existing functionality to change)

πŸ“š Description

Third-party embed scripts (Twitter widgets, Instagram embeds) hurt performance and leak user data. Following the Cloudflare Zaraz approach, we now fetch embed data server-side and proxy all assets through your domain.

Added two headless components with scoped slots for full styling control:

<ScriptXEmbed tweet-id="1754336034228171055">
  <template #default="{ userName, text, likesFormatted, photos }">
    <!-- Style however you want -->
  </template>
</ScriptXEmbed>

<ScriptInstagramEmbed post-url="https://instagram.com/p/ABC123/">
  <template #default="{ html, shortcode }">
    <div v-html="html" />
  </template>
</ScriptInstagramEmbed>

What's included:

  • ScriptXEmbed - fetches from X syndication API, exposes tweet data via slots
  • ScriptInstagramEmbed - fetches embed HTML, rewrites asset URLs to proxy
  • Server routes for data fetching + image/asset proxying
  • 10-minute caching at server level
  • Docs, playground examples, and e2e tests
  • Showcase on docs home page

Privacy benefits:

  • Zero third-party JavaScript loaded
  • No cookies set by X/Instagram
  • User IPs not shared with third parties
  • All content served from your domain

harlan-zw and others added 2 commits January 20, 2026 16:36
Add privacy-first social media embed components that fetch data
server-side and proxy all assets through your domain. Zero
client-side API calls to third-party services.

- ScriptXEmbed: Headless component with scoped slots for X/Twitter
- ScriptInstagramEmbed: Renders proxied Instagram embed HTML
- Server routes for data fetching and image/asset proxying
- 10-minute server-side caching
- Full documentation and e2e tests

Resolves #335

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@vercel
Copy link

vercel bot commented Jan 20, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Review Updated (UTC)
scripts-docs Error Error Jan 20, 2026 10:27am
scripts-playground Ready Ready Preview, Comment Jan 20, 2026 10:27am

Comment on lines 41 to 53
const rewrittenHtml = html
// Rewrite scontent CDN images
.replace(
/https:\/\/scontent\.cdninstagram\.com([^"'\s)]+)/g,
'/api/_scripts/instagram-embed-image?url=' + encodeURIComponent('https://scontent.cdninstagram.com') + '$1',
)
// Rewrite static CDN CSS/assets
.replace(
/https:\/\/static\.cdninstagram\.com([^"'\s)]+)/g,
'/api/_scripts/instagram-embed-asset?url=' + encodeURIComponent('https://static.cdninstagram.com') + '$1',
)
// Remove Instagram's embed.js script (we don't need it)
.replace(/<script[^>]*src="[^"]*embed\.js"[^>]*><\/script>/gi, '')

Check failure

Code scanning / CodeQL

Incomplete multi-character sanitization High

This string may still contain
<script
, which may cause an HTML element injection vulnerability.

Copilot Autofix

AI about 18 hours ago

In general, this should be fixed by using a robust HTML sanitization strategy rather than a narrow regex that removes only a specific <script> tag. Since the problem is that <script content can remain after partial sanitization, the safest fix (without changing existing functional intent too much) is to ensure that all <script tags are removed from the returned HTML, not just the embed.js one. This aligns with the direction of the current code (explicitly removing one script) but makes it comprehensive instead of incomplete.

The single best fix here, without changing the overall behavior of proxying Instagram’s embed HTML, is to adjust the final .replace call so it strips all <script> elements rather than only those whose src contains embed.js. This keeps the path construction / proxying logic intact and simply broadens the sanitization. Concretely, in src/runtime/server/instagram-embed.ts around line 54, replace the regex /\<script[^>]*src="[^"]*embed\.js"[^>]*><\/script>/gi with a more general script-element removal regex, e.g. /\<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, which matches any <script> block, regardless of attributes or content. No new imports or helper methods are needed; we are just changing an existing regex in the string-processing chain.

Suggested changeset 1
src/runtime/server/instagram-embed.ts

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/src/runtime/server/instagram-embed.ts b/src/runtime/server/instagram-embed.ts
--- a/src/runtime/server/instagram-embed.ts
+++ b/src/runtime/server/instagram-embed.ts
@@ -50,8 +50,8 @@
       /https:\/\/static\.cdninstagram\.com([^"'\s)]+)/g,
       (match) => `/api/_scripts/instagram-embed-asset?url=${encodeURIComponent(match)}`,
     )
-    // Remove Instagram's embed.js script (we don't need it)
-    .replace(/<script[^>]*src="[^"]*embed\.js"[^>]*><\/script>/gi, '')
+    // Remove all script tags to avoid executing arbitrary scripts
+    .replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
 
   // Cache for 10 minutes
   setHeader(event, 'Content-Type', 'text/html')
EOF
@@ -50,8 +50,8 @@
/https:\/\/static\.cdninstagram\.com([^"'\s)]+)/g,
(match) => `/api/_scripts/instagram-embed-asset?url=${encodeURIComponent(match)}`,
)
// Remove Instagram's embed.js script (we don't need it)
.replace(/<script[^>]*src="[^"]*embed\.js"[^>]*><\/script>/gi, '')
// Remove all script tags to avoid executing arbitrary scripts
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')

// Cache for 10 minutes
setHeader(event, 'Content-Type', 'text/html')
Copilot is powered by AI and may make mistakes. Always verify output.
@pkg-pr-new
Copy link

pkg-pr-new bot commented Jan 20, 2026

Open in StackBlitz

npm i https://pkg.pr.new/nuxt/scripts/@nuxt/scripts@590

commit: 62baa3f

harlan-zw and others added 2 commits January 20, 2026 19:59
Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
Comment on lines +31 to +35
const shortcode = extractInstagramShortcode(props.postUrl)

const { data: html, status, error } = useAsyncData<string>(
`instagram-embed-${shortcode}`,
() => $fetch(`${props.apiEndpoint}?url=${encodeURIComponent(props.postUrl)}&captions=${props.captions}`),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The shortcode is computed once at component initialization but never updated if the postUrl prop changes. Additionally, if an invalid Instagram URL is passed (one that doesn't match the extraction regex), the shortcode becomes undefined, causing multiple invalid URLs to share the same cache key instagram-embed-undefined, which will incorrectly return cached results from one invalid URL when fetching another.

View Details
πŸ“ Patch Details
diff --git a/src/runtime/components/ScriptInstagramEmbed.vue b/src/runtime/components/ScriptInstagramEmbed.vue
index 0d25caa..7765864 100644
--- a/src/runtime/components/ScriptInstagramEmbed.vue
+++ b/src/runtime/components/ScriptInstagramEmbed.vue
@@ -1,5 +1,6 @@
 <script setup lang="ts">
 import type { HTMLAttributes } from 'vue'
+import { computed } from 'vue'
 import { useAsyncData } from 'nuxt/app'
 import { extractInstagramShortcode } from '../registry/instagram-embed'
 
@@ -28,10 +29,16 @@ const props = withDefaults(defineProps<{
   apiEndpoint: '/api/_scripts/instagram-embed',
 })
 
-const shortcode = extractInstagramShortcode(props.postUrl)
+const shortcode = computed(() => extractInstagramShortcode(props.postUrl))
+
+const cacheKey = computed(() => {
+  const code = shortcode.value
+  // Use shortcode if available, otherwise use a hash of the URL to avoid collisions
+  return `instagram-embed-${code || btoa(props.postUrl).substring(0, 16)}`
+})
 
 const { data: html, status, error } = useAsyncData<string>(
-  `instagram-embed-${shortcode}`,
+  cacheKey,
   () => $fetch(`${props.apiEndpoint}?url=${encodeURIComponent(props.postUrl)}&captions=${props.captions}`),
 )
 

Analysis

Non-reactive cache key and cache collision in ScriptInstagramEmbed

What fails: ScriptInstagramEmbed component has two related caching bugs:

  1. Non-reactive cache key: When the postUrl prop changes, the component fails to refetch data because the cache key is static. The shortcode variable is computed once at component initialization and never updates, causing useAsyncData to reuse the old cached result instead of fetching new data for a different Instagram post.

  2. Cache collision for invalid URLs: When an invalid Instagram URL is passed (one that doesn't match the extraction regex), extractInstagramShortcode() returns undefined. Multiple invalid URLs all receive the cache key instagram-embed-undefined, causing the first invalid URL's error response to be cached and incorrectly returned for all subsequent invalid URLs.

How to reproduce:

  1. Problem 1 - Non-reactive cache key:

    • Create a component instance with postUrl="https://www.instagram.com/p/ABC123/"
    • Change the prop to postUrl="https://www.instagram.com/p/XYZ789/"
    • Expected: Component refetches the new post
    • Actual: Component returns the cached result from the first post
  2. Problem 2 - Cache collision:

    • Create two component instances with different invalid URLs:
      • Instance 1: postUrl="https://example.com/invalid"
      • Instance 2: postUrl="https://another-invalid.com/url"
    • Both receive cache key instagram-embed-undefined
    • First instance's error response is cached
    • Second instance incorrectly returns the first instance's cached error

Root cause:

At line 31, shortcode was computed once from props.postUrl using a regular variable assignment:

const shortcode = extractInstagramShortcode(props.postUrl)

This is not a reactive computed property. According to Nuxt 4.x documentation, the cache key for useAsyncData can be reactive (using computed refs, refs, or getter functions). When a cache key doesn't change, useAsyncData doesn't refetch data. Additionally, when extractInstagramShortcode() returns undefined for invalid URLs, all invalid URLs collapse into a single cache entry.

Solution: Made shortcode a reactive computed property and created a reactive cacheKey computed property that includes a URL hash when the shortcode is undefined, ensuring:

  1. Cache key updates when postUrl prop changes, triggering refetch
  2. Each unique URL gets a unique cache key, eliminating collision for invalid URLs

Co-authored-by: vercel[bot] <35613825+vercel[bot]@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Support Social Media Embeds

2 participants