From 2c11bd3b8e0b32edf72732476fc843e20d9b32e7 Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sat, 17 Oct 2026 23:54:50 -0400 Subject: [PATCH] fix(previews): a page can no longer pose as a YouTube (etc.) player via its og:url (#326) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The embed fallback used the preview's og:url whatever site was posted. og:url is set by the page, so evil.example could render as a playable YouTube card (attacker's video) whose links go to evil.example. Only use an og:url on the same provider as the posted link, including the provider's own short-link domains (youtu.be, spotify.link, redd.it, …). Fixes #326 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017PCb59uY3UKqDqt7B6hzd5 --- .../components/url-preview/UrlPreviewCard.tsx | 6 +++- .../url-preview/embedProvider.test.ts | 22 ++++++++++++ .../components/url-preview/embedProvider.ts | 36 +++++++++++++++++++ 3 files changed, 63 insertions(+), 1 deletion(-) create mode 100644 src/app/components/url-preview/embedProvider.test.ts create mode 100644 src/app/components/url-preview/embedProvider.ts 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; + } +} -- 2.47.3