← All reports

Content served as video/mp2t can be loaded as html

CVE: CVE-2026-28871 · Safari 26.4 · Released March 24, 2026 Impact: Visiting a maliciously crafted website may lead to a cross-site scripting attack Apple's description: A logic issue was addressed with improved checks. Credit: @hamayanhamayan

Severity: High | Component: WebCore document factory / MIME type registry | 59efb64 | Bugzilla 305859

High. Two consecutive steps of the same navigation asked "is this media?" with two different predicates, and the wider one won the admission vote while the narrower one lost the dispatch vote — dropping the response onto a handler chain whose default arm is the HTML parser. No memory primitive, just script execution in whatever origin served the bytes.

Every navigation that ends in a rendered frame passes through two decisions that look like one: first the loader decides the response is displayable rather than downloadable, then a factory decides which Document subclass will consume the bytes. Those two decisions are made by different code with different information — the loader consults a flat registry of MIME types the engine claims it can handle, while DOMImplementation::createDocument runs an ordered if-chain of content-type predicates. The invariant binding them is that the set of types admitted for display must be exactly the set of types some specific handler will claim, because the chain's final unconditional arm builds an HTMLDocument.

The angle: A site that lets users upload bytes and serves them back under a transport-stream video type had those bytes parsed as markup, so inline script ran with that site's cookies, storage, and DOM.

Source/WebCore/dom/DOMImplementation.cpp

#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

Source/WebCore/platform/MIMETypeRegistry.cpp

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);
}

Source/WebCore/platform/graphics/avfoundation/objc/MediaPlayerPrivateMediaSourceAVFObjC.mm

void MediaPlayerPrivateMediaSourceAVFObjC::getSupportedTypes(HashSet<String>& types)
{
- types = AVStreamDataParserMIMETypeCache::singleton().supportedTypes();
+ types.clear();
}

LayoutTests/media/iframe-load-html-as-m2ts.html

+ 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');

Three production changes, each closing the same gap from a different side, plus a regression test.

In DOMImplementation.cpp, the #if ENABLE(VIDEO) arm of createDocument stops constructing a MediaEngineSupportParameters and stops calling MediaPlayer::supportsType(parameters). It now asks MIMETypeRegistry::isSupportedMediaMIMEType(contentType) — the registry-level predicate, the same table the loader consulted when it decided this response would be displayed at all. The two steps of the navigation now share one source of truth.

