From c3e1c26194ccc5278c8e7d96a85ea1b720880cde Mon Sep 17 00:00:00 2001 From: Tony Date: Sun, 12 Jul 2026 18:07:09 +0800 Subject: [PATCH] fix(desktop): set referrerpolicy on youtube embed iframes (#5039) --- apps/desktop/changelog/next.md | 2 ++ .../src/lib/__tests__/parse-html.test.ts | 35 ++++++++++++++++++- .../layer/renderer/src/lib/parse-html.ts | 14 +++++++- packages/internal/utils/src/html.ts | 1 + packages/readability/src/sanitize.test.ts | 14 ++++++++ packages/readability/src/sanitize.ts | 14 +++++++- 6 files changed, 77 insertions(+), 3 deletions(-) diff --git a/apps/desktop/changelog/next.md b/apps/desktop/changelog/next.md index 8f5eac449..a3464797c 100644 --- a/apps/desktop/changelog/next.md +++ b/apps/desktop/changelog/next.md @@ -6,6 +6,8 @@ ## No longer broken +- Fixed YouTube embeds in entry content and readability view failing to play due to a missing referrer + ## Thanks Special thanks to volunteer contributors @ for their valuable contributions diff --git a/apps/desktop/layer/renderer/src/lib/__tests__/parse-html.test.ts b/apps/desktop/layer/renderer/src/lib/__tests__/parse-html.test.ts index 12930aeed..b66b2ff5c 100644 --- a/apps/desktop/layer/renderer/src/lib/__tests__/parse-html.test.ts +++ b/apps/desktop/layer/renderer/src/lib/__tests__/parse-html.test.ts @@ -1,6 +1,7 @@ +import { renderToString } from "react-dom/server" import { describe, expect, it } from "vitest" -import { extractCodeFromHtml } from "../parse-html" +import { extractCodeFromHtml, parseHtml } from "../parse-html" describe("extractCodeFromHtml", () => { it("should extract code from div elements", () => { @@ -388,3 +389,35 @@ describe("extractCodeFromHtml", () => { ) }) }) + +describe("parseHtml iframe", () => { + const renderIframe = (attrs: string) => + renderToString(parseHtml(``).toContent()).toLowerCase() + + const youtubeSrc = `src="https://www.youtube.com/embed/dQw4w9WgXcQ"` + + it("should rewrite referrer-hiding policies on youtube iframes", () => { + for (const policy of ["no-referrer", "same-origin"]) { + expect(renderIframe(`${youtubeSrc} referrerpolicy="${policy}"`)).toContain( + `referrerpolicy="strict-origin-when-cross-origin"`, + ) + } + }) + + it("should keep other declared referrerpolicy values on youtube iframes", () => { + expect(renderIframe(`${youtubeSrc} referrerpolicy="origin"`)).toContain( + `referrerpolicy="origin"`, + ) + }) + + it("should set referrerpolicy when youtube iframes declare none", () => { + // an absent attribute would inherit no-referrer from the document meta tag + expect(renderIframe(youtubeSrc)).toContain(`referrerpolicy="strict-origin-when-cross-origin"`) + }) + + it("should drop referrerpolicy on non-youtube iframes", () => { + expect( + renderIframe(`src="https://example.com/embed" referrerpolicy="no-referrer"`), + ).not.toContain("referrerpolicy") + }) +}) diff --git a/apps/desktop/layer/renderer/src/lib/parse-html.ts b/apps/desktop/layer/renderer/src/lib/parse-html.ts index 14ddf1ff5..4429b6c81 100644 --- a/apps/desktop/layer/renderer/src/lib/parse-html.ts +++ b/apps/desktop/layer/renderer/src/lib/parse-html.ts @@ -16,6 +16,8 @@ import { createHeadingRenderer } from "~/components/ui/markdown/renderers/Headin import { MarkdownInlineImage } from "~/components/ui/markdown/renderers/InlineImage" import { Media } from "~/components/ui/media/Media" +const youtubeEmbedRegex = /^https:\/\/(?:www\.)?(?:youtube\.com|youtube-nocookie\.com)\/embed\// + function markInlineImage(node?: Element) { for (const item of node?.children ?? []) { if (item.type === "element" && item.tagName === "img") { @@ -142,7 +144,7 @@ export const parseHtml = ( return createElement("input", props) }, iframe: ({ node, ...props }) => { - const { width, height, src, ...rest } = props + const { width, height, src, referrerPolicy, ...rest } = props // Apply security sandbox attributes and responsive styling return createElement("iframe", { @@ -154,6 +156,16 @@ export const parseHtml = ( sandbox: "allow-scripts allow-same-origin allow-popups allow-forms", allowFullScreen: true, loading: "lazy", + // Avoid YouTube Error 153 https://developers.google.com/youtube/terms/required-minimum-functionality#embedded-player-api-client-identity + ...(typeof src === "string" && + youtubeEmbedRegex.test(src) && { + referrerPolicy: + !referrerPolicy || + referrerPolicy === "no-referrer" || + referrerPolicy === "same-origin" + ? "strict-origin-when-cross-origin" + : referrerPolicy, + }), style: { aspectRatio: width && height ? `${width} / ${height}` : "16 / 9", ...rest.style, diff --git a/packages/internal/utils/src/html.ts b/packages/internal/utils/src/html.ts index 987990340..1c0dfe65b 100644 --- a/packages/internal/utils/src/html.ts +++ b/packages/internal/utils/src/html.ts @@ -188,6 +188,7 @@ export const parseHtml = (content: string, options?: ParseHtmlOptions) => { "allowfullscreen", "sandbox", "loading", + "referrerPolicy", "title", "id", "class", diff --git a/packages/readability/src/sanitize.test.ts b/packages/readability/src/sanitize.test.ts index afefe10fd..c14ffad49 100644 --- a/packages/readability/src/sanitize.test.ts +++ b/packages/readability/src/sanitize.test.ts @@ -22,4 +22,18 @@ describe("sanitizeHTMLString", () => { expect(clean).toContain('') expect(clean).toContain("link") }) + + it("keeps youtube embed iframes and strips any other iframe", () => { + const clean = sanitizeHTMLString(` + + + + + `) + + expect(clean).toContain("https://www.youtube.com/embed/dQw4w9WgXcQ") + expect(clean).toContain("https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ") + expect(clean).not.toContain("evil.example.com") + expect(clean).not.toContain("watch?v=") + }) }) diff --git a/packages/readability/src/sanitize.ts b/packages/readability/src/sanitize.ts index df1a36e93..c8c22933d 100644 --- a/packages/readability/src/sanitize.ts +++ b/packages/readability/src/sanitize.ts @@ -1,6 +1,8 @@ import DOMPurify from "dompurify" import { JSDOM } from "jsdom" +const youtubeEmbedRegex = /^https:\/\/(?:www\.)?(?:youtube\.com|youtube-nocookie\.com)\/embed\// + // For avoiding xss attack from readability, the raw document string should be sanitized. // The xss attack in electron may lead to more serious outcomes than browser environment. // It may allow remote execution of malicious scripts in the main process. @@ -11,5 +13,15 @@ export function sanitizeHTMLString(dirtyDocumentString: string) { throw new Error("DOMPurify is not supported in the current DOM environment.") } - return purify.sanitize(dirtyDocumentString) + // Keep YouTube embed iframes; any other iframe is still stripped. + purify.addHook("uponSanitizeElement", (node, data) => { + if ( + data.tagName === "iframe" && + !youtubeEmbedRegex.test((node as Element).getAttribute?.("src") ?? "") + ) { + node.parentNode?.removeChild(node) + } + }) + + return purify.sanitize(dirtyDocumentString, { ADD_TAGS: ["iframe"] }) }