Released March 5, 2024

CVE-2024-23280

CVSS 6.5 severity medium CWE-74 vector CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:N

Overview

Impact:

Description: A maliciously crafted webpage may be able to fingerprint the user

Researchers: an anonymous researcher

Key insight. DOMFileSystem resolved and stat’d paths even when it had no granted root (m_rootPath empty), so a File synthesized via DataTransfer could probe local filesystem metadata; guarding on a non-empty root closes the leak.

Patch Details

diff --git a/LayoutTests/http/tests/security/file-system-access-via-dataTransfer-expected.txt b/LayoutTests/http/tests/security/file-system-access-via-dataTransfer-expected.txt
new file mode 100644
index 000000000000..641ddaebab3e
--- /dev/null
+++ b/LayoutTests/http/tests/security/file-system-access-via-dataTransfer-expected.txt
@@ -0,0 +1,10 @@
+Test that accessing local file system metadata is not allowed
+
+On success, you will see a series of "PASS" messages, followed by "TEST COMPLETE".
+
+
+PASS Should not receive file
+PASS successfullyParsed is true
+
+TEST COMPLETE
+
diff --git a/LayoutTests/http/tests/security/file-system-access-via-dataTransfer.html b/LayoutTests/http/tests/security/file-system-access-via-dataTransfer.html
new file mode 100644
index 000000000000..caf05e1f85fc
--- /dev/null
+++ b/LayoutTests/http/tests/security/file-system-access-via-dataTransfer.html
@@ -0,0 +1,50 @@
+<html>
+<head>
+<script src="/js-test-resources/js-test.js"></script>
+<body>
+
+<script>
+description("Test that accessing local file system metadata is not allowed");
+
+function runTest() {
+    if (!window.internals) {
+        alert("This test depends on Internals");
+        return;
+    }
+
+    window.jsTestIsAsync = true;
+
+    let path = location.pathname.split("/");
+    let targetFileName = internals.createTemporaryFile(`${path[path.length - 1]}`, "");
+
+    let input = document.createElement("input");
+    input.type = "file";
+
+    let file = new File([], targetFileName, {"type":"text/plain"});
+
+    dataTransfer = new DataTransfer();
+    dataTransfer.items.add(file)
+    input.files = dataTransfer.files;
+
+    var functionOnSuccess = function (file)
+    {
+        testFailed("Should not receive file");
+        finishJSTest()
+    }
+
+    var functionOnError = function (value)
+    {
+        testPassed("Should not receive file");
+        finishJSTest()
+    }
+
+    input.webkitEntries.forEach((entry) => {
+        entry.file(functionOnSuccess, functionOnError)
+    });
+}
+
+runTest();
+
+</script>
+</body>
+</html>
diff --git a/Source/WebCore/Modules/entriesapi/DOMFileSystem.cpp b/Source/WebCore/Modules/entriesapi/DOMFileSystem.cpp
index 0c08ddbec609..e86b3dcc356c 100644
--- a/Source/WebCore/Modules/entriesapi/DOMFileSystem.cpp
+++ b/Source/WebCore/Modules/entriesapi/DOMFileSystem.cpp
@@ -303,6 +303,9 @@ void DOMFileSystem::getEntry(ScriptExecutionContext& context, FileSystemDirector
         return;
     }
 
