CVE-2026-17888
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fchrome/browser/ui/webui/skills/skills_page_handler_unittest.cc |
modified | |
forchrome/browser/ui/webui/skills/skills_page_handler_unittest.cc |
modified | |
ifchrome/browser/ui/webui/skills/skills_page_handler_unittest.cc |
modified | |
forcomponents/skills/internal/skills_service_impl.cc |
modified | |
TEST_Fcomponents/skills/internal/skills_service_impl_unittest.cc |
modified | |
forcomponents/skills/internal/skills_service_impl_unittest.cc |
modified |
Files Changed
chrome/browser/ui/webui/skills/skills_page_handler.ccchrome/browser/ui/webui/skills/skills_page_handler_unittest.ccchrome/browser/ui/webui/skills/skills_page_interactive_uitest.cccomponents/skills/internal/skills_service_impl.cccomponents/skills/internal/skills_service_impl_unittest.cccomponents/skills/public/skills_service.cccomponents/skills/public/skills_service.h
Patch
From 6a962f21d15a91f57cba0b06e5e30c92a8efc89d Mon Sep 17 00:00:00 2001 From: chrstne <[email protected]> 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 <[email protected]> Commit-Queue: Christine Ying <[email protected]> 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
Regression Test / PoC
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
Original Bug Report
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.ccchrome/browser/ui/webui/skills/skills_page_handler.ccui/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.)
- An attacker compromises the sandboxed Network Process, allowing them to intercept and forge HTTP responses.
- When the browser fetches discovery skills, the attacker returns a forged protobuf response where a skill’s
image_urlis set to a malicious payload (e.g.,data:image/png;base64,...). - The browser parses the protobuf and sends the unvalidated
data:URI to thechrome://skillsWebUI via Mojo. - The WebUI binds the URL to an
<img is="cr-auto-img">element, which bypassesSanitizedImageSourcedue to thedata:protocol check. - Blink decodes the image inline within the WebUI renderer.
- 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
- Validation in C++: In
SkillsServiceImpl::Handle1pSkillsorTranslate1PSkills, explicitly validate that theimage_urluses a secure, expected protocol (e.g.,https:) and belongs to a trusted domain before converting it to aGURLor sending it over Mojo. If it fails validation, clear the URL. - Review
cr-auto-imgbypass: Consider whether thedata:URI bypass incr_auto_img.tsis strictly necessary. If untrusteddata: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