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
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
Source/WebCore/platform/MIMETypeRegistry.cpp
Source/WebCore/platform/graphics/avfoundation/objc/MediaPlayerPrivateMediaSourceAVFObjC.mm
LayoutTests/media/iframe-load-html-as-m2ts.html
Patch Details
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.
Background
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.
Analysis
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.
Insight
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.
Audit directions
-
Gatekeeper/dispatcher predicate mismatch. The invariant: whatever predicate admits a response for display must be the same predicate that selects its handler. Narrow — walk every branch of
DOMImplementation::createDocument(isSupportedImageMIMEType,isPDFMIMEType,isUSDMIMEType,supportsWebVisibleMimeType, theisTextMIMEType/XML tail) and diff each againstMIMETypeRegistry::canShowMIMETypeand the loader policy check actually used to admit that response. Wider — the same shape appears in any two-phase admit-then-dispatch in WebKit: download-vs-display policy inPolicyChecker, plugin visibility (PluginData::OnlyApplicationPluginsat dispatch versus a broader set at admission), archive and QuickLook handling. Widest — this is the general "allowlist and handler table disagree" class, and it transfers cleanly out of WebKit: HTTP framework routers that 404 into a catch-all, OS file-type handler registries, Android intent filters. Match tell on the narrow rung: an admission call that unions N backends sitting next to a dispatch call that passes extra parameters (context, platform type, codecs) the admission call never had. On the wider rung: any function whose decision to proceed and decision of who proceeds come from two differently-named helpers. On the widest rung: ask "can the set that gets me through the door be larger than the set of doors that exist?" -
Fail-open default arm at the end of a dispatch chain. The invariant: unrecognized input should land on the least-privileged handler, not the richest parser. Narrow — inspect the tail of
DOMImplementation::createDocumentpast the plugin lookup and enumerate which content types now reachHTMLDocument::createwithout ever having been claimed by a specific predicate; feed every entry ofsupportedMediaMIMETypes(),supportedImageMIMETypes(), and the plugin MIME set through the chain to build that list empirically. Wider — apply the same read to other WebCore dispatchers whose lastreturnconstructs the permissive option: content sniffers,ParserContentPolicydefaults, attachment andContent-Dispositionfallbacks. Widest — for any parser-selection code in any codebase, the reusable question is "what happens to input that matches nothing, and is that arm safer or more powerful than the arms above it?" Match tell: a long if-chain ofequalLettersIgnoringASCIICase/registry predicates whose final unconditionalreturnbuilds the script-executing object. -
Capability caches populated by backends with context-conditional support. The invariant: a registry keyed only on type must not be consulted in a context that requires more than type. Narrow — audit the other
MediaPlayerFactory::getSupportedTypesoverrides (the GStreamer and WebM MSE factories,MediaPlayerPrivateAVFoundationObjC, the remote/GPU-process media player proxies) and for each check whether its siblingsupportsTypeAndCodecsearly-returns on something beyond type and codecs —parameters.platformType,playbackTargetType, EME/CDM state — whilegetSupportedTypesstill publishes the full backend cache. Wider — the same asymmetry surrounds the other singleton MIME caches (AVAssetMIMETypeCache,AVStreamDataParserMIMETypeCache, the image-decoder type caches) and anywhere aContentTypeis dropped in favour of a bare type string. Widest — a feature-detection API and a capability registry that answer the same question at different granularities will drift; worth checking in any plugin or codec registry exposing bothisTypeSupported(type, opts)andlistSupportedTypes(). Match tell: agetSupportedTypesthat assigns a whole backend cache in one statement, living in the same class as asupportsType*with guard clauses on fields the cache never saw. -
Normalize-for-the-guard, raw-for-the-lookup. The new guard in
MIMETypeRegistry::isSupportedMediaMIMETypetestsmimeType.convertToASCIILowercase()against the three prefixes, but the subsequentsupportedMediaMIMETypes().contains(mimeType)still uses the original unnormalized string — the two steps only agree if thatHashSet's hash and equality are case-folding. Worth verifying this asymmetry is benign, since the shape either over-rejects (mixed-case types the set would have matched) or, in the reverse arrangement, under-rejects. Narrow — confirm the comparator onsupportedMediaMIMETypes()inMIMETypeRegistry.h/.cppand compare against the neighbouringadditionalSupportedImageMIMETypes(), which explicitly usesASCIICaseInsensitiveHash. Wider — grepMIMETypeRegistry.cppandHTTPParsers.cppfor other predicates that compute a normalized local and then pass the original onward. Widest — this is the general "canonicalize once, compare twice" class; the same shape underlies path-traversal and header-smuggling bugs wherever a check and its subsequent use disagree on which form of the string is authoritative. Match tell: a local variable holding a normalized copy that is read by exactly one of the two checks in the function.