diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx index 24a6881b6..067fc68c8 100644 --- a/src/app/components/url-preview/UrlPreviewCard.tsx +++ b/src/app/components/url-preview/UrlPreviewCard.tsx @@ -54,6 +54,7 @@ import { GIF_PREVIEW_MAX_WIDTH, gifPreviewBox, } from '../../utils/gifPreviewSize'; +import { sameProvider } from './embedProvider'; const linkStyles = { color: color.Success.Main }; @@ -2497,7 +2498,10 @@ export const UrlPreviewCard = as<'div', { url: string; ts: number }>( const resolveEmbed = (prev: IPreviewUrlResponse): MediaEmbed | null => { if (embed) return embed; const ogUrl = prev['og:url']; - return typeof ogUrl === 'string' && ogUrl !== url + // [Gitea #326] …but only from an og:url on the posted link's own + // provider: the page sets og:url, so any site could otherwise show up + // as a playable YouTube (etc.) card that links to itself. + return typeof ogUrl === 'string' && ogUrl !== url && sameProvider(url, ogUrl) ? parseMediaEmbed(ogUrl, window.location.hostname) : null; }; diff --git a/src/app/components/url-preview/embedProvider.test.ts b/src/app/components/url-preview/embedProvider.test.ts new file mode 100644 index 000000000..0f28b4013 --- /dev/null +++ b/src/app/components/url-preview/embedProvider.test.ts @@ -0,0 +1,22 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { sameProvider } from './embedProvider'; + +test("sameProvider: a page cannot borrow another provider's embed via og:url (#326)", () => { + const yt = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ'; + assert.equal(sameProvider('https://evil.example/free-gift', yt), false); + assert.equal(sameProvider('https://youtube.com.evil.example/x', yt), false); + assert.equal(sameProvider('https://youtu.be/abc', yt), true); + assert.equal(sameProvider('https://m.youtube.com/shorts/abc', yt), true); + assert.equal( + sameProvider('https://vm.tiktok.com/ZM123/', 'https://www.tiktok.com/@a/video/1'), + true, + ); + assert.equal(sameProvider('https://spotify.link/abc', 'https://open.spotify.com/track/1'), true); + assert.equal(sameProvider('https://on.soundcloud.com/x', 'https://soundcloud.com/a/b'), true); + assert.equal( + sameProvider('https://redd.it/abc', 'https://www.reddit.com/r/x/comments/abc/'), + true, + ); + assert.equal(sameProvider('not a url', yt), false); +}); diff --git a/src/app/components/url-preview/embedProvider.ts b/src/app/components/url-preview/embedProvider.ts new file mode 100644 index 000000000..48255f772 --- /dev/null +++ b/src/app/components/url-preview/embedProvider.ts @@ -0,0 +1,36 @@ +/** + * [Gitea #326] A page's og:url is chosen by the page itself, so a phishing + * link could serve `og:url = youtube.com/watch?v=…` and render as a playable + * YouTube card linking to the attacker. Only trust an og:url on the same + * provider as the link actually posted (including the provider's own + * short-link domains). + */ +const PROVIDER_ALIASES: Record = { + 'youtu.be': 'youtube.com', + 'youtube-nocookie.com': 'youtube.com', + 'spotify.link': 'spotify.com', + 'redd.it': 'reddit.com', + 'x.com': 'twitter.com', + 'twitch.tv': 'twitch.tv', + 'apple.co': 'apple.com', +}; + +/** + * Pure: the provider a host belongs to (last two labels, aliases folded). Hosts + * under two-part public suffixes (`*.co.uk`) share a value, which is harmless + * here: no embeddable provider lives under one. + */ +export const providerOf = (host: string): string => { + const parts = host.toLowerCase().replace(/\.$/, '').split('.'); + const base = parts.slice(-2).join('.'); + return PROVIDER_ALIASES[base] ?? base; +}; + +/** Pure: whether `ogUrl` is on the same provider as the posted `url`. */ +export function sameProvider(url: string, ogUrl: string): boolean { + try { + return providerOf(new URL(url).hostname) === providerOf(new URL(ogUrl).hostname); + } catch { + return false; + } +}