CVE-2026-28871
Overview
Impact:
Description: Visiting a maliciously crafted website may lead to a cross-site scripting attack
Researchers: @hamayanhamayan
Key insight. The vulnerability is a classifier-divergence bug: two different predicates (live MediaPlayer::supportsType vs the static MIMETypeRegistry) disagreed about whether a MIME type is ‘media’, so a resource admitted as media could still be built as an executable document — HTML labeled as media got parsed and its script ran. Consistency of a security-relevant type decision across code paths is the real invariant.
Patch Details
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
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)
Background
Advisory: Apple Security Advisory
| Attribute | Value |
|---|---|
| CVE | CVE-2026-28871 |
| Bugzilla | 305859 |
| Component | WebCore |
| Bug Class | LogicError |
| Severity | medium |
| Commit | 59efb640749a3024… |
| Advisory | Apple Advisory |
Terminology
- 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
1. 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.
2. 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.
3. 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.
4. 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.
5. 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
This is a same-origin/type-confusion logic bug, not a memory-corruption primitive: the payoff is arbitrary script execution in the origin under which the mislabeled response is served, i.e. cross-site scripting, entirely within the WebContent process and its sandbox. It does not by itself give an out-of-bounds or use-after-free primitive and cannot be escalated to native code execution through this path alone. Realistic impact is against sites that accept user-controlled content and rely on serving it under a supposedly inert media MIME type; there it enables full XSS (cookie/session theft, DOM access) in that site’s context.
Audit Directions
Other createDocument-style media decisions
Grep 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 registry
Audit 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 sets
Search 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 types
Look 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.
Changed Functions
| Function | File | Change | Note |
|---|---|---|---|
DOMImplementation::createDocument |
Source/WebCore/dom/DOMImplementation.cpp |
modified | Replaced the MediaEngineSupportParameters + MediaPlayer::supportsType() query with a single MIMETypeRegistry::isSupportedMediaMIMEType(contentType) call to decide whether to build a MediaDocument, aligning document creation with the rest of WebKit’s media-type classification. |
MIMETypeRegistry::isSupportedMediaMIMEType |
Source/WebCore/platform/MIMETypeRegistry.cpp |
modified | Added a guard that lowercases the type and returns false unless it starts with ‘video/’, ‘audio/’, or ‘application/’, before checking supportedMediaMIMETypes(), preventing non-media prefixes from being accepted as media. |
MediaPlayerPrivateMediaSourceAVFObjC::getSupportedTypes |
Source/WebCore/platform/graphics/avfoundation/objc/MediaPlayerPrivateMediaSourceAVFObjC.mm |
modified | Now clears the output set instead of returning AVStreamDataParserMIMETypeCache::singleton().supportedTypes(), removing the dynamic OS-dependent type list that was a source of divergent media-type support answers. |
Files Changed
Source Files
Source/WebCore/dom/DOMImplementation.cppSource/WebCore/platform/MIMETypeRegistry.cppSource/WebCore/platform/graphics/avfoundation/objc/MediaPlayerPrivateMediaSourceAVFObjC.mm
Test Files
LayoutTests/media/iframe-load-html-as-m2ts-expected.txtLayoutTests/media/iframe-load-html-as-m2ts.html
Regression Test
The fix ships this proof-of-concept / regression test:
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