Released March 24, 2026

CVE-2026-28871

CVSS 4.3 severity medium CWE-79 vector CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:N/A:N

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.cpp
  • Source/WebCore/platform/MIMETypeRegistry.cpp
  • Source/WebCore/platform/graphics/avfoundation/objc/MediaPlayerPrivateMediaSourceAVFObjC.mm

Test Files

  • LayoutTests/media/iframe-load-html-as-m2ts-expected.txt
  • LayoutTests/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