fix(desktop): set referrerpolicy on youtube embed iframes (#5039)

This commit is contained in:
Tony 2026-07-12 18:07:09 +08:00 committed by GitHub
parent de935145da
commit c3e1c26194
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 77 additions and 3 deletions

View File

@ -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

View File

@ -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(`<iframe ${attrs}></iframe>`).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")
})
})

View File

@ -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,

View File

@ -188,6 +188,7 @@ export const parseHtml = (content: string, options?: ParseHtmlOptions) => {
"allowfullscreen",
"sandbox",
"loading",
"referrerPolicy",
"title",
"id",
"class",

View File

@ -22,4 +22,18 @@ describe("sanitizeHTMLString", () => {
expect(clean).toContain('<img src="/image.png">')
expect(clean).toContain("<a>link</a>")
})
it("keeps youtube embed iframes and strips any other iframe", () => {
const clean = sanitizeHTMLString(`
<iframe src="https://www.youtube.com/embed/dQw4w9WgXcQ" width="640" height="360"></iframe>
<iframe src="https://www.youtube-nocookie.com/embed/dQw4w9WgXcQ"></iframe>
<iframe src="https://evil.example.com/embed"></iframe>
<iframe src="https://www.youtube.com/watch?v=dQw4w9WgXcQ"></iframe>
`)
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=")
})
})

View File

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