CVE-2026-28871
Overview
Background
- MediaDocument
- A synthetic WebCore document that wraps a media resource URL in a media element and never parses the payload as HTML, so any markup/script in the resource is inert.
- MediaPlayer::supportsType
- A runtime query that asks the available media engines whether they can play a given content type, returning IsNotSupported / MayBeSupported / IsSupported and depending on installed codecs and platform state.
- MIMETypeRegistry::isSupportedMediaMIMEType
- A static, table-driven predicate that reports whether a MIME type is a known/supported media type, used across WebKit to classify resources independently of live engine state.
- AVStreamDataParserMIMETypeCache
- A cache of MIME types the platform AVStreamDataParser (Media Source Extensions backend) reports it can parse, a dynamic and OS-version-dependent list.
- Cross-site scripting (XSS)
- Execution of attacker-controlled script in the security context (origin) of a target document, here achieved by getting HTML+script rendered as active content instead of an inert media document.
Root Cause Analysis
DOMImplementation::createDocument() decides what kind of Document to instantiate for a given content type. For media types it previously called the live media-engine query MediaPlayer::supportsType(parameters) and, if the result was anything other than IsNotSupported, created a MediaDocument (a synthetic document that merely wraps the URL in a <video> element and never parses the payload as markup).
The bug is that this check was NOT the same check the rest of WebKit uses to classify a resource as ‘media’: other code paths rely on MIMETypeRegistry::isSupportedMediaMIMEType(). Because the two predicates could disagree for a given MIME type, a resource could be classified one way for loading/handling and the opposite way for document creation. When a type that the registry considered ‘media’ (so the response was allowed through as media rather than sniffed/handled as HTML) fell through createDocument()’s divergent MediaPlayer::supportsType() test as IsNotSupported, WebKit did not build a safe MediaDocument and instead fell through to building a normal document that parses and executes the payload’s HTML and scripts. The bundled LayoutTest demonstrates the effect precisely: an iframe is pointed at ‘data:video/mp2t,<h1>Error</h1><script>parent.postMessage(“fail”,"*")</script>’, and the test only passes if that script does NOT run (no postMessage) while the document still reaches readyState ‘complete’ — i.e. the payload must be handled as an inert MediaDocument, not executed.
The fix restores a single, consistent classification: createDocument() now calls MIMETypeRegistry::isSupportedMediaMIMEType(contentType), the same predicate used elsewhere, so anything treated as media is uniformly turned into a MediaDocument.
The patch additionally hardens isSupportedMediaMIMEType() to reject any type whose lowercased form does not start with ‘video/’, ‘audio/’, or ‘application/’ before consulting the supported set, eliminating non-media prefixes from ever being accepted as media. Finally, MediaPlayerPrivateMediaSourceAVFObjC::getSupportedTypes() is changed to clear the set instead of returning AVStreamDataParserMIMETypeCache’s dynamically reported types; that dynamic, OS-dependent list was a source of divergence between MediaPlayer::supportsType() and the static registry, so removing it narrows the set of types whose media-vs-HTML disposition could differ across code paths. The violated invariant is ‘a resource classified as a supported media type is always rendered as an inert MediaDocument, never parsed as active content’; the fix re-establishes it by using one authoritative predicate and by shrinking the fuzzy, engine-dependent portion of the supported-type universe.
Attack Path
- Choose a divergent media MIME type Identify a MIME type (e.g. video/mp2t as used by the test) that MIMETypeRegistry treats as a supported media type — so the response is admitted as media and not sniffed as HTML — but for which the pre-patch MediaPlayer::supportsType() engine query returns IsNotSupported.
- Serve HTML+script under that type Deliver a payload whose bytes are HTML containing an inline <script>, but label it with the chosen media MIME type. The LayoutTest uses a data: URL (‘data:video/mp2t,<h1>Error</h1><script>…</script>’); a server response with Content-Type: video/mp2t works equivalently.
- Load it as a document Cause the payload to be loaded as a document — e.g. in an iframe, a navigation, or a subframe the attacker controls the response for — so DOMImplementation::createDocument() runs for that content type.
- Divergent check misfires createDocument()’s MediaPlayer::supportsType() returns IsNotSupported for the type, so it skips MediaDocument::create() and falls through to building a document that parses the payload as markup.
- Script executes The inline script runs in the resulting document’s context; the test detects this via a postMessage to the parent. In a real attack this yields script execution under whatever origin the response was served from, enabling cross-site scripting (e.g. against a site that lets users host files it believes will be treated as inert media).
Impact Assessment
Files Changed
LayoutTests/media/iframe-load-html-as-m2ts-expected.txtLayoutTests/media/iframe-load-html-as-m2ts.htmlSource/WebCore/dom/DOMImplementation.cppSource/WebCore/platform/MIMETypeRegistry.cppSource/WebCore/platform/graphics/avfoundation/objc/MediaPlayerPrivateMediaSourceAVFObjC.mm
Audit Directions
- Other createDocument-style media decisionsGrep for remaining callers of MediaPlayer::supportsType and MediaEngineSupportParameters in document/loader factory paths (DOMImplementation.cpp, DocumentLoader, FrameLoader, PluginDocument) and check whether the media/plugin-vs-HTML decision there uses the same authoritative predicate now used in createDocument.
- Predicate mismatches across the MIME registryAudit every use of MIMETypeRegistry::isSupportedMediaMIMEType, isSupportedImageMIMEType, and the plugin/PDF equivalents for places where one code path decides ’this resource is X’ and another decides how to render it, looking for asymmetry that lets HTML be admitted under a non-HTML type.
- Dynamically populated supported-type setsSearch for getSupportedTypes implementations that copy from OS/engine caches (AVStreamDataParserMIMETypeCache, AVAssetMIMETypeCache, and similar ‘singleton().supportedTypes()’ patterns) and verify they cannot inject types with non-media prefixes or types that other classifiers do not also recognize.
- Prefix/normalization assumptions on MIME typesLook for MIME-type comparisons that do not normalize case or do not constrain the top-level type (missing convertToASCIILowercase() or missing startsWith(“video/”/“audio/”) checks) in supportedX MIMEType functions, since the fix shows these were exploitable gaps.
Patch
diff --git a/Source/WebCore/dom/DOMImplementation.cpp b/Source/WebCore/dom/DOMImplementation.cpp
index 34f9d0c21032..a4ee9e4ea956 100644
--- a/Source/WebCore/dom/DOMImplementation.cpp
+++ b/Source/WebCore/dom/DOMImplementation.cpp
@@ -177,10 +177,7 @@ Ref<Document> DOMImplementation::createDocument(const String& contentType, Local
return ImageDocument::create(*frame, url);
#if ENABLE(VIDEO)
- MediaEngineSupportParameters parameters;
- parameters.type = ContentType { contentType };
- parameters.url = url;
- if (MediaPlayer::supportsType(parameters) != MediaPlayer::SupportsType::IsNotSupported)
+ if (MIMETypeRegistry::isSupportedMediaMIMEType(contentType))
return MediaDocument::create(frame, settings, url);
#endif
diff --git a/Source/WebCore/platform/MIMETypeRegistry.cpp b/Source/WebCore/platform/MIMETypeRegistry.cpp
index 25830af558dd..719f0ea7eaea 100644
--- a/Source/WebCore/platform/MIMETypeRegistry.cpp
+++ b/Source/WebCore/platform/MIMETypeRegistry.cpp
@@ -586,6 +586,9 @@ bool MIMETypeRegistry::isSupportedMediaMIMEType(const String& mimeType)
{
if (mimeType.isEmpty())
return false;
+ auto lowercaseType = mimeType.convertToASCIILowercase();
+ if (!lowercaseType.startsWith("video/"_s) && !lowercaseType.startsWith("audio/"_s) && !lowercaseType.startsWith("application/"_s))
+ return false;
return supportedMediaMIMETypes().contains(mimeType);
}
diff --git a/Source/WebCore/platform/graphics/avfoundation/objc/MediaPlayerPrivateMediaSourceAVFObjC.mm b/Source/WebCore/platform/graphics/avfoundation/objc/MediaPlayerPrivateMediaSourceAVFObjC.mm
index 46e2ab5fdb66..27fabf05621b 100644
--- a/Source/WebCore/platform/graphics/avfoundation/objc/MediaPlayerPrivateMediaSourceAVFObjC.mm
+++ b/Source/WebCore/platform/graphics/avfoundation/objc/MediaPlayerPrivateMediaSourceAVFObjC.mm
@@ -184,7 +184,7 @@ bool MediaPlayerPrivateMediaSourceAVFObjC::isAvailable()
void MediaPlayerPrivateMediaSourceAVFObjC::getSupportedTypes(HashSet<String>& types)
{
- types = AVStreamDataParserMIMETypeCache::singleton().supportedTypes();
+ types.clear();
}
MediaPlayer::SupportsType MediaPlayerPrivateMediaSourceAVFObjC::supportsTypeAndCodecs(const MediaEngineSupportParameters& parameters)
diff --git a/LayoutTests/media/iframe-load-html-as-m2ts-expected.txt b/LayoutTests/media/iframe-load-html-as-m2ts-expected.txt
new file mode 100644
index 000000000000..7f575e9da1e0
--- /dev/null
+++ b/LayoutTests/media/iframe-load-html-as-m2ts-expected.txt
@@ -0,0 +1,4 @@
+
+EXPECTED (frame.contentDocument.readyState == 'complete') OK
+END OF TEST
+
diff --git a/LayoutTests/media/iframe-load-html-as-m2ts.html b/LayoutTests/media/iframe-load-html-as-m2ts.html
new file mode 100644
index 000000000000..bf8acf9550a8
--- /dev/null
+++ b/LayoutTests/media/iframe-load-html-as-m2ts.html
@@ -0,0 +1,29 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>iframe-load-html-as-m2ts</title>
+ <script src="video-test.js"></script>
+ <script>
+ window.addEventListener('load', event => {
+ runTest().then(endTest).catch(failTest);
+ });
+
+ var frame;
+
+ async function runTest() {
+ frame = document.body.appendChild(document.createElement('iframe'));
+ waitFor(window, 'message').then(event => {
+ failTest(`Received message from iframe: "${event.data}"`);
+ });
+ frame.src = 'data:video/mp2t,%3Ch1%3EError%3C%2Fh1%3E%3Cscript%3Eparent.postMessage%28%22fail%22%2C%20%22%2A%22%29%3C%2Fscript%3E';
+ await testExpectedEventually('frame.contentDocument.readyState', 'complete');
+ await sleepFor('100');
+ }
+ </script>
+</head>
+<body>
+
+</body>
+</html>
\ No newline at end of file
Regression Test / PoC
diff --git a/LayoutTests/media/iframe-load-html-as-m2ts-expected.txt b/LayoutTests/media/iframe-load-html-as-m2ts-expected.txt
new file mode 100644
index 000000000000..7f575e9da1e0
--- /dev/null
+++ b/LayoutTests/media/iframe-load-html-as-m2ts-expected.txt
@@ -0,0 +1,4 @@
+
+EXPECTED (frame.contentDocument.readyState == 'complete') OK
+END OF TEST
+
diff --git a/LayoutTests/media/iframe-load-html-as-m2ts.html b/LayoutTests/media/iframe-load-html-as-m2ts.html
new file mode 100644
index 000000000000..bf8acf9550a8
--- /dev/null
+++ b/LayoutTests/media/iframe-load-html-as-m2ts.html
@@ -0,0 +1,29 @@
+<!DOCTYPE html>
+<html>
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>iframe-load-html-as-m2ts</title>
+ <script src="video-test.js"></script>
+ <script>
+ window.addEventListener('load', event => {
+ runTest().then(endTest).catch(failTest);
+ });
+
+ var frame;
+
+ async function runTest() {
+ frame = document.body.appendChild(document.createElement('iframe'));
+ waitFor(window, 'message').then(event => {
+ failTest(`Received message from iframe: "${event.data}"`);
+ });
+ frame.src = 'data:video/mp2t,%3Ch1%3EError%3C%2Fh1%3E%3Cscript%3Eparent.postMessage%28%22fail%22%2C%20%22%2A%22%29%3C%2Fscript%3E';
+ await testExpectedEventually('frame.contentDocument.readyState', 'complete');
+ await sleepFor('100');
+ }
+ </script>
+</head>
+<body>
+
+</body>
+</html>
\ No newline at end of file