# Embed & Link Preview System Source files: - `packages/server/src/utils/embedClassifier.ts` — URL classification and provider detection - `packages/server/src/utils/embedResolver.ts` — URL extraction, embed resolution pipeline, DB persistence, batch fetching - `packages/server/src/utils/metadataFetcher.ts` — OpenGraph/HTML metadata scraping with Cheerio - `packages/server/src/utils/ssrf.ts` — SSRF protection (DNS resolution, private IP blocking) - `packages/web/src/components/chat/EmbedRenderer.tsx` — Client-side embed routing by type - `packages/web/src/components/chat/embeds/GenericEmbed.tsx` — Generic link preview card - `packages/web/src/components/chat/embeds/ImageEmbed.tsx` — Direct image embed with lightbox - `packages/web/src/components/chat/embeds/RichEmbed.tsx` — Rich iframe embed (Spotify) - `packages/web/src/components/chat/embeds/VideoEmbed.tsx` — Video embed (YouTube, Vimeo, direct) - `packages/shared/src/types.ts` — `Embed`, `EmbedType`, `EmbedProvider` type definitions --- ## Type Definitions ```typescript type EmbedType = 'generic' | 'video' | 'image' | 'audio' | 'rich'; type EmbedProvider = 'youtube' | 'vimeo' | 'spotify'; interface Embed { id: string; // Snowflake messageId: string | null; // FK -> messages.id (space messages) dmMessageId: string | null; // FK -> dm_messages.id (DMs) url: string; // Original URL from message content embedType: EmbedType; provider: EmbedProvider | null; title: string | null; description: string | null; image: string | null; // Thumbnail / og:image URL embedUrl: string | null; // iframe-safe embed URL width: number | null; // Image/thumbnail pixel width height: number | null; // Image/thumbnail pixel height color: string | null; // Reserved, always null currently createdAt: number; // Epoch ms } ``` DB schema: see `embeds` table in [database.md](database.md). Constraint: exactly one of `messageId`/`dmMessageId` is set. --- ## Pipeline Overview ``` Message created/edited -> extractUrls(content) // regex, dedupe, limit 5 -> for each URL: classifyUrl(url) // extension match or provider detection fetchUrlMetadata(url) // if needsMetadataFetch (SSRF-validated) probeRemoteImageDimensions // if image with unknown dimensions INSERT into embeds table -> broadcast embeds_resolved / dm_embeds_resolved via WebSocket ``` --- ## 1. URL Extraction `embedResolver.ts:extractUrls()` **Regex:** `https?:\/\/[^\s<>"{}|\\^`[\]]+` - Matches `http://` and `https://` URLs in message content - Deduplicates while preserving order (first occurrence wins) - **Limit:** 5 URLs per message (`MAX_EMBEDS_PER_MESSAGE = 5`) - Returns empty array for null/empty content --- ## 2. URL Classification `embedClassifier.ts:classifyUrl()` Classification runs in two phases: extension matching (no URL parsing needed), then provider matching (requires valid `URL` object). ### Phase 1 — Direct Media Extensions Regex-based, checked before URL parsing. These skip metadata fetch entirely. | Pattern | EmbedType | Provider | needsMetadataFetch | |---------|-----------|----------|--------------------| | `.jpg`, `.jpeg`, `.png`, `.gif`, `.webp`, `.avif` | `image` | null | false | | `.mp3`, `.ogg`, `.wav`, `.flac`, `.opus` | `audio` | null | false | | `.mp4`, `.webm`, `.mov` | `video` | null | false | Extension matching is case-insensitive and tolerates query strings (`(\?.*)?$`). ### Phase 2 — Provider Matching Requires successful `new URL()` parsing. Hostname normalized by stripping `www.` prefix. #### YouTube Hosts: `youtube.com`, `m.youtube.com`, `youtu.be` Supported URL patterns via `extractYouTubeId()`: | Pattern | Example | |---------|---------| | `/watch?v=ID` | `youtube.com/watch?v=dQw4w9WgXcQ` | | `/shorts/ID` | `youtube.com/shorts/dQw4w9WgXcQ` | | `/embed/ID` | `youtube.com/embed/dQw4w9WgXcQ` | | `/v/ID` (legacy) | `youtube.com/v/dQw4w9WgXcQ` | | Short link | `youtu.be/dQw4w9WgXcQ` | Video ID regex: `[A-Za-z0-9_-]+` Result: `embedType: 'video'`, `provider: 'youtube'`, `embedUrl: https://www.youtube-nocookie.com/embed/{videoId}`, `needsMetadataFetch: true` Privacy: Uses `youtube-nocookie.com` domain for embed iframes. #### Vimeo Host: `vimeo.com` Pattern: `vimeo.com/{numericId}` (regex: `/^\/(\d+)/`) Result: `embedType: 'video'`, `provider: 'vimeo'`, `embedUrl: https://player.vimeo.com/video/{id}`, `needsMetadataFetch: true` #### Spotify Host: `open.spotify.com` Pattern: `open.spotify.com/{type}/{id}` where type is `track`, `album`, or `playlist`, id is `[A-Za-z0-9]+` Result: `embedType: 'rich'`, `provider: 'spotify'`, `embedUrl: https://open.spotify.com/embed/{type}/{id}`, `needsMetadataFetch: true` #### Fallthrough Any URL that does not match a provider: `embedType: 'generic'`, `provider: null`, `embedUrl: null`, `needsMetadataFetch: true` Invalid URLs (fail `new URL()` parsing): same as fallthrough. --- ## 3. SSRF Protection `ssrf.ts:validateExternalUrl()` and `ssrf.ts:safeFetch()` All outbound fetches to user- or peer-supplied URLs go through `safeFetch()`, which validates the target with `validateExternalUrl()` (below) and re-validates the destination of every redirect hop. `validateExternalUrl()` throws on any violation. ### Validation Steps 1. **URL parsing** — `new URL(url)` must succeed 2. **Scheme check** — only `http:` and `https:` allowed 3. **DNS resolution** — `dns.promises.lookup(hostname)` resolves hostname to IP 4. **Private IP check** — `isPrivateIp(address)` rejects internal addresses ### Blocked IP Ranges `ssrf.ts:isPrivateIp()` | Range | Description | |-------|-------------| | `127.*` | Loopback | | `0.*`, `0.0.0.0` | Unspecified | | `10.*` | Private class A | | `192.168.*` | Private class C | | `172.16.0.0/12` | Private class B (172.16–172.31, checked via integer parse of second octet) | | `169.254.*` | Link-local | | `::1` | IPv6 loopback | | `fc*`, `fd*` | IPv6 unique local | | `fe80*` | IPv6 link-local | ### Redirect Handling Outbound fetches use `safeFetch()` (`ssrf.ts`), which follows redirects **manually** (`redirect: 'manual'`) and runs `validateExternalUrl()` against every hop's destination before following it, capped at 5 redirects. A redirect from a public host to a private/internal address (loopback, link-local, RFC1918) is therefore blocked, closing the redirect-based SSRF bypass. Callers — `fetchUrlMetadata`, `probeRemoteImageDimensions`, and `fetchSpaceInviteSnapshot` — all route through `safeFetch` rather than calling `validateExternalUrl` + `fetch` separately. **Residual:** `validateExternalUrl` resolves DNS and `fetch` resolves again, leaving a narrow DNS-rebinding TOCTOU window. Closing it fully requires pinning the resolved IP at connect time via a custom dispatcher; the redirect re-validation closes the practical, attacker-controlled bypass. --- ## 4. Metadata Fetching `metadataFetcher.ts:fetchUrlMetadata()` ### Flow 1. `safeFetch(url)` — SSRF-validated fetch (initial URL + every redirect hop); throws on block, caught to return `null` 2. Request sent with `User-Agent: BackspaceBot/1.0`, 5-second timeout via `AbortController` 3. **Content-Type detection** — if response is `image/*`, `video/*`, or `audio/*`, returns early with `contentType` field set (no HTML parsing) 4. **Size guard** — rejects responses with `Content-Length > 512KB` 5. **Stream-read with hard limit** — reads body via `ReadableStream`, stops at 512KB even for chunked (unknown-length) responses 6. **HTML parsing** via Cheerio ### Metadata Extraction Parsed from HTML using Cheerio with the following priority: | Field | Primary Source | Fallback | |-------|---------------|----------| | `title` | `og:title` | `