CVE-2024-40785
Overview
Impact:
Description: Processing maliciously crafted web content may lead to a cross site scripting attack
Researchers: Johan Carlsson (joaxcar)
Key insight. An incomplete allow-list: the text/plain-preference safeguard forced XML and SVG back to inert text/plain but forgot application/xhtml+xml, so a text/plain response could still be reinterpreted as scriptable XHTML and run its embedded scripts – classic XSS via MIME confusion, fixed by completing the list.
Patch Details
diff --git a/LayoutTests/http/tests/mime/resources/.htaccess b/LayoutTests/http/tests/mime/resources/.htaccess
index 8c651044dc8c..3c1d95df81fa 100644
--- a/LayoutTests/http/tests/mime/resources/.htaccess
+++ b/LayoutTests/http/tests/mime/resources/.htaccess
@@ -1,6 +1,14 @@
<Files xml-with-html.xml>
ForceType "text/plain"
</Files>
+<Files xhtml-with-html.xhtml>
+ForceType "text/plain"
+</Files>
+<Files xhtml-with-html-with-content-disposition>
+ForceType "text/plain"
+Header always set Content-Disposition "inline; filename=\"xhtml-with-html-with-content-disposition.xhtml\""
+Header always set X-Content-Type-Options "nosniff"
+</Files>
<Files svg-with-html.svg>
ForceType "text/plain"
</Files>
diff --git a/LayoutTests/http/tests/mime/resources/xhtml-with-html-with-content-disposition b/LayoutTests/http/tests/mime/resources/xhtml-with-html-with-content-disposition
new file mode 100644
index 000000000000..91a7bdb2a4cc
--- /dev/null
+++ b/LayoutTests/http/tests/mime/resources/xhtml-with-html-with-content-disposition
@@ -0,0 +1,4 @@
+<?xml version=\'1.0\' encoding=\'utf-8\'?>
+<html xmlns:html="http://www.w3.org/1999/xhtml">
+<html:script>console.log("Fail");</html:script>
+</html>
diff --git a/LayoutTests/http/tests/mime/resources/xhtml-with-html.xhtml b/LayoutTests/http/tests/mime/resources/xhtml-with-html.xhtml
new file mode 100644
index 000000000000..91a7bdb2a4cc
--- /dev/null
+++ b/LayoutTests/http/tests/mime/resources/xhtml-with-html.xhtml
@@ -0,0 +1,4 @@
+<?xml version=\'1.0\' encoding=\'utf-8\'?>
+<html xmlns:html="http://www.w3.org/1999/xhtml">
+<html:script>console.log("Fail");</html:script>
+</html>
diff --git a/LayoutTests/http/tests/mime/xhtml-with-html-expected.txt b/LayoutTests/http/tests/mime/xhtml-with-html-expected.txt
new file mode 100644
index 000000000000..1771f28358a1
--- /dev/null
+++ b/LayoutTests/http/tests/mime/xhtml-with-html-expected.txt
@@ -0,0 +1,2 @@
+xhtml-with-html.xhtml has MIME type text/plain
+
diff --git a/LayoutTests/http/tests/mime/xhtml-with-html-with-content-disposition-expected.txt b/LayoutTests/http/tests/mime/xhtml-with-html-with-content-disposition-expected.txt
new file mode 100644
index 000000000000..caaaacfd863e
--- /dev/null
+++ b/LayoutTests/http/tests/mime/xhtml-with-html-with-content-disposition-expected.txt
@@ -0,0 +1,2 @@
+xhtml-with-html-with-content-disposition has MIME type text/plain
+
diff --git a/LayoutTests/http/tests/mime/xhtml-with-html-with-content-disposition.html b/LayoutTests/http/tests/mime/xhtml-with-html-with-content-disposition.html
new file mode 100644
index 000000000000..e29d3836b9ba
--- /dev/null
+++ b/LayoutTests/http/tests/mime/xhtml-with-html-with-content-disposition.html
@@ -0,0 +1,24 @@
+<html>
+<script>
+if (window.testRunner) {
+ testRunner.waitUntilDone();
+ testRunner.dumpResourceResponseMIMETypes();
+ testRunner.dumpAsText();
+}
+</script>
+<body>
+<script>
+function runTests() {
+ let iframe = document.createElement("iframe");
+ iframe.src = 'resources/xhtml-with-html-with-content-disposition';
+ if (window.testRunner) {
+ iframe.onerror = testRunner.notifyDone();
+ iframe.onload = testRunner.notifyDone();
+ }
+ document.body.appendChild(iframe);
+}
+runTests();
+</script>
+</body>
+</html>
+
diff --git a/LayoutTests/http/tests/mime/xhtml-with-html.html b/LayoutTests/http/tests/mime/xhtml-with-html.html
new file mode 100644
index 000000000000..41478413bcb6
--- /dev/null
+++ b/LayoutTests/http/tests/mime/xhtml-with-html.html
@@ -0,0 +1,24 @@
+<html>
+<script>
+if (window.testRunner) {
+ testRunner.waitUntilDone();
+ testRunner.dumpResourceResponseMIMETypes();
+ testRunner.dumpAsText();
+}
+</script>
+<body>
+<script>
+function runTests() {
+ let iframe = document.createElement("iframe");
+ iframe.src = 'resources/xhtml-with-html.xhtml';
+ if (window.testRunner) {
+ iframe.onerror = testRunner.notifyDone();
+ iframe.onload = testRunner.notifyDone();
+ }
+ document.body.appendChild(iframe);
+}
+runTests();
+</script>
+</body>
+</html>
+
diff --git a/Source/WebCore/platform/network/ios/WebCoreURLResponseIOS.mm b/Source/WebCore/platform/network/ios/WebCoreURLResponseIOS.mm
index 8822e84e8a7c..b07adf5849cc 100644
--- a/Source/WebCore/platform/network/ios/WebCoreURLResponseIOS.mm
+++ b/Source/WebCore/platform/network/ios/WebCoreURLResponseIOS.mm
@@ -40,7 +40,7 @@ namespace WebCore {
static inline bool shouldPreferTextPlainMIMEType(const String& mimeType, const String& proposedMIMEType)
{
- return ("text/plain"_s == mimeType) && ((proposedMIMEType == "text/xml"_s) || (proposedMIMEType == "application/xml"_s) || (proposedMIMEType == "image/svg+xml"_s));
+ return ("text/plain"_s == mimeType) && ((proposedMIMEType == "text/xml"_s) || (proposedMIMEType == "application/xhtml+xml"_s) || (proposedMIMEType == "application/xml"_s) || (proposedMIMEType == "image/svg+xml"_s));
}
void adjustMIMETypeIfNecessary(CFURLResponseRef response, bool isMainResourceLoad)
Background
Advisory: Apple Security Advisory
| Attribute | Value |
|---|---|
| CVE | CVE-2024-40785 |
| Bugzilla | 273805 |
| Component | WebKit |
| Bug Class | Bypass |
| Severity | medium |
| Commit | efaf5e842f103b13… |
| Advisory | Apple Advisory |
Terminology
- MIME type downgrade (text/plain preference) — A defensive WebKit behavior that keeps a server-declared text/plain response inert instead of letting a sniffed/proposed scriptable type turn it into active content.
- adjustMIMETypeIfNecessary — The iOS WebCore routine that rewrites a response’s MIME type based on helpers like shouldPreferTextPlainMIMEType before the content is dispatched to a parser.
- XHTML / html-namespaced script — application/xhtml+xml is parsed as an XML document in which <script> elements in the XHTML namespace are executable, making XHTML a scriptable (active) content type.
- X-Content-Type-Options: nosniff — A header telling the browser not to MIME-sniff; the test’s content-disposition variant shows the fix must hold even when sniffing is disabled and a type is proposed by other means.
- Content sniffing / proposed MIME type — The platform network layer may compute a ‘proposed’ type from bytes or filename that differs from the server’s declared type, which is what could promote text/plain to XHTML.
Root Cause Analysis
On iOS, WebCore post-processes network responses in WebCoreURLResponseIOS.mm to defensively downgrade certain MIME types. The helper shouldPreferTextPlainMIMEType returns true when the server-declared type is text/plain and the platform/network layer proposed a scriptable markup type, in which case adjustMIMETypeIfNecessary keeps the response as text/plain so it is displayed inertly rather than parsed as active content. Before the patch the allow-list of ‘proposed’ types that should be forced back to text/plain covered text/xml, application/xml, and image/svg+xml but omitted application/xhtml+xml. XHTML is a scriptable document type: an XHTML document can carry executable <html:script> elements in the XHTML namespace, so if a response that a server labeled text/plain is instead treated as application/xhtml+xml, WebKit will parse it as an XHTML document and run its scripts. The invariant being enforced is ‘a resource the server declared as text/plain must not be silently reinterpreted as an executable/scriptable document’; the missing entry broke that invariant for the XHTML case, so content sniffing or a proposed-type computation could promote a text/plain body into an active XHTML document. The fix adds application/xhtml+xml to the same list, so a text/plain response with a proposed XHTML type is coerced back to text/plain and its embedded scripts do not execute. The added layout tests confirm that xhtml-with-html.xhtml (and a Content-Disposition:inline + X-Content-Type-Options:nosniff variant) are reported as MIME type text/plain and that the embedded console.log(‘Fail’) script does not run.
Attack Path
1. Control a response body and its declared type
The attacker gets a victim to load a resource whose bytes are attacker-controlled XHTML containing an html-namespaced <html:script>, e.g. an upload or user-content endpoint that serves files as text/plain.
2. Induce an XHTML proposed type
Arrange conditions (filename extension .xhtml, sniffing, or the network layer’s proposed MIME computation) so WebKit’s proposed type becomes application/xhtml+xml even though the server said text/plain.
3. Slip past the text/plain downgrade
Because application/xhtml+xml was absent from shouldPreferTextPlainMIMEType, adjustMIMETypeIfNecessary does not force text/plain, so the response is handed to the XML/XHTML document parser.
4. Execute injected script
The XHTML parser runs the embedded <html:script>, executing attacker JavaScript in the security context/origin the document is loaded under – a cross-site scripting outcome.
5. Abuse the origin
The script performs same-origin actions (read cookies/DOM, issue authenticated requests) against the origin serving the content.
Impact Assessment
This is a MIME-type handling / content-type confusion flaw, not a memory-safety bug: the primitive is the ability to get attacker-supplied bytes that a server marked text/plain parsed and scripted as XHTML, i.e. cross-site scripting within the loaded document’s origin. Realistic impact is script execution in that origin (cookie/DOM theft, authenticated requests, phishing), scoped to whatever origin serves the content and constrained by the same-origin policy; it does not by itself yield memory corruption or a sandbox escape. It executes in the WebContent process and is limited to iOS (the fix is in the ios/ platform file).
Audit Directions
Complete the scriptable-type list
Re-examine shouldPreferTextPlainMIMEType and any sibling allow-lists for other scriptable/active markup types that are missing – e.g. application/xml-external-parsed-entity, image/svg+xml variants, text/html-adjacent types, or vendor XML types that the XML/XHTML parser will script; grep for ‘+xml’ and ’text/xml’/‘application/xml’ comparisons.
Cross-platform parity
Compare the iOS WebCoreURLResponseIOS.mm logic with the macOS/other WebCoreURLResponse implementations (grep for shouldPreferTextPlainMIMEType and adjustMIMETypeIfNecessary across Source/WebCore/platform/network) to ensure the same XHTML entry and overall list are consistent on every platform.
Type-string comparison correctness
Audit MIME comparisons here and nearby for case sensitivity and parameter handling (e.g. ‘application/xhtml+xml; charset=…’ or uppercase), since equality against a bare lowercased literal can be bypassed if the proposed type carries parameters or differing case.
Other MIME-to-parser routing decisions
Look at all sites that decide whether a response is treated as a document vs. inert (grep for mimeTypeInferredFromExtension, isSupportedXMLMIMEType/isXMLMIMEType, MIMETypeRegistry active-type checks) for places where a text/plain or download disposition could still be routed to a scripting parser.
Changed Functions
| Function | File | Change | Note |
|---|---|---|---|
shouldPreferTextPlainMIMEType |
Source/WebCore/platform/network/ios/WebCoreURLResponseIOS.mm |
modified | Adds application/xhtml+xml to the set of proposed MIME types that, when the declared type is text/plain, force the response back to text/plain – closing the XHTML scripting gap alongside text/xml, application/xml, image/svg+xml. |
Regression Test
The fix ships this proof-of-concept / regression test:
diff --git a/LayoutTests/http/tests/mime/resources/.htaccess b/LayoutTests/http/tests/mime/resources/.htaccess
index 8c651044dc8c..3c1d95df81fa 100644
--- a/LayoutTests/http/tests/mime/resources/.htaccess
+++ b/LayoutTests/http/tests/mime/resources/.htaccess
@@ -1,6 +1,14 @@
<Files xml-with-html.xml>
ForceType "text/plain"
</Files>
+<Files xhtml-with-html.xhtml>
+ForceType "text/plain"
+</Files>
+<Files xhtml-with-html-with-content-disposition>
+ForceType "text/plain"
+Header always set Content-Disposition "inline; filename=\"xhtml-with-html-with-content-disposition.xhtml\""
+Header always set X-Content-Type-Options "nosniff"
+</Files>
<Files svg-with-html.svg>
ForceType "text/plain"
</Files>
diff --git a/LayoutTests/http/tests/mime/resources/xhtml-with-html-with-content-disposition b/LayoutTests/http/tests/mime/resources/xhtml-with-html-with-content-disposition
new file mode 100644
index 000000000000..91a7bdb2a4cc
--- /dev/null
+++ b/LayoutTests/http/tests/mime/resources/xhtml-with-html-with-content-disposition
@@ -0,0 +1,4 @@
+<?xml version=\'1.0\' encoding=\'utf-8\'?>
+<html xmlns:html="http://www.w3.org/1999/xhtml">
+<html:script>console.log("Fail");</html:script>
+</html>
diff --git a/LayoutTests/http/tests/mime/resources/xhtml-with-html.xhtml b/LayoutTests/http/tests/mime/resources/xhtml-with-html.xhtml
new file mode 100644
index 000000000000..91a7bdb2a4cc
--- /dev/null
+++ b/LayoutTests/http/tests/mime/resources/xhtml-with-html.xhtml
@@ -0,0 +1,4 @@
+<?xml version=\'1.0\' encoding=\'utf-8\'?>
+<html xmlns:html="http://www.w3.org/1999/xhtml">
+<html:script>console.log("Fail");</html:script>
+</html>
diff --git a/LayoutTests/http/tests/mime/xhtml-with-html-expected.txt b/LayoutTests/http/tests/mime/xhtml-with-html-expected.txt
new file mode 100644
index 000000000000..1771f28358a1
--- /dev/null
+++ b/LayoutTests/http/tests/mime/xhtml-with-html-expected.txt
@@ -0,0 +1,2 @@
+xhtml-with-html.xhtml has MIME type text/plain
+
diff --git a/LayoutTests/http/tests/mime/xhtml-with-html-with-content-disposition-expected.txt b/LayoutTests/http/tests/mime/xhtml-with-html-with-content-disposition-expected.txt
new file mode 100644
index 000000000000..caaaacfd863e
--- /dev/null
+++ b/LayoutTests/http/tests/mime/xhtml-with-html-with-content-disposition-expected.txt
@@ -0,0 +1,2 @@
+xhtml-with-html-with-content-disposition has MIME type text/plain
+
diff --git a/LayoutTests/http/tests/mime/xhtml-with-html-with-content-disposition.html b/LayoutTests/http/tests/mime/xhtml-with-html-with-content-disposition.html
new file mode 100644
index 000000000000..e29d3836b9ba
--- /dev/null
+++ b/LayoutTests/http/tests/mime/xhtml-with-html-with-content-disposition.html
@@ -0,0 +1,24 @@
+<html>
+<script>
+if (window.testRunner) {
+ testRunner.waitUntilDone();
+ testRunner.dumpResourceResponseMIMETypes();
+ testRunner.dumpAsText();
+}
+</script>
+<body>
+<script>
+function runTests() {
+ let iframe = document.createElement("iframe");
+ iframe.src = 'resources/xhtml-with-html-with-content-disposition';
+ if (window.testRunner) {
+ iframe.onerror = testRunner.notifyDone();
+ iframe.onload = testRunner.notifyDone();
+ }
+ document.body.appendChild(iframe);
+}
+runTests();
+</script>
+</body>
+</html>
+
diff --git a/LayoutTests/http/tests/mime/xhtml-with-html.html b/LayoutTests/http/tests/mime/xhtml-with-html.html
new file mode 100644
index 000000000000..41478413bcb6
--- /dev/null
+++ b/LayoutTests/http/tests/mime/xhtml-with-html.html
@@ -0,0 +1,24 @@
+<html>
+<script>
+if (window.testRunner) {
+ testRunner.waitUntilDone();
+ testRunner.dumpResourceResponseMIMETypes();
+ testRunner.dumpAsText();
+}
+</script>
+<body>
+<script>
+function runTests() {
+ let iframe = document.createElement("iframe");
+ iframe.src = 'resources/xhtml-with-html.xhtml';
+ if (window.testRunner) {
+ iframe.onerror = testRunner.notifyDone();
+ iframe.onload = testRunner.notifyDone();
+ }
+ document.body.appendChild(iframe);
+}
+runTests();
+</script>
+</body>
+</html>
+