Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in WebUI
DescriptionInsufficient validation of untrusted input in WebUI
ComponentWebUI
Bug ClassLogic Error
Tracker523720529
Fix commit6a962f21d15a (chromium/src) +89/-6
CISA KEVNot listed
CreditedGoogle
Disclosed2026-07-29

Changed Functions

FunctionChangeNotes
TEST_F
chrome/browser/ui/webui/skills/skills_page_handler_unittest.cc
modified
for
chrome/browser/ui/webui/skills/skills_page_handler_unittest.cc
modified
if
chrome/browser/ui/webui/skills/skills_page_handler_unittest.cc
modified
for
components/skills/internal/skills_service_impl.cc
modified
TEST_F
components/skills/internal/skills_service_impl_unittest.cc
modified
for
components/skills/internal/skills_service_impl_unittest.cc
modified

Files Changed

  • chrome/browser/ui/webui/skills/skills_page_handler.cc
  • chrome/browser/ui/webui/skills/skills_page_handler_unittest.cc
  • chrome/browser/ui/webui/skills/skills_page_interactive_uitest.cc
  • components/skills/internal/skills_service_impl.cc
  • components/skills/internal/skills_service_impl_unittest.cc
  • components/skills/public/skills_service.cc
  • components/skills/public/skills_service.h
From 6a962f21d15a91f57cba0b06e5e30c92a8efc89d Mon Sep 17 00:00:00 2001
From: chrstne <chrstne@google.com>
Date: Tue, 23 Jun 2026 11:24:55 -0700
Subject: [PATCH] [Skills] Limit valid 1p skill image urls

- only allow http/s image urls from gstatic from showing in 1p skills
- only applies to skills v1 for now

Bug: b:523720529
Change-Id: I4f5bf6b992fb21f9bc2f2697380aea4cf697bd8f
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7986005
Reviewed-by: Aashna Sheth <aashnas@google.com>
Commit-Queue: Christine Ying <chrstne@google.com>
Cr-Commit-Position: refs/heads/main@{#1651135}
---

diff --git a/chrome/browser/ui/webui/skills/skills_page_handler.cc b/chrome/browser/ui/webui/skills/skills_page_handler.cc
index c29a5c1f..b28ba9c5 100644
--- a/chrome/browser/ui/webui/skills/skills_page_handler.cc
+++ b/chrome/browser/ui/webui/skills/skills_page_handler.cc
@@ -40,8 +40,12 @@
     translated_skill.prompt = skill.prompt();
     translated_skill.description = skill.description();
     translated_skill.curated_by = skill.curated_by();
-    translated_skill.image_url = GURL(skill.image_url());
     translated_skill.source = sync_pb::SkillSource::SKILL_SOURCE_FIRST_PARTY;
+
+    GURL image_url(skill.image_url());
+    if (SkillsService::IsValidSkillImageUrl(image_url)) {
+      translated_skill.image_url = std::move(image_url);
+    }
     translated_map[skill.category()].push_back(std::move(translated_skill));
   }
   return translated_map;
diff --git a/chrome/browser/ui/webui/skills/skills_page_handler_unittest.cc b/chrome/browser/ui/webui/skills/skills_page_handler_unittest.cc
index 086832c..2f10940 100644
--- a/chrome/browser/ui/webui/skills/skills_page_handler_unittest.cc
+++ b/chrome/browser/ui/webui/skills/skills_page_handler_unittest.cc
@@ -103,7 +103,7 @@
   skill_proto.set_prompt("Skill prompt");
   skill_proto.set_category("Category");
   skill_proto.set_description("Skill description");
-  skill_proto.set_image_url("https://example.com/image.png");
+  skill_proto.set_image_url("https://gstatic.com/image.png");
 
   first_party_skill_data->skills_list.push_back(skill_proto);
 
@@ -125,7 +125,7 @@
         EXPECT_EQ("icon", skill.icon);
         EXPECT_EQ("Skill prompt", skill.prompt);
         EXPECT_EQ("Skill description", skill.description);
-        EXPECT_EQ("https://example.com/image.png", skill.image_url);
+        EXPECT_EQ("https://gstatic.com/image.png", skill.image_url);
         EXPECT_EQ(sync_pb::SkillSource::SKILL_SOURCE_FIRST_PARTY, skill.source);
 
         ASSERT_EQ(1u, state->topics_info_list.size());