+    if (m_rootPath.isEmpty())
+        return completionCallback(Exception { ExceptionCode::NotFoundError, "Path does not exist"_s });
+
     m_workQueue->dispatch([protectedThis = Ref { *this }, context = Ref { context }, fullPath = crossThreadCopy(WTFMove(fullPath)), resolvedVirtualPath = crossThreadCopy(WTFMove(resolvedVirtualPath)), completionCallback = WTFMove(completionCallback)]() mutable {
         auto entryType = fileTypeIgnoringHiddenFiles(fullPath);
         callOnMainThread([protectedThis = WTFMove(protectedThis), context = WTFMove(context), resolvedVirtualPath = crossThreadCopy(WTFMove(resolvedVirtualPath)), entryType, completionCallback = WTFMove(completionCallback)]() mutable {
@@ -327,6 +330,8 @@ void DOMFileSystem::getEntry(ScriptExecutionContext& context, FileSystemDirector
 
 void DOMFileSystem::getFile(ScriptExecutionContext& context, FileSystemFileEntry& fileEntry, GetFileCallback&& completionCallback)
 {
+    if (m_rootPath.isEmpty())
+        return completionCallback(Exception { ExceptionCode::NotFoundError, "Path does not exist"_s });
     auto virtualPath = fileEntry.virtualPath();
     auto fullPath = evaluatePath(virtualPath);
     m_workQueue->dispatch([fullPath = crossThreadCopy(WTFMove(fullPath)), virtualPath = crossThreadCopy(WTFMove(virtualPath)), context = Ref { context }, completionCallback = WTFMove(completionCallback)]() mutable {

Background

Advisory: Apple Security Advisory

Attribute Value
CVE CVE-2024-23280
Bugzilla 266703
Component WebCore
Bug Class LogicError
Severity medium
Commit 17c0ad98bb1ce2d5…
Advisory Apple Advisory

Terminology

  • Entries API / DOMFileSystem — A web API exposing FileSystemEntry objects (from drag-drop/file inputs) that resolve virtual paths against a root directory.
  • m_rootPath — The granted sandbox root for the file system; empty means no legitimate directory was granted.
  • Filesystem metadata probing — Testing existence/type of local paths to infer host/user characteristics (fingerprinting).

Root Cause Analysis

This fixes a local-filesystem information leak (user fingerprinting) in the Entries API’s DOMFileSystem. A File can be introduced via DataTransfer (drag-and-drop) with an attacker-influenced name, and webkitEntries / FileSystemEntry.file()/getEntry() then resolve paths against a DOMFileSystem. DOMFileSystem::getEntry and getFile evaluate a virtual path against m_rootPath and dispatch work to a background queue that stats the resolved full path (fileTypeIgnoringHiddenFiles). Pre-patch, when m_rootPath was empty — i.e. the file system has no legitimately granted sandbox root (as happens for a File synthesized via DataTransfer rather than a real user-granted directory) — the code still proceeded to resolve and stat paths, letting a page probe the existence/type (metadata) of local filesystem paths it was never granted access to, which can be used to fingerprint the user/host. The fix adds an early guard in both getEntry and getFile: if (m_rootPath.isEmpty()) return completionCallback(Exception { NotFoundError, “Path does not exist” }); so no filesystem probing occurs without a valid root. The restored invariant is that DOMFileSystem resolves/stats paths only when it has a granted root, so a synthesized File cannot be used to enumerate local filesystem metadata. The regression test creates a temporary file, adds it to a file input via DataTransfer, and calls entry.file(), expecting the access to be denied (no file returned).

Attack Path

1. Synthesize a File via DataTransfer

Create a File with a crafted name, add it to a DataTransfer/file input so it exposes a FileSystemEntry with no granted root.

2. Call entry.file()/getEntry()

Invoke the Entries API, which pre-patch resolved and stat’d paths even though m_rootPath is empty.

3. Probe local paths

Use the metadata (existence/type) returned for resolved paths to test for local files.

4. Fingerprint the user

Infer host/user characteristics from which local files exist — an information leak.

Impact Assessment

A local-filesystem information disclosure / fingerprinting issue in the WebContent process with no memory corruption: a DataTransfer-synthesized File could be used to probe local path metadata without a granted root. Impact is privacy (host/user fingerprinting), reachable from ordinary web content with drag-drop.

Audit Directions

Root-less filesystem access

Audit DOMFileSystem and Entries API paths for operations that resolve/stat paths without first verifying a granted m_rootPath.

DataTransfer-synthesized files

Grep for FileSystemEntry creation from DataTransfer/file inputs and confirm they cannot reach real filesystem metadata.

Changed Functions

Function File Change Note
DOMFileSystem::getEntry Source/WebCore/Modules/entriesapi/DOMFileSystem.cpp modified Returns NotFoundError immediately when m_rootPath.isEmpty(), so no path resolution/stat occurs without a granted root.
DOMFileSystem::getFile Source/WebCore/Modules/entriesapi/DOMFileSystem.cpp modified Same early m_rootPath.isEmpty() guard before dispatching filesystem work.

Files Changed

Source Files

  • Source/WebCore/Modules/entriesapi/DOMFileSystem.cpp

Test Files

  • LayoutTests/http/tests/security/file-system-access-via-dataTransfer-expected.txt
  • LayoutTests/http/tests/security/file-system-access-via-dataTransfer.html

Regression Test

The fix ships this proof-of-concept / regression test:

diff --git a/LayoutTests/http/tests/security/file-system-access-via-dataTransfer-expected.txt b/LayoutTests/http/tests/security/file-system-access-via-dataTransfer-expected.txt
new file mode 100644
index 000000000000..641ddaebab3e
--- /dev/null
+++ b/LayoutTests/http/tests/security/file-system-access-via-dataTransfer-expected.txt
@@ -0,0 +1,10 @@
+Test that accessing local file system metadata is not allowed
+
+On success, you will see a series of "PASS" messages, followed by "TEST COMPLETE".
+
+
+PASS Should not receive file
+PASS successfullyParsed is true
+
+TEST COMPLETE
+
diff --git a/LayoutTests/http/tests/security/file-system-access-via-dataTransfer.html b/LayoutTests/http/tests/security/file-system-access-via-dataTransfer.html
new file mode 100644
index 000000000000..caf05e1f85fc
--- /dev/null
+++ b/LayoutTests/http/tests/security/file-system-access-via-dataTransfer.html
@@ -0,0 +1,50 @@
+<html>
+<head>
+<script src="/js-test-resources/js-test.js"></script>
+<body>
+
+<script>
+description("Test that accessing local file system metadata is not allowed");
+
+function runTest() {
+    if (!window.internals) {
+        alert("This test depends on Internals");
+        return;
+    }
+
+    window.jsTestIsAsync = true;
+
+    let path = location.pathname.split("/");
+    let targetFileName = internals.createTemporaryFile(`${path[path.length - 1]}`, "");
+
+    let input = document.createElement("input");
+    input.type = "file";
+
+    let file = new File([], targetFileName, {"type":"text/plain"});
+
+    dataTransfer = new DataTransfer();
+    dataTransfer.items.add(file)
+    input.files = dataTransfer.files;
+
+    var functionOnSuccess = function (file)
+    {
+        testFailed("Should not receive file");
+        finishJSTest()
+    }
+
+    var functionOnError = function (value)
+    {
+        testPassed("Should not receive file");
+        finishJSTest()
+    }
+
+    input.webkitEntries.forEach((entry) => {
+        entry.file(functionOnSuccess, functionOnError)
+    });
+}
+
+runTest();
+
+</script>
+</body>
+</html>