Released July 29, 2024

CVE-2024-40785

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

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>
+