In MIMETypeRegistry.cpp, isSupportedMediaMIMEType grows a top-level-type guard ahead of the set lookup: it lowercases the incoming type and returns false unless the result starts with video/, audio/, or application/. Nothing registered by any engine under a text/* or image/* top-level type can route a response to MediaDocument::create regardless of what a backend publishes into the table.

In MediaPlayerPrivateMediaSourceAVFObjC.mm, the MediaSource Extensions engine's getSupportedTypes stops publishing AVStreamDataParserMIMETypeCache::singleton().supportedTypes() and calls types.clear() instead. MSE-only container types no longer appear in the aggregated supportedMediaMIMETypes() set, and therefore no longer pass canShowMIMEType. MSE feature detection is unaffected: it flows through MediaPlayerPrivateMediaSourceAVFObjC::supportsTypeAndCodecs, which early-returns IsNotSupported unless parameters.platformType == PlatformMediaDecodingType::MediaSource.

The layout test navigates an iframe to data:video/mp2t,<h1>Error</h1><script>parent.postMessage("fail","*")</script> and fails if the message ever arrives. Because a data: URL document gets an opaque origin, postMessage is the only way to observe from outside whether script ran; the test's positive assertion is simply that the frame's document reaches readyState == 'complete' — i.e. the iframe stays at its initial about:blank-equivalent document because the response is no longer displayable.

Content-type admission. When a response arrives, WebKit decides whether to render it in a frame or hand it off (download, external handler). That decision runs through MIMETypeRegistry::canShowMIMEType(type), a membership test over WebCore's central table of types the engine claims it can handle.

The media portion of that table. supportedMediaMIMETypes() is not a static list. It is a union, built by asking every registered MediaPlayerFactory for its getSupportedTypes() set and merging the results. MIMETypeRegistry::isSupportedMediaMIMEType is a membership test against that union. The key property: the union is keyed on type alone.

The finer-grained query. MediaPlayer::supportsType(MediaEngineSupportParameters) asks a richer question. The parameters carry the content type with codecs, the URL, and a platformType distinguishing a bare-URL load from a MediaSource load; each engine answers IsNotSupported, MayBeSupported, or IsSupported. An engine can therefore gate on information the type-alone registry never carried.

MSE and its Apple-platform engine. Media Source Extensions is the JS API where a page constructs a MediaSource, appends container byte segments into SourceBuffers, and attaches the result to a media element; MediaSource.isTypeSupported() reports which container/codec combinations can be appended. On Apple platforms this is implemented by a dedicated engine, MediaPlayerPrivateMediaSourceAVFObjC, registered alongside the engines that play a URL directly. Its parsing is done by AVFoundation's AVStreamDataParser, and AVStreamDataParserMIMETypeCache is the singleton listing the container types that parser understands.

The document factory. DOMImplementation::createDocument(contentType, frame, settings, url, ...) runs after load policy has already accepted the response. It tests the content type against an ordered series of predicates — text/html, application/xhtml+xml, text/plain, PDF, image, media, model, FTP directory listing, plugin — and returns the matching Document subclass.

What the handler choice means for script. MediaDocument is a synthesized document containing a single <video> element pointed at the URL; it never parses the response bytes as markup. HTMLDocument feeds the bytes to the HTML parser, which executes inline and external scripts in the document's origin.

video/mp2t is the MPEG-2 Transport Stream container, most familiar as the per-segment file type inside HLS streams.

This is a gatekeeper/dispatcher predicate mismatch: the admission check ORs over every media backend, the dispatch check re-asks a narrower question, and types in the gap fall through to the chain's default arm.

  Admission (loader)                 Dispatch (createDocument)
  canShowMIMEType("video/mp2t")      MediaPlayer::supportsType({type, url})
    └─► supportedMediaMIMETypes()      └─► MSE engine: platformType
          = ∪ getSupportedTypes()            != MediaSource → IsNotSupported
            ├─ AVFoundation URL engine       └─► no other engine claims it
            └─ MSE engine ──────────┐              │
                 AVStreamDataParser │              ▼
                 cache: video/mp2t ─┘        if-chain matches nothing
                        │                           │
                   ADMITTED ────────────────► HTMLDocument::create()

Follow the left column of the diagram. The MSE engine's static getSupportedTypes assigned the entire AVStreamDataParserMIMETypeCache into the output set in one statement. Those are container types the AVFoundation byte-stream parser can parse when fed through a MediaSource — they are not types any engine will play from a bare URL. But getSupportedTypes has nowhere to express that condition: the registry it feeds is keyed on type alone. So video/mp2t entered supportedMediaMIMETypes(), canShowMIMEType returned true, and the loader committed to displaying the response in a frame.

Now the right column. createDocument asked MediaPlayer::supportsType(parameters) with a freshly default-constructed MediaEngineSupportParameters — content type and URL set, platformType left at its default, which is not MediaSource. MediaPlayerPrivateMediaSourceAVFObjC::supportsTypeAndCodecs early-returns IsNotSupported for exactly that case, since an MSE engine cannot service a URL load. No non-MSE engine claimed the type either. The media arm therefore declined a type the loader had already admitted on that same engine's behalf.

What happens to a response that nothing claims is the second half of the bug. The if-chain walks text/html, XHTML, text/plain, PDF, image, media, model, FTP-dir, plugin — and matches none of them. The tail of the chain fails open: the unmatched case constructs an HTMLDocument. The bytes the server labelled as a video container, which a hosting site may treat as inert user-uploaded data, go to the HTML parser, and inline <script> executes in the security origin of the URL that served them. The commit message states the outcome plainly — "which leads to the DOMImplementation creating a HTMLDocument for a non-playable media type" — and the regression test encodes it: parent.postMessage("fail", "*") can only run if the mp2t response was parsed as markup.

The security model assumption this breaks is the one every file-upload host and user-content origin relies on: that serving bytes with a media Content-Type makes them inert with respect to script execution in that origin. An origin serving attacker-controlled bytes under an MSE-only media type had those bytes parsed as markup, yielding same-origin DOM access, document.cookie, credentialed fetches, and storage exfiltration — a stored-XSS vector against the hosting site. Worth being precise about what this is not: the declared type was honoured at the policy step, and only handler selection disagreed. X-Content-Type-Options: nosniff and scrupulous Content-Type labelling would not have prevented it, because no sniffing occurred. The blast radius stays inside the WebContent process — no sandbox or process boundary is crossed, and no memory-safety primitive is involved.

The fix seals the gap from both ends. createDocument now calls the same registry predicate the admission step used, so anything admitted as media becomes a MediaDocument and never markup. The MSE engine stops contributing MSE-only types to that registry, so video/mp2t is no longer admitted for display at all — matching the test's expectation that the iframe simply stays at its initial document. The video//audio//application/ prefix guard is belt-and-braces, blocking any future engine-registered entry with a text/* or image/* top-level type from reaching MediaDocument::create. The trade is deliberate and visible in the diff: WebKit now declines to display bare .ts URLs rather than showing a media viewer for them, with MSE feature detection left to route through supportsTypeAndCodecs under platformType == MediaSource.

An MSE-only container type sat inside the loader's admission set but outside the document factory's dispatch set, and the factory's unmatched arm is the HTML parser.

The structural detail worth carrying past this CVE is that the media registry is a union over backends with unequal preconditions. MediaPlayerFactory::getSupportedTypes is keyed on type alone, while its sibling supportsTypeAndCodecs can additionally gate on platformType, EME state, or playback target. An engine that only functions under MSE will therefore inevitably inflate the type-alone registry with types nothing can play standalone — the asymmetry is built into the interface, not introduced by a mistake in this particular backend. Any consumer that admits on the union and then re-asks a stricter question reproduces this gap by construction. That the consequence here was script execution rather than a blank frame is a separate design decision: the tail of DOMImplementation::createDocument returns the scripting-capable handler for unmatched input, so every predicate mismatch anywhere in that chain converts directly into XSS.