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"] })
}