Chrome · DevTools › DevTools
CVE-2026-113223
Memory Corruption in DevTools (DevTools)
Overview
Low
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fchrome/browser/devtools/devtools_file_helper_unittest.cc |
modified | |
forchrome/browser/devtools/devtools_file_helper_unittest.cc |
modified |
Files Changed
chrome/browser/devtools/devtools_file_helper_unittest.ccchrome/browser/devtools/devtools_file_helper.cc
Patch
From 2420cd155b4863b37dc1b48d5d346dcb0d8acc56 Mon Sep 17 00:00:00 2001
From: Wolfgang Beyer <wolfi@chromium.org>
Date: Thu, 17 Sep 2026 05:05:52 -0700
Subject: [PATCH] [DevTools] Block path traversal in "save as" dialog
Bug: 513735940
Change-Id: I2524ce6870c9c29697d3fc1ad1f4a3fc9df85cff
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8422481
Auto-Submit: Wolfgang Beyer <wolfi@chromium.org>
Commit-Queue: Wolfgang Beyer <wolfi@chromium.org>
Reviewed-by: Alex Rudenko <alexrudenko@chromium.org>
Commit-Queue: Alex Rudenko <alexrudenko@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1699701}
---
diff --git a/chrome/browser/devtools/devtools_file_helper.cc b/chrome/browser/devtools/devtools_file_helper.cc
index 3284853..cfdaaca 100644
--- a/chrome/browser/devtools/devtools_file_helper.cc
+++ b/chrome/browser/devtools/devtools_file_helper.cc
@@ -36,6 +36,7 @@
#include "content/public/browser/download_manager.h"
#include "content/public/common/content_client.h"
#include "crypto/obsolete/md5.h"
+#include "net/base/filename_util.h"
#include "ui/base/l10n/l10n_util.h"
#include "ui/shell_dialogs/selected_file_info.h"
#include "url/gurl.h"
@@ -166,30 +167,24 @@
}
if (initial_path.empty()) {
- GURL gurl(url);
- std::string suggested_file_name;
- if (gurl.is_valid()) {
- std::string escaped_content = gurl.ExtractFileName();
- // TODO(crbug.com/40839171): Due to filename encoding on Windows we can't
- // expect to always be able to convert to UTF8 and back
- suggested_file_name = url::DecodeUrlEscapeSequences(
- escaped_content, url::DecodeUrlMode::kUtf8OrIsomorphic);
- } else {
- suggested_file_name = url;
- }
- // TODO(crbug.com/40839171): Truncate a UTF8 string in a better way
- if (suggested_file_name.length() > 64) {
- suggested_file_name.erase(64);
- }
- // TODO(crbug.com/40839171): Ensure suggested_file_name is an ASCII string
+ // SECURITY: |url| is page-controlled. Derive the suggested filename via
+ // net::GenerateFileName(), which strips path separators, "..", BiDi
+ // control characters and shell-integrated extensions - the same
+ // sanitization the download stack applies. This prevents %2F / %5C
+ // traversal out of the default Save-As folder and U+202E filename
+ // spoofing.
+ base::FilePath suggested_file_name = net::GenerateFileName(
+ GURL(url), /*content_disposition=*/std::string(),
+ /*referrer_charset=*/std::string(), /*suggested_name=*/std::string(),
+ /*mime_type=*/std::string(), /*default_name=*/"download");
+
if (!GetLastSavePath().empty()) {
- initial_path =
- GetLastSavePath().DirName().AppendASCII(suggested_file_name);
+ initial_path = GetLastSavePath().DirName().Append(suggested_file_name);
} else {
base::FilePath download_path =
DownloadPrefs::FromDownloadManager(profile_->GetDownloadManager())
->DownloadPath();
- initial_path = download_path.AppendASCII(suggested_file_name);
+ initial_path = download_path.Append(suggested_file_name);
}
}
diff --git a/chrome/browser/devtools/devtools_file_helper_unittest.cc b/chrome/browser/devtools/devtools_file_helper_unittest.cc
index 75b99061..5ca4a97f 100644
--- a/chrome/browser/devtools/devtools_file_helper_unittest.cc
+++ b/chrome/browser/devtools/devtools_file_helper_unittest.cc
@@ -103,6 +103,40 @@
});
}
+ // Runs Save() for `url` and returns the path that would be pre-filled in the
+ // "Save as" dialog. The selection is canceled, so nothing is written to disk.
+ base::FilePath GetSuggestedSavePath(const std::string& url) {
+ base::FilePath suggested_path;
+ base::test::TestFuture<void> canceled;
+ file_helper()->Save(
+ url, "some text", /* save_as */ true,
+ /* is_base64 */ false,
+ base::BindLambdaForTesting(
+ [&](DevToolsFileHelper::SelectedCallback,
+ DevToolsFileHelper::CanceledCallback canceled_callback,
+ const base::FilePath& initial_path) {
+ suggested_path = initial_path;
+ std::move(canceled_callback).Run();
+ }),
+ base::DoNothing(), canceled.GetCallback());
+ EXPECT_TRUE(canceled.Wait());
+ return suggested_path;
+ }
+
+ // Saves to `path`, which makes it the directory against which subsequent
+ // suggested save paths are resolved. DevToolsFileHelper keeps the last save
+ // path in a process-global, so tests that care about the directory must
+ // prime it explicitly rather than rely on the order tests run in.
+ void PrimeLastSaveDirectory(const base::FilePath& path) {
+ base::test::TestFuture<const std::string&> saved;
+ file_helper()->Save("https://example.com/primer.txt", "primer",
+ /* save_as */ true,
+ /* is_base64 */ false,
+ FakeSelectFileCallback(ui::SelectedFileInfo(path)),
+ saved.GetCallback(), base::DoNothing());
+ EXPECT_TRUE(saved.Wait());
+ }
+
void ConnectAutomaticFileSystem(const base::FilePath& path,
const base::Uuid& uuid,
bool already_known,
@@ -253,6 +287,66 @@
EXPECT_EQ(base::ReadFileToBytes(tf.path()), data);
}
+TEST_F(DevToolsFileHelperTest, SuggestedSavePathUsesLastSaveDirectory) {
+ base::ScopedTempFile tf;
+ ASSERT_TRUE(tf.Create());
+ PrimeLastSaveDirectory(tf.path());
+
+ EXPECT_EQ(GetSuggestedSavePath("https://example.com/script.js"),
+ tf.path().DirName().Append(FILE_PATH_LITERAL("script.js")));
+}
+
+TEST_F(DevToolsFileHelperTest, SuggestedSavePathFallsBackToDefaultName) {
+ base::ScopedTempFile tf;
+ ASSERT_TRUE(tf.Create());
+ PrimeLastSaveDirectory(tf.path());
+ const base::FilePath dir = tf.path().DirName();
+
+ // URLs without a usable file name must fall back to the default name,
+ // rather than turning the raw URL into a file name.
+ for (const char* url : {"https://example.com/", "not a url", ""}) {
+ EXPECT_EQ(GetSuggestedSavePath(url),
+ dir.Append(FILE_PATH_LITERAL("download")))
+ << url;
+ }
+}
+
+// Regression test: `url` is page-controlled, so the file name derived from it
+// must never be able to escape the target directory, no matter how the
+// separators and parent references are escaped.
+TEST_F(DevToolsFileHelperTest, SuggestedSavePathCannotEscapeDirectory) {
+ base::ScopedTempFile tf;
+ ASSERT_TRUE(tf.Create());
+ PrimeLastSaveDirectory(tf.path());
+ const base::FilePath dir = tf.path().DirName();
+
+ for (const char* url : {
+ "https://example.com/a%2F..%2F..%2Fevil.sh",
+ "https://example.com/%2e%2e%2f%2e%2e%2fevil.sh",
+ "https://example.com/a%5C..%5C..%5Cevil.sh",
+ "https://example.com/%2Fetc%2Fpasswd",
+ }) {
+ const base::FilePath suggested = GetSuggestedSavePath(url);
+ EXPECT_FALSE(suggested.ReferencesParent()) << url;
+ EXPECT_EQ(suggested.DirName(), dir) << url;
+ }
+}
+
+// Regression test: the suggested file name must not carry BiDi control
+// characters, which can be used to spoof the extension shown in the dialog.
+TEST_F(DevToolsFileHelperTest, SuggestedSavePathStripsBidiControlCharacters) {
+ base::ScopedTempFile tf;
+ ASSERT_TRUE(tf.Create());
+ PrimeLastSaveDirectory(tf.path());
+
+ const base::FilePath suggested =
+ GetSuggestedSavePath("https://example.com/report%E2%80%AEfdp.exe");
+
+ EXPECT_EQ(suggested.value().find(FILE_PATH_LITERAL("\u202e")),
+ base::FilePath::StringType::npos);
+ EXPECT_EQ(suggested.DirName(), tf.path().DirName());
+}
+
#if BUILDFLAG(IS_ANDROID)
TEST_F(DevToolsFileHelperTest, SaveToFileContentUri) {
base::ScopedTempFile tf;
Loading diff…
Regression Test / PoC
shipped with the fix
diff --git a/chrome/browser/devtools/devtools_file_helper_unittest.cc b/chrome/browser/devtools/devtools_file_helper_unittest.cc
index 75b99061..5ca4a97f 100644
--- a/chrome/browser/devtools/devtools_file_helper_unittest.cc
+++ b/chrome/browser/devtools/devtools_file_helper_unittest.cc
@@ -103,6 +103,40 @@
});
}
+ // Runs Save() for `url` and returns the path that would be pre-filled in the
+ // "Save as" dialog. The selection is canceled, so nothing is written to disk.
+ base::FilePath GetSuggestedSavePath(const std::string& url) {
+ base::FilePath suggested_path;
+ base::test::TestFuture<void> canceled;
+ file_helper()->Save(
+ url, "some text", /* save_as */ true,
+ /* is_base64 */ false,
+ base::BindLambdaForTesting(
+ [&](DevToolsFileHelper::SelectedCallback,
+ DevToolsFileHelper::CanceledCallback canceled_callback,
+ const base::FilePath& initial_path) {
+ suggested_path = initial_path;
+ std::move(canceled_callback).Run();
+ }),
+ base::DoNothing(), canceled.GetCallback());
+ EXPECT_TRUE(canceled.Wait());
+ return suggested_path;
+ }
+
+ // Saves to `path`, which makes it the directory against which subsequent
+ // suggested save paths are resolved. DevToolsFileHelper keeps the last save
+ // path in a process-global, so tests that care about the directory must
+ // prime it explicitly rather than rely on the order tests run in.
+ void PrimeLastSaveDirectory(const base::FilePath& path) {
+ base::test::TestFuture<const std::string&> saved;
+ file_helper()->Save("https://example.com/primer.txt", "primer",
+ /* save_as */ true,
+ /* is_base64 */ false,
+ FakeSelectFileCallback(ui::SelectedFileInfo(path)),
+ saved.GetCallback(), base::DoNothing());
+ EXPECT_TRUE(saved.Wait());
+ }
+
void ConnectAutomaticFileSystem(const base::FilePath& path,
const base::Uuid& uuid,
bool already_known,
@@ -253,6 +287,66 @@
EXPECT_EQ(base::ReadFileToBytes(tf.path()), data);
}
+TEST_F(DevToolsFileHelperTest, SuggestedSavePathUsesLastSaveDirectory) {
+ base::ScopedTempFile tf;
+ ASSERT_TRUE(tf.Create());
+ PrimeLastSaveDirectory(tf.path());
+
+ EXPECT_EQ(GetSuggestedSavePath("https://example.com/script.js"),
+ tf.path().DirName().Append(FILE_PATH_LITERAL("script.js")));
+}
+
+TEST_F(DevToolsFileHelperTest, SuggestedSavePathFallsBackToDefaultName) {
+ base::ScopedTempFile tf;
+ ASSERT_TRUE(tf.Create());
+ PrimeLastSaveDirectory(tf.path());
+ const base::FilePath dir = tf.path().DirName();
+
+ // URLs without a usable file name must fall back to the default name,
+ // rather than turning the raw URL into a file name.
+ for (const char* url : {"https://example.com/", "not a url", ""}) {
+ EXPECT_EQ(GetSuggestedSavePath(url),
+ dir.Append(FILE_PATH_LITERAL("download")))
+ << url;
+ }
+}
+
+// Regression test: `url` is page-controlled, so the file name derived from it
+// must never be able to escape the target directory, no matter how the
+// separators and parent references are escaped.
+TEST_F(DevToolsFileHelperTest, SuggestedSavePathCannotEscapeDirectory) {
+ base::ScopedTempFile tf;
+ ASSERT_TRUE(tf.Create());
+ PrimeLastSaveDirectory(tf.path());
+ const base::FilePath dir = tf.path().DirName();
+
+ for (const char* url : {
+ "https://example.com/a%2F..%2F..%2Fevil.sh",
+ "https://example.com/%2e%2e%2f%2e%2e%2fevil.sh",
+ "https://example.com/a%5C..%5C..%5Cevil.sh",
+ "https://example.com/%2Fetc%2Fpasswd",
+ }) {
+ const base::FilePath suggested = GetSuggestedSavePath(url);
+ EXPECT_FALSE(suggested.ReferencesParent()) << url;
+ EXPECT_EQ(suggested.DirName(), dir) << url;
+ }
+}
+
+// Regression test: the suggested file name must not carry BiDi control
+// characters, which can be used to spoof the extension shown in the dialog.
+TEST_F(DevToolsFileHelperTest, SuggestedSavePathStripsBidiControlCharacters) {
+ base::ScopedTempFile tf;
+ ASSERT_TRUE(tf.Create());
+ PrimeLastSaveDirectory(tf.path());
+
+ const base::FilePath suggested =
+ GetSuggestedSavePath("https://example.com/report%E2%80%AEfdp.exe");
+
+ EXPECT_EQ(suggested.value().find(FILE_PATH_LITERAL("\u202e")),
+ base::FilePath::StringType::npos);
+ EXPECT_EQ(suggested.DirName(), tf.path().DirName());
+}
+
#if BUILDFLAG(IS_ANDROID)
TEST_F(DevToolsFileHelperTest, SaveToFileContentUri) {
base::ScopedTempFile tf;
Loading diff…
Original Bug Report
The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.
References
On This Page