@@ -263,5 +263,43 @@
   run_loop.Run();
 }
 
+TEST_F(SkillsPageHandlerTest, 1pSkills_OnlyShowAcceptsHttpsImageUrls) {
+  auto first_party_skill_data = std::make_unique<FirstPartySkillData>();
+  const std::vector<std::pair<std::string, std::string>> kCases = {
+      {"invalid_https_id", "https://example.com/image.png"},
+      {"http_id", "http://gstatic.com/image.png"},
+      {"https_id", "https://gstatic.com/image.png"},
+      {"data_id", "data:image/png;base64,iVBORw0KGgo="},
+      {"empty_id", ""},
+  };
+
+  for (const auto& [id, image_url] : kCases) {
+    skills::proto::Skill skill_proto;
+    skill_proto.set_id(id);
+    skill_proto.set_name("Skill Name");
+    skill_proto.set_category("Category");
+    skill_proto.set_image_url(image_url);
+    first_party_skill_data->skills_list.push_back(skill_proto);
+  }
+
+  base::RunLoop run_loop;
+  EXPECT_CALL(mock_page_, Update1PSkills(_))
+      .WillOnce([&run_loop](mojom::BrowseSkillsInitialStatePtr state) {
+        ASSERT_TRUE(state->skill_map.contains("Category"));
+        const auto& skills = state->skill_map.at("Category");
+        for (const auto& skill : skills) {
+          if (skill.id == "http_id" || skill.id == "https_id") {
+            EXPECT_FALSE(skill.image_url.is_empty());
+          } else {
+            EXPECT_TRUE(skill.image_url.is_empty());
+          }
+        }
+        run_loop.Quit();
+      });
+
+  handler_->OnDiscoverySkillsUpdated(first_party_skill_data.get());
+  run_loop.Run();
+}
+
 }  // namespace
 }  // namespace skills
diff --git a/chrome/browser/ui/webui/skills/skills_page_interactive_uitest.cc b/chrome/browser/ui/webui/skills/skills_page_interactive_uitest.cc
index 6eea83dc..0061490c 100644
--- a/chrome/browser/ui/webui/skills/skills_page_interactive_uitest.cc
+++ b/chrome/browser/ui/webui/skills/skills_page_interactive_uitest.cc
@@ -396,7 +396,7 @@
   skill->set_description("Look for some socks");
   skill->set_icon("🧦");
   skill->set_prompt("Look for some socks");
-  skill->set_image_url("https://example.com/image.png");
+  skill->set_image_url("https://gstatic.com/image.png");
 
   skills::proto::Skill* skill2 = skills_list.add_skills();
   skill2->set_id("345");
@@ -447,7 +447,7 @@
   skill->set_description("Look for some socks");
   skill->set_icon("🧦");
   skill->set_prompt("Look for some socks");
-  skill->set_image_url("https://example.com/image.png");
+  skill->set_image_url("https://gstatic.com/image.png");
 
   skills::proto::Skill* skill2 = skills_list.add_skills();
   skill2->set_id("345");
diff --git a/components/skills/internal/skills_service_impl.cc b/components/skills/internal/skills_service_impl.cc
index 62ed9a45..0536b049 100644
--- a/components/skills/internal/skills_service_impl.cc
+++ b/components/skills/internal/skills_service_impl.cc
@@ -336,9 +336,13 @@
     first_party_skill_objects_map_.reserve(
         first_party_data_.skills_list.size());
     for (const auto& proto_skill : first_party_data_.skills_list) {
+      GURL image_url(proto_skill.image_url());
+      if (!SkillsService::IsValidSkillImageUrl(image_url)) {
+        image_url = GURL();
+      }
       Skill skill(proto_skill.id(), proto_skill.name(), proto_skill.icon(),
                   proto_skill.prompt(), proto_skill.description(),
-                  proto_skill.curated_by(), GURL(proto_skill.image_url()),
+                  proto_skill.curated_by(), image_url,
                   sync_pb::SkillSource::SKILL_SOURCE_FIRST_PARTY);
       first_party_skill_objects_map_.insert(
           {proto_skill.id(), std::move(skill)});
diff --git a/components/skills/internal/skills_service_impl_unittest.cc b/components/skills/internal/skills_service_impl_unittest.cc
index 663f329..c45db9c 100644
--- a/components/skills/internal/skills_service_impl_unittest.cc
+++ b/components/skills/internal/skills_service_impl_unittest.cc
@@ -692,5 +692,33 @@
                           Pointee(HasSkill("Name B", "icon", "prompt", ""))));
 }
 
+TEST_F(SkillsServiceImplTest, Handle1pSkills_OnlyAcceptsHttpsImageUrls) {
+  InitService();
+  auto first_party_skill_data = std::make_unique<FirstPartySkillData>();
+  const std::vector<std::pair<std::string, std::string>> kCases = {
+      {"invalid_https_id", "https://example.com/image.png"},
+      {"https_id", "https://gstatic.com/image.png"},
+      {"data_id", "data:image/png;base64,iVBORw0KGgo="},
+      {"empty_id", ""},
+  };
+  for (const auto& [id, image_url] : kCases) {
+    skills::proto::Skill proto_skill;
+    proto_skill.set_id(id);
+    proto_skill.set_name("name");
+    proto_skill.set_image_url(image_url);
+    first_party_skill_data->skills_list.push_back(proto_skill);
+  }
+
+  service().Handle1pSkills(std::move(first_party_skill_data));
+
+  const Skill* https_skill = service().GetSkillById("https_id");
+  EXPECT_EQ(GURL("https://gstatic.com/image.png"), https_skill->image_url);
+
+  for (const char* id : {"invalid_https_id", "data_id", "empty_id"}) {
+    const Skill* skill = service().GetSkillById(id);
+    EXPECT_TRUE(skill->image_url.is_empty());
+  }
+}
+
 }  // namespace
 }  // namespace skills
diff --git a/components/skills/public/skills_service.cc b/components/skills/public/skills_service.cc
index cb4b4448..165977c 100644
--- a/components/skills/public/skills_service.cc
+++ b/components/skills/public/skills_service.cc
@@ -14,4 +14,10 @@
 
 SkillsService::~SkillsService() = default;
 
+// static
+bool SkillsService::IsValidSkillImageUrl(const GURL& gurl) {
+  return gurl.is_valid() && gurl.DomainIs("gstatic.com") &&
+         (gurl.SchemeIs(url::kHttpsScheme) || gurl.SchemeIs(url::kHttpScheme));
+}
+
 }  // namespace skills
diff --git a/components/skills/public/skills_service.h b/components/skills/public/skills_service.h
index 70752540..edfacb7 100644
--- a/components/skills/public/skills_service.h
+++ b/components/skills/public/skills_service.h
@@ -212,6 +212,9 @@
   // Notify that a glic panel associated with the skills service is being
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/ui/webui/skills/skills_page_handler_unittest.cc b/chrome/browser/ui/webui/skills/skills_page_handler_unittest.cc
index 086832c..2f10940 100644
--- a/chrome/browser/ui/webui/skills/skills_page_handler_unittest.cc
+++ b/chrome/browser/ui/webui/skills/skills_page_handler_unittest.cc
@@ -103,7 +103,7 @@
   skill_proto.set_prompt("Skill prompt");
   skill_proto.set_category("Category");
   skill_proto.set_description("Skill description");
-  skill_proto.set_image_url("https://example.com/image.png");
+  skill_proto.set_image_url("https://gstatic.com/image.png");
 
   first_party_skill_data->skills_list.push_back(skill_proto);
 
@@ -125,7 +125,7 @@
         EXPECT_EQ("icon", skill.icon);
         EXPECT_EQ("Skill prompt", skill.prompt);
         EXPECT_EQ("Skill description", skill.description);
-        EXPECT_EQ("https://example.com/image.png", skill.image_url);
+        EXPECT_EQ("https://gstatic.com/image.png", skill.image_url);
         EXPECT_EQ(sync_pb::SkillSource::SKILL_SOURCE_FIRST_PARTY, skill.source);
 
         ASSERT_EQ(1u, state->topics_info_list.size());
@@ -263,5 +263,43 @@
   run_loop.Run();
 }
 
+TEST_F(SkillsPageHandlerTest, 1pSkills_OnlyShowAcceptsHttpsImageUrls) {
+  auto first_party_skill_data = std::make_unique<FirstPartySkillData>();
+  const std::vector<std::pair<std::string, std::string>> kCases = {
+      {"invalid_https_id", "https://example.com/image.png"},
+      {"http_id", "http://gstatic.com/image.png"},
+      {"https_id", "https://gstatic.com/image.png"},
+      {"data_id", "data:image/png;base64,iVBORw0KGgo="},
+      {"empty_id", ""},
+  };
+
+  for (const auto& [id, image_url] : kCases) {
+    skills::proto::Skill skill_proto;
+    skill_proto.set_id(id);
+    skill_proto.set_name("Skill Name");
+    skill_proto.set_category("Category");
+    skill_proto.set_image_url(image_url);
+    first_party_skill_data->skills_list.push_back(skill_proto);
+  }
+
+  base::RunLoop run_loop;
+  EXPECT_CALL(mock_page_, Update1PSkills(_))
+      .WillOnce([&run_loop](mojom::BrowseSkillsInitialStatePtr state) {
+        ASSERT_TRUE(state->skill_map.contains("Category"));
+        const auto& skills = state->skill_map.at("Category");
+        for (const auto& skill : skills) {
+          if (skill.id == "http_id" || skill.id == "https_id") {
+            EXPECT_FALSE(skill.image_url.is_empty());
+          } else {
+            EXPECT_TRUE(skill.image_url.is_empty());
+          }
+        }
+        run_loop.Quit();
+      });
+
+  handler_->OnDiscoverySkillsUpdated(first_party_skill_data.get());
+  run_loop.Run();
+}
+
 }  // namespace
 }  // namespace skills
diff --git a/chrome/browser/ui/webui/skills/skills_page_interactive_uitest.cc b/chrome/browser/ui/webui/skills/skills_page_interactive_uitest.cc
index 6eea83dc..0061490c 100644
--- a/chrome/browser/ui/webui/skills/skills_page_interactive_uitest.cc
+++ b/chrome/browser/ui/webui/skills/skills_page_interactive_uitest.cc
@@ -396,7 +396,7 @@
   skill->set_description("Look for some socks");
   skill->set_icon("🧦");
   skill->set_prompt("Look for some socks");
-  skill->set_image_url("https://example.com/image.png");
+  skill->set_image_url("https://gstatic.com/image.png");
 
   skills::proto::Skill* skill2 = skills_list.add_skills();
   skill2->set_id("345");
@@ -447,7 +447,7 @@
   skill->set_description("Look for some socks");
   skill->set_icon("🧦");
   skill->set_prompt("Look for some socks");
-  skill->set_image_url("https://example.com/image.png");
+  skill->set_image_url("https://gstatic.com/image.png");
 
   skills::proto::Skill* skill2 = skills_list.add_skills();
   skill2->set_id("345");
diff --git a/components/skills/internal/skills_service_impl_unittest.cc b/components/skills/internal/skills_service_impl_unittest.cc
index 663f329..c45db9c 100644
--- a/components/skills/internal/skills_service_impl_unittest.cc
+++ b/components/skills/internal/skills_service_impl_unittest.cc
@@ -692,5 +692,33 @@
                           Pointee(HasSkill("Name B", "icon", "prompt", ""))));
 }
 
+TEST_F(SkillsServiceImplTest, Handle1pSkills_OnlyAcceptsHttpsImageUrls) {
+  InitService();
+  auto first_party_skill_data = std::make_unique<FirstPartySkillData>();
+  const std::vector<std::pair<std::string, std::string>> kCases = {
+      {"invalid_https_id", "https://example.com/image.png"},
+      {"https_id", "https://gstatic.com/image.png"},
+      {"data_id", "data:image/png;base64,iVBORw0KGgo="},
+      {"empty_id", ""},
+  };
+  for (const auto& [id, image_url] : kCases) {
+    skills::proto::Skill proto_skill;
+    proto_skill.set_id(id);
+    proto_skill.set_name("name");
+    proto_skill.set_image_url(image_url);
+    first_party_skill_data->skills_list.push_back(proto_skill);
+  }
+
+  service().Handle1pSkills(std::move(first_party_skill_data));
+
+  const Skill* https_skill = service().GetSkillById("https_id");
+  EXPECT_EQ(GURL("https://gstatic.com/image.png"), https_skill->image_url);
+
+  for (const char* id : {"invalid_https_id", "data_id", "empty_id"}) {
+    const Skill* skill = service().GetSkillById(id);
+    EXPECT_TRUE(skill->image_url.is_empty());
+  }
+}
+
 }  // namespace
 }  // namespace skills
Loading diff…

Original Bug Report

reported by rj...@google.com

Privilege Escalation via SanitizedImageSource bypass in chrome://skills

Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.

Overview: The Skills service does not validate the URL scheme of images fetched from its remote endpoint. A compromised network process can inject a malicious data: URI, which bypasses the out-of-process image decoder boundary (SanitizedImageSource) in the chrome://skills WebUI. This forces the privileged WebUI renderer to decode the image inline, exposing it to potential image decoder vulnerabilities.

Affected files:

  • components/skills/internal/skills_service_impl.cc
  • chrome/browser/ui/webui/skills/skills_page_handler.cc
  • ui/webui/resources/cr_elements/cr_auto_img/cr_auto_img.ts

Estimated timestamp from git blame: Unknown (Google3 checkout)

Description

The Skills service fetches first-party skill data from a gstatic endpoint (https://www.gstatic.com/chrome/skills/first_party_skills_binary) using the network service. This data is parsed as a protobuf which includes an image_url field used to display illustrations in the chrome://skills WebUI.

A potential vulnerability exists because the browser process does not validate the URL scheme when parsing the protobuf response. In components/skills/internal/skills_service_impl.cc, the image_url string is converted directly into a GURL object. Because data: is a valid URI scheme, the URL parses successfully. This unvalidated URL is then passed over Mojo via Translate1PSkills (chrome/browser/ui/webui/skills/skills_page_handler.cc) to the privileged chrome://skills renderer.

Security Boundary Bypass

The chrome://skills WebUI uses the <img is="cr-auto-img"> custom element to display these images. Typically, for external images, this component prefixes the URL with chrome://image (SanitizedImageSource). This is a critical security measure: SanitizedImageSource uses the data_decoder service to decode complex images (like PNG or WebP) safely in a separate, heavily sandboxed utility process, protecting the renderer from image parsing exploits.

However, the implementation of cr-auto-img in ui/webui/resources/cr_elements/cr_auto_img/cr_auto_img.ts explicitly bypasses this wrapper for data: URIs:

    if (url.protocol === 'data:' || url.protocol === 'chrome:') {
      this.src = url.href;
      return;
    }

Because the unvalidated data: URI from the Skills service hits this branch, the src attribute is set directly. This causes Blink’s internal ImageResource loader to decode the malicious payload inline, directly inside the privileged WebUI renderer process.

Potential Attack Scenario

(Note: These are suggested steps; our tooling has not executed a working proof of concept.)

  1. An attacker compromises the sandboxed Network Process, allowing them to intercept and forge HTTP responses.
  2. When the browser fetches discovery skills, the attacker returns a forged protobuf response where a skill’s image_url is set to a malicious payload (e.g., data:image/png;base64,...).
  3. The browser parses the protobuf and sends the unvalidated data: URI to the chrome://skills WebUI via Mojo.
  4. The WebUI binds the URL to an <img is="cr-auto-img"> element, which bypasses SanitizedImageSource due to the data: protocol check.
  5. Blink decodes the image inline within the WebUI renderer.
  6. If the payload is designed to exploit a 0-day or unpatched 1-day vulnerability in Blink’s native image decoders (e.g., libwebp, libpng), the attacker achieves arbitrary code execution within the privileged WebUI renderer, effectively escaping the Network Process sandbox.

Suggested Fix

  1. Validation in C++: In SkillsServiceImpl::Handle1pSkills or Translate1PSkills, explicitly validate that the image_url uses a secure, expected protocol (e.g., https:) and belongs to a trusted domain before converting it to a GURL or sending it over Mojo. If it fails validation, clear the URL.
  2. Review cr-auto-img bypass: Consider whether the data: URI bypass in cr_auto_img.ts is strictly necessary. If untrusted data: URIs can reach this component, they should ideally be routed through an out-of-process decoder as well, or rejected entirely.

Evaluated with Chrome root at commit: 65b3256311f3ab6fb9870eaa522de7e6dd2663bb


Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:

  • If you are familiar with the severity guidelines, you may adjust the severity.
  • If this is a false positive, and there’s no work to be done, please close as WAI.
  • If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.

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.

Raised in root component due to access or custom field issues on 1970385

View on issue tracker