Overview

Low
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in New Tab Page
DescriptionInsufficient validation of untrusted input in New Tab Page
ComponentNew Tab Page
Bug ClassLogic Error
Tracker497241148
Fix commite0fd6c072f0b (chromium/src) +91/-4
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-30

Changed Functions

FunctionChangeNotes
if
chrome/browser/ui/webui/new_tab_page/untrusted_source.cc
modified
TEST_F
chrome/browser/ui/webui/new_tab_page/untrusted_source_unittest.cc
modified

Files Changed

  • chrome/browser/ui/webui/new_tab_page/untrusted_source.cc
  • chrome/browser/ui/webui/new_tab_page/untrusted_source_unittest.cc
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();
+}
Loading diff…

Regression Test / PoC

shipped with the fix
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();
+}
Loading diff…

Original Bug Report

reported by vm...@google.com

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.

  1. An attacker compromises the chrome-untrusted://new-tab-page renderer (e.g., via XSS or a sandbox escape from an iframe). Chrome’s threat model assumes untrusted WebUIs can be compromised.
  2. The compromised renderer executes a fetch request: fetch('chrome-untrusted://new-tab-page/Extensions/<extension-id>/<version>/background.jpg').
  3. The request is routed to UntrustedSource in the browser process.
  4. ShouldServiceRequest allows the request because the path contains background.jpg.
  5. StartDataRequest processes the request because the path ends with background.jpg.
  6. The browser reads the requested file from the extension’s installation directory inside the profile and returns its contents in the HTTP response.
  7. 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.

View on issue tracker