CVE-2026-14038
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/ui/webui/new_tab_page/untrusted_source.cc |
modified | |
TEST_Fchrome/browser/ui/webui/new_tab_page/untrusted_source_unittest.cc |
modified |
Files Changed
chrome/browser/ui/webui/new_tab_page/untrusted_source.ccchrome/browser/ui/webui/new_tab_page/untrusted_source_unittest.cc
Patch
From e0fd6c072f0bdae225c436539b9a5986720d63e0 Mon Sep 17 00:00:00 2001
From: Andrew Paseltiner <apaseltiner@chromium.org>
Date: Wed, 06 May 2026 19:46:02 -0700
Subject: [PATCH] Fix directory traversal in UntrustedSource background image requests
Tighten path validation for background image requests in UntrustedSource
by only allowing 'background.jpg' or a 32-character hex token followed
by 'background.jpg'. This prevents malicious directory traversal into
subdirectories within the Chrome profile.
Fixed: 497241148
Change-Id: I90babc687054dbf8057ae2b4fa15bf33df7db982
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7816317
Commit-Queue: Andrew Paseltiner <apaseltiner@chromium.org>
Reviewed-by: Riley Tatum <rtatum@google.com>
Cr-Commit-Position: refs/heads/main@{#1626675}
---
diff --git a/chrome/browser/ui/webui/new_tab_page/untrusted_source.cc b/chrome/browser/ui/webui/new_tab_page/untrusted_source.cc
index 2b305af..26d598dd 100644
--- a/chrome/browser/ui/webui/new_tab_page/untrusted_source.cc
+++ b/chrome/browser/ui/webui/new_tab_page/untrusted_source.cc
@@ -23,6 +23,7 @@
#include "base/task/task_traits.h"
#include "base/task/thread_pool.h"
#include "base/time/time.h"
+#include "base/token.h"
#include "chrome/browser/new_tab_page/one_google_bar/one_google_bar_data.h"
#include "chrome/browser/new_tab_page/one_google_bar/one_google_bar_service_factory.h"
#include "chrome/browser/policy/chrome_policy_blocklist_service_factory.h"
@@ -90,6 +91,21 @@
return csv.substr(1);
}
+// Validates that the background image path is either exactly "background.jpg"
+// or a 32-character hex token followed by "background.jpg". This prevents
+// directory traversal (crbug.com/497241148). The analogous serialization
+// logic can be found in
+// `NtpCustomBackgroundService::SetBackgroundToLocalResourceWithId()` and
+// `WallpaperSearchBackgroundManager::SelectLocalBackgroundImage()`.
+bool IsValidBackgroundImagePath(std::string_view path) {
+ std::optional<std::string_view> prefix =
+ base::RemoveSuffix(path, "background.jpg");
+ if (!prefix) {
+ return false;
+ }
+ return prefix->empty() || base::Token::FromString(*prefix).has_value();
+}
+
std::map<std::string, std::string> ExtractQueryParams(
std::string_view query_params) {
std::map<std::string, std::string> params;
@@ -260,7 +276,7 @@
IDR_NEW_TAB_PAGE_UNTRUSTED_BACKGROUND_IMAGE_JS));
return;
}
- if (base::EndsWith(path, "background.jpg")) {
+ if (IsValidBackgroundImagePath(path)) {
base::ThreadPool::PostTaskAndReplyWithResult(
FROM_HERE, {base::TaskPriority::USER_VISIBLE, base::MayBlock()},
base::BindOnce(&ReadBackgroundImageData,
@@ -308,7 +324,7 @@
return path == "one-google-bar" || path == "one_google_bar.js" ||
path == "one_google_bar_api.js" || path == "image" ||
path == "background_image" || path == "custom_background_image" ||
- path == "background_image.js" || path.contains("background.jpg");
+ path == "background_image.js" || IsValidBackgroundImagePath(path);
}
void UntrustedSource::OnOneGoogleBarDataUpdated() {
diff --git a/chrome/browser/ui/webui/new_tab_page/untrusted_source_unittest.cc b/chrome/browser/ui/webui/new_tab_page/untrusted_source_unittest.cc
index 152a8b8..a0238756 100644
--- a/chrome/browser/ui/webui/new_tab_page/untrusted_source_unittest.cc
+++ b/chrome/browser/ui/webui/new_tab_page/untrusted_source_unittest.cc
@@ -9,9 +9,9 @@
#include <string>
#include "base/base64.h"
-#include "base/functional/bind.h"
+#include "base/files/file_util.h"
#include "base/memory/ref_counted_memory.h"
-#include "base/strings/strcat.h"
+#include "base/run_loop.h"
#include "base/test/bind.h"
#include "base/test/mock_callback.h"
#include "base/test/scoped_feature_list.h"
@@ -163,3 +163,74 @@
ASSERT_EQ(one_google_bar_service()->additional_query_params().at("async"),
"fixed:0");
}
+
+TEST_F(UntrustedSourceTest, BackgroundRequest_Valid) {
+ static constexpr uint8_t kData[] = {1, 2, 3, 4};
+ ASSERT_TRUE(base::WriteFile(profile_->GetPath().AppendASCII("background.jpg"),
+ kData));
+
+ {
+ base::RunLoop run_loop;
+ base::MockCallback<content::URLDataSource::GotDataCallback> callback;
+ EXPECT_CALL(callback, Run(testing::_))
+ .Times(1)
+ .WillOnce([&](scoped_refptr<base::RefCountedMemory> memory) {
+ EXPECT_EQ(base::span(kData), base::span(*memory));
+ run_loop.Quit();
+ });
+
+ untrusted_source_->StartDataRequest(
+ GURL("chrome-untrusted://new-tab-page/background.jpg"),
+ test_web_contents_getter_, callback.Get());
+ run_loop.Run();
+ }
+
+ // Verify that a valid token-prefixed URL also works.
+ static constexpr char kToken[] = "1234567890ABCDEF1234567890ABCDEF";
+ base::FilePath token_background_img =
+ profile_->GetPath().AppendASCII(std::string(kToken) + "background.jpg");
+ static constexpr uint8_t kTokenData[] = {5, 6, 7, 8};
+ ASSERT_TRUE(base::WriteFile(token_background_img, kTokenData));
+
+ {
+ base::RunLoop run_loop;
+ base::MockCallback<content::URLDataSource::GotDataCallback> callback;
+ EXPECT_CALL(callback, Run(testing::_))
+ .Times(1)
+ .WillOnce([&](scoped_refptr<base::RefCountedMemory> memory) {
+ EXPECT_EQ(base::span(kTokenData), base::span(*memory));
+ run_loop.Quit();
+ });
+
+ untrusted_source_->StartDataRequest(
+ GURL(std::string("chrome-untrusted://new-tab-page/") + kToken +
+ "background.jpg"),
+ test_web_contents_getter_, callback.Get());
+ run_loop.Run();
+ }
+}
+
+// Verifies that directory traversal is disallowed (crbug.com/497241148).
+TEST_F(UntrustedSourceTest, BackgroundRequest_DirectoryTraversal) {
+ base::FilePath extensions_dir =
+ profile_->GetPath().AppendASCII("12345678901234567890123456789012");
+ ASSERT_TRUE(base::CreateDirectory(extensions_dir));
+ base::FilePath background_img = extensions_dir.AppendASCII("background.jpg");
+ static constexpr uint8_t kData[] = {1, 2, 3, 4};
+ ASSERT_TRUE(base::WriteFile(background_img, kData));
+
+ base::RunLoop run_loop;
+ base::MockCallback<content::URLDataSource::GotDataCallback> callback;
+ EXPECT_CALL(callback, Run(testing::_))
+ .Times(1)
+ .WillOnce([&](scoped_refptr<base::RefCountedMemory> memory) {
+ EXPECT_TRUE(base::span(*memory).empty());
+ run_loop.Quit();
+ });
+
+ untrusted_source_->StartDataRequest(
+ GURL("chrome-untrusted://new-tab-page/12345678901234567890123456789012/"
+ "background.jpg"),
+ test_web_contents_getter_, callback.Get());
+ run_loop.Run();
+}
Regression Test / PoC
diff --git a/chrome/browser/ui/webui/new_tab_page/untrusted_source_unittest.cc b/chrome/browser/ui/webui/new_tab_page/untrusted_source_unittest.cc
index 152a8b8..a0238756 100644
--- a/chrome/browser/ui/webui/new_tab_page/untrusted_source_unittest.cc
+++ b/chrome/browser/ui/webui/new_tab_page/untrusted_source_unittest.cc
@@ -9,9 +9,9 @@
#include <string>
#include "base/base64.h"
-#include "base/functional/bind.h"
+#include "base/files/file_util.h"
#include "base/memory/ref_counted_memory.h"
-#include "base/strings/strcat.h"
+#include "base/run_loop.h"
#include "base/test/bind.h"
#include "base/test/mock_callback.h"
#include "base/test/scoped_feature_list.h"
@@ -163,3 +163,74 @@
ASSERT_EQ(one_google_bar_service()->additional_query_params().at("async"),
"fixed:0");
}
+
+TEST_F(UntrustedSourceTest, BackgroundRequest_Valid) {
+ static constexpr uint8_t kData[] = {1, 2, 3, 4};
+ ASSERT_TRUE(base::WriteFile(profile_->GetPath().AppendASCII("background.jpg"),
+ kData));
+
+ {
+ base::RunLoop run_loop;
+ base::MockCallback<content::URLDataSource::GotDataCallback> callback;
+ EXPECT_CALL(callback, Run(testing::_))
+ .Times(1)
+ .WillOnce([&](scoped_refptr<base::RefCountedMemory> memory) {
+ EXPECT_EQ(base::span(kData), base::span(*memory));
+ run_loop.Quit();
+ });
+
+ untrusted_source_->StartDataRequest(
+ GURL("chrome-untrusted://new-tab-page/background.jpg"),
+ test_web_contents_getter_, callback.Get());
+ run_loop.Run();
+ }
+
+ // Verify that a valid token-prefixed URL also works.
+ static constexpr char kToken[] = "1234567890ABCDEF1234567890ABCDEF";
+ base::FilePath token_background_img =
+ profile_->GetPath().AppendASCII(std::string(kToken) + "background.jpg");
+ static constexpr uint8_t kTokenData[] = {5, 6, 7, 8};
+ ASSERT_TRUE(base::WriteFile(token_background_img, kTokenData));
+
+ {
+ base::RunLoop run_loop;
+ base::MockCallback<content::URLDataSource::GotDataCallback> callback;
+ EXPECT_CALL(callback, Run(testing::_))
+ .Times(1)
+ .WillOnce([&](scoped_refptr<base::RefCountedMemory> memory) {
+ EXPECT_EQ(base::span(kTokenData), base::span(*memory));
+ run_loop.Quit();
+ });
+
+ untrusted_source_->StartDataRequest(
+ GURL(std::string("chrome-untrusted://new-tab-page/") + kToken +
+ "background.jpg"),
+ test_web_contents_getter_, callback.Get());
+ run_loop.Run();
+ }
+}
+
+// Verifies that directory traversal is disallowed (crbug.com/497241148).
+TEST_F(UntrustedSourceTest, BackgroundRequest_DirectoryTraversal) {
+ base::FilePath extensions_dir =
+ profile_->GetPath().AppendASCII("12345678901234567890123456789012");
+ ASSERT_TRUE(base::CreateDirectory(extensions_dir));
+ base::FilePath background_img = extensions_dir.AppendASCII("background.jpg");
+ static constexpr uint8_t kData[] = {1, 2, 3, 4};
+ ASSERT_TRUE(base::WriteFile(background_img, kData));
+
+ base::RunLoop run_loop;
+ base::MockCallback<content::URLDataSource::GotDataCallback> callback;
+ EXPECT_CALL(callback, Run(testing::_))
+ .Times(1)
+ .WillOnce([&](scoped_refptr<base::RefCountedMemory> memory) {
+ EXPECT_TRUE(base::span(*memory).empty());
+ run_loop.Quit();
+ });
+
+ untrusted_source_->StartDataRequest(
+ GURL("chrome-untrusted://new-tab-page/12345678901234567890123456789012/"
+ "background.jpg"),
+ test_web_contents_getter_, callback.Get());
+ run_loop.Run();
+}
Original Bug Report
Limited local file read / extension fingerprinting in UntrustedSource
Project Fortify, an experimental security project, has identified the following potential security issue.
Overview: A compromised chrome-untrusted://new-tab-page renderer can potentially read any file within the Chrome profile directory that ends with background.jpg. This is due to a permissive path validation check in UntrustedSource that fails to prevent directory traversal into subdirectories.
Affected files:
chrome/browser/ui/webui/new_tab_page/untrusted_source.cc
Estimated timestamp from git blame: 2026-01-07
Summary
The UntrustedSource class, which serves data for the chrome-untrusted://new-tab-page origin, contains a logic flaw in its path validation. It allows a compromised renderer to read any file inside the user’s profile directory whose name ends with background.jpg. By requesting paths containing subdirectories, an attacker can access extension assets (e.g., Extensions/<id>/<version>/background.jpg) to fingerprint installed extensions or read their contents, bypassing the random UUIDs (Web Accessible Resources) normally used to prevent fingerprinting.
Vulnerability Details
In chrome/browser/ui/webui/new_tab_page/untrusted_source.cc, the StartDataRequest method handles incoming requests for chrome-untrusted://new-tab-page. It extracts the path from the URL and validates it:
if (base::EndsWith(path, "background.jpg")) {
base::ThreadPool::PostTaskAndReplyWithResult(
FROM_HERE, {base::TaskPriority::USER_VISIBLE, base::MayBlock()},
base::BindOnce(&ReadBackgroundImageData,
profile_->GetPath().AppendASCII(path)),
base::BindOnce(&ServeBackgroundImageData, std::move(callback)));
return;
}
The ShouldServiceRequest gate in the same file uses an even weaker check:
return ... || path.contains("background.jpg");
The path variable is derived directly from the URL. While GURL canonicalization prevents parent-directory traversal (../) escaping the profile directory entirely, the EndsWith check does not restrict the use of forward slashes (/) to access subdirectories relative to the profile path.
When profile_->GetPath().AppendASCII(path) is called, it directly appends the attacker-controlled path (e.g., Extensions/<extension-id>/<version>/background.jpg) to the absolute profile directory path. The file is then read from disk and served to the renderer.
Potential Steps to Trigger
Note: These are suggested steps; our tooling has not executed a live proof-of-concept.
- An attacker compromises the
chrome-untrusted://new-tab-pagerenderer (e.g., via XSS or a sandbox escape from an iframe). Chrome’s threat model assumes untrusted WebUIs can be compromised. - The compromised renderer executes a
fetchrequest:fetch('chrome-untrusted://new-tab-page/Extensions/<extension-id>/<version>/background.jpg'). - The request is routed to
UntrustedSourcein the browser process. ShouldServiceRequestallows the request because the path containsbackground.jpg.StartDataRequestprocesses the request because the path ends withbackground.jpg.- The browser reads the requested file from the extension’s installation directory inside the profile and returns its contents in the HTTP response.
- The attacker reads the response. If data is returned, it confirms the extension is installed (fingerprinting) and leaks the file’s contents.
Proposed Fix
The path validation should be tightened to ensure the requested path does not contain directory separators.
For example, explicitly reject paths containing / or \:
if (base::EndsWith(path, "background.jpg") && path.find('/') == std::string::npos && path.find('\\') == std::string::npos) {
...
}
Alternatively, validate the path against a strict regular expression that matches the expected format of background image filenames (e.g., a 32-character hex token followed by _background.jpg).
Evaluated with Chrome root at commit: a9cbf6e8b275fe4147435aa905f3b7f5a656f5f0
Results from so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.