CVE-2024-40785
Overview
Background
- 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
- 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.
- 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.
- 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.
- 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.
- Abuse the origin The script performs same-origin actions (read cookies/DOM, issue authenticated requests) against the origin serving the content.
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifLayoutTests/http/tests/mime/xhtml-with-html-with-content-disposition.html |
modified | |
ifLayoutTests/http/tests/mime/xhtml-with-html.html |
modified |
Files Changed
Source/WebCore/platform/network/ios/WebCoreURLResponseIOS.mmLayoutTests/http/tests/mime/resources/.htaccessLayoutTests/http/tests/mime/resources/xhtml-with-html-with-content-dispositionLayoutTests/http/tests/mime/resources/xhtml-with-html.xhtmlLayoutTests/http/tests/mime/xhtml-with-html-expected.txtLayoutTests/http/tests/mime/xhtml-with-html-with-content-disposition-expected.txtLayoutTests/http/tests/mime/xhtml-with-html-with-content-disposition.htmlLayoutTests/http/tests/mime/xhtml-with-html.html
Audit Directions
- Complete the scriptable-type listRe-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 parityCompare 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 correctnessAudit 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 decisionsLook 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.
Patch
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)
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>
+
Regression Test / PoC
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>
+