CVE-2026-17844
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
TEST_Fchrome/browser/media/router/discovery/dial/device_description_service_unittest.cc |
modified | |
forchrome/browser/media/router/providers/dial/dial_media_route_provider.cc |
modified | |
TEST_Fchrome/browser/media/router/providers/dial/dial_media_route_provider_unittest.cc |
modified |
Files Changed
chrome/browser/media/router/discovery/dial/device_description_service.ccchrome/browser/media/router/discovery/dial/device_description_service_unittest.ccchrome/browser/media/router/providers/dial/dial_media_route_provider.ccchrome/browser/media/router/providers/dial/dial_media_route_provider_unittest.cccomponents/media_router/common/media_source.cc
Patch
From dbd5ebafd072895b9b64b460b446400ed54121fa Mon Sep 17 00:00:00 2001 From: mark a. foltz <[email protected]> Date: Wed, 03 Jun 2026 15:25:38 -0700 Subject: [PATCH] [media-router] Fix several logic and validation issues in DIAL. This CL updates the DIAL implementation to: 1. Re-validate the cached application URL host against the current device IP address during cache lookup in DeviceDescriptionService. 2. Make DIAL application name lookup for origin allowlisting case-insensitive in DialMediaRouteProvider. 3. Explicitly reject dot-segment relative path tokens (".", "..") in IsDialAppName validation to prevent incorrect resolution of relative URLs. Includes unit tests for all updated behaviors. Fixed: 518111542,514460133,513022076 Change-Id: Ibec34c60d8d2103b3387449735e7f259977cb146 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7899779 Reviewed-by: Muyao Xu <[email protected]> Auto-Submit: Mark Foltz <[email protected]> Commit-Queue: Muyao Xu <[email protected]> Commit-Queue: Mark Foltz <[email protected]> Cr-Commit-Position: refs/heads/main@{#1641256} --- diff --git a/chrome/browser/media/router/discovery/dial/device_description_service.cc b/chrome/browser/media/router/discovery/dial/device_description_service.cc index 9cfe7d4..590554c0 100644 --- a/chrome/browser/media/router/discovery/dial/device_description_service.cc +++ b/chrome/browser/media/router/discovery/dial/device_description_service.cc @@ -185,6 +185,13 @@ return nullptr; } + // The device's IP address may have changed; re-validate the cached app_url + // against the current ip_address and remove it if there is a mismatch. + if (!device_data.IsValidUrl(it->second.description_data.app_url)) { + description_cache_.erase(it); + return nullptr; + } + // Entry is valid. return &it->second; } diff --git a/chrome/browser/media/router/discovery/dial/device_description_service_unittest.cc b/chrome/browser/media/router/discovery/dial/device_description_service_unittest.cc index 5ee04a5..a746306b 100644 --- a/chrome/browser/media/router/discovery/dial/device_description_service_unittest.cc +++ b/chrome/browser/media/router/discovery/dial/device_description_service_unittest.cc @@ -143,6 +143,50 @@ description_cache_; }; +TEST_F(DeviceDescriptionServiceTest, CacheHitSkipsIsValidUrlAfterIpChange) { + // First discovery cycle: device advertises from IP_A = 192.168.1.10. + net::IPAddress ip_a; + ASSERT_TRUE(ip_a.AssignFromIPLiteral("192.168.1.10")); + const int kConfigId = 7; + + // After the first cycle, the description (validated against IP_A) is cached + // under the device's label with config_id=7. Simulate that cached state. + ParsedDialDeviceDescription cached_desc; + cached_desc.app_url = GURL("http://192.168.1.10/apps"); // host == IP_A + cached_desc.friendly_name = "My TV"; + cached_desc.model_name = "TV"; + cached_desc.unique_id = "uuid:random"; + + DeviceDescriptionService::CacheEntry entry; + entry.expire_time = base::Time::Now() + base::Hours(12); + entry.config_id = kConfigId; + entry.description_data = cached_desc; + (*description_cache_)["label-1"] = entry; + + // ---- Second discovery cycle: same USN, same CONFIGID, NEW source IP_B. ---- + // DialRegistry::OnDeviceDiscovered -> UpdateFrom() preserves the label and + // overwrites ip_address_ with IP_B = 192.168.1.20. + net::IPAddress ip_b; + ASSERT_TRUE(ip_b.AssignFromIPLiteral("192.168.1.20")); + + DialDeviceData updated("uuid:random", GURL("http://192.168.1.20/dd.xml"), + base::Time::Now()); + updated.set_label("label-1"); // preserved by UpdateFrom() + updated.set_config_id(kConfigId); // unchanged -> cache hit + updated.set_ip_address(ip_b); // NEW IP + + // Capture what the success callback receives. + EXPECT_CALL(mock_success_cb_, Run(_, _)).Times(0); + EXPECT_CALL(*device_description_service(), ParseDeviceDescription(_, _)) + .Times(0); + + device_description_service()->GetDeviceDescriptions({updated}); + + // Verify that cache was invalidated, so it falls back to starting a fresh + // fetch. + EXPECT_FALSE(fetcher_map_->empty()); +} + TEST_F(DeviceDescriptionServiceTest, TestGetDeviceDescriptionFromCache) { auto device_data = CreateDialDeviceData(1); auto description_data = CreateParsedDialDeviceDescription(1); diff --git a/chrome/browser/media/router/providers/dial/dial_media_route_provider.cc b/chrome/browser/media/router/providers/dial/dial_media_route_provider.cc index c33e4780..7ffe48a 100644 --- a/chrome/browser/media/router/providers/dial/dial_media_route_provider.cc +++ b/chrome/browser/media/router/providers/dial/dial_media_route_provider.cc @@ -12,6 +12,7 @@ #include "base/no_destructor.h" #include "base/notimplemented.h" #include "base/strings/strcat.h" +#include "base/strings/string_util.h" #include "base/strings/stringprintf.h" #include "base/task/sequenced_task_runner.h" #include "chrome/browser/media/router/data_decoder_util.h" @@ -651,7 +652,7 @@ static const base::NoDestructor< base::flat_map<std::string, std::vector<url::Origin>>> origin_allowlist( - {{"YouTube", + {{"youtube", {CreateOrigin("https://music.youtube.com/"), CreateOrigin("https://music-green-qa.youtube.com/"), CreateOrigin("https://music-release-qa.youtube.com/"), @@ -661,20 +662,22 @@ CreateOrigin("https://web-green-qa.youtube.com"), CreateOrigin("https://web-release-qa.youtube.com"), CreateOrigin("https://www.youtube.com")}}, - {"Netflix", {CreateOrigin("https://www.netflix.com")}}, - {"Pandora", {CreateOrigin("https://www.pandora.com")}}, - {"Radio", {CreateOrigin("https://www.pandora.com")}}, - {"Hulu", {CreateOrigin("https://www.hulu.com")}}, - {"Vimeo", {CreateOrigin("https://www.vimeo.com")}}, - {"Dailymotion", {CreateOrigin("https://www.dailymotion.com")}}, + {"netflix", {CreateOrigin("https://www.netflix.com")}}, + {"pandora", {CreateOrigin("https://www.pandora.com")}}, + {"radio", {CreateOrigin("https://www.pandora.com")}}, + {"hulu", {CreateOrigin("https://www.hulu.com")}}, + {"vimeo", {CreateOrigin("https://www.vimeo.com")}}, + {"dailymotion", {CreateOrigin("https://www.dailymotion.com")}}, {"com.dailymotion", {CreateOrigin("https://www.dailymotion.com")}}}); - auto origins_it = origin_allowlist->find(app_name); - if (origins_it == origin_allowlist->end()) { - return std::vector<url::Origin>(); + // DIAL devices commonly treat app names case-insensitively. + for (const auto& [name, origins] : *origin_allowlist) { + if (base::EqualsCaseInsensitiveASCII(name, app_name)) { + return origins; + } } - return origins_it->second; + return std::vector<url::Origin>(); } DialMediaRouteProvider::MediaSinkQuery::MediaSinkQuery() = default; diff --git a/chrome/browser/media/router/providers/dial/dial_media_route_provider_unittest.cc b/chrome/browser/media/router/providers/dial/dial_media_route_provider_unittest.cc index b949ec4..c4e35c0d 100644 --- a/chrome/browser/media/router/providers/dial/dial_media_route_provider_unittest.cc +++ b/chrome/browser/media/router/providers/dial/dial_media_route_provider_unittest.cc @@ -472,6 +472,33 @@ task_environment_.RunUntilIdle(); } +TEST_F(DialMediaRouteProviderTest, AddSinkQueryCaseInsensitive) { + std::string youtube_source_lower("cast-dial:youtube"); + std::vector<url::Origin> youtube_origins = { + url::Origin::Create(GURL("https://music.youtube.com/")), + url::Origin::Create(GURL("https://music-green-qa.youtube.com/")), + url::Origin::Create(GURL("https://music-release-qa.youtube.com/")), + url::Origin::Create(GURL("https://tv.youtube.com")), + url::Origin::Create(GURL("https://tv-green-qa.youtube.com")), + url::Origin::Create(GURL("https://tv-release-qa.youtube.com")), + url::Origin::Create(GURL("https://web-green-qa.youtube.com")), + url::Origin::Create(GURL("https://web-release-qa.youtube.com")), + url::Origin::Create(GURL("https://www.youtube.com"))}; + + EXPECT_CALL(mock_sink_service_, + DoStartMonitoringAvailableSinksForApp("youtube")); + base::RunLoop run_loop; + // Lowercase "youtube" should return the YouTube origin list. + EXPECT_CALL(mock_router_, + OnSinksReceived(mojom::MediaRouteProviderId::DIAL, + youtube_source_lower, IsEmpty(), youtube_origins)) + .WillOnce([&run_loop]() { run_loop.Quit(); }); + provider_->StartObservingMediaSinks(youtube_source_lower); + run_loop.Run(); + + provider_->StopObservingMediaSinks(youtube_source_lower); +} + TEST_F(DialMediaRouteProviderTest, AddSinkQuerySameMediaSource) { std::string youtube_source("cast-dial:YouTube"); EXPECT_CALL(mock_sink_service_, diff --git a/components/media_router/common/media_source.cc b/components/media_router/common/media_source.cc index 8c43f41..96bc889 100644 --- a/components/media_router/common/media_source.cc +++ b/components/media_router/common/media_source.cc @@ -81,6 +81,11 @@ if (app_name.empty()) {
Regression Test / PoC
diff --git a/chrome/browser/media/router/discovery/dial/device_description_service_unittest.cc b/chrome/browser/media/router/discovery/dial/device_description_service_unittest.cc
index 5ee04a5..a746306b 100644
--- a/chrome/browser/media/router/discovery/dial/device_description_service_unittest.cc
+++ b/chrome/browser/media/router/discovery/dial/device_description_service_unittest.cc
@@ -143,6 +143,50 @@
description_cache_;
};
+TEST_F(DeviceDescriptionServiceTest, CacheHitSkipsIsValidUrlAfterIpChange) {
+ // First discovery cycle: device advertises from IP_A = 192.168.1.10.
+ net::IPAddress ip_a;
+ ASSERT_TRUE(ip_a.AssignFromIPLiteral("192.168.1.10"));
+ const int kConfigId = 7;
+
+ // After the first cycle, the description (validated against IP_A) is cached
+ // under the device's label with config_id=7. Simulate that cached state.
+ ParsedDialDeviceDescription cached_desc;
+ cached_desc.app_url = GURL("http://192.168.1.10/apps"); // host == IP_A
+ cached_desc.friendly_name = "My TV";
+ cached_desc.model_name = "TV";
+ cached_desc.unique_id = "uuid:random";
+
+ DeviceDescriptionService::CacheEntry entry;
+ entry.expire_time = base::Time::Now() + base::Hours(12);
+ entry.config_id = kConfigId;
+ entry.description_data = cached_desc;
+ (*description_cache_)["label-1"] = entry;
+
+ // ---- Second discovery cycle: same USN, same CONFIGID, NEW source IP_B. ----
+ // DialRegistry::OnDeviceDiscovered -> UpdateFrom() preserves the label and
+ // overwrites ip_address_ with IP_B = 192.168.1.20.
+ net::IPAddress ip_b;
+ ASSERT_TRUE(ip_b.AssignFromIPLiteral("192.168.1.20"));
+
+ DialDeviceData updated("uuid:random", GURL("http://192.168.1.20/dd.xml"),
+ base::Time::Now());
+ updated.set_label("label-1"); // preserved by UpdateFrom()
+ updated.set_config_id(kConfigId); // unchanged -> cache hit
+ updated.set_ip_address(ip_b); // NEW IP
+
+ // Capture what the success callback receives.
+ EXPECT_CALL(mock_success_cb_, Run(_, _)).Times(0);
+ EXPECT_CALL(*device_description_service(), ParseDeviceDescription(_, _))
+ .Times(0);
+
+ device_description_service()->GetDeviceDescriptions({updated});
+
+ // Verify that cache was invalidated, so it falls back to starting a fresh
+ // fetch.
+ EXPECT_FALSE(fetcher_map_->empty());
+}
+
TEST_F(DeviceDescriptionServiceTest, TestGetDeviceDescriptionFromCache) {
auto device_data = CreateDialDeviceData(1);
auto description_data = CreateParsedDialDeviceDescription(1);
diff --git a/chrome/browser/media/router/providers/dial/dial_media_route_provider_unittest.cc b/chrome/browser/media/router/providers/dial/dial_media_route_provider_unittest.cc
index b949ec4..c4e35c0d 100644
--- a/chrome/browser/media/router/providers/dial/dial_media_route_provider_unittest.cc
+++ b/chrome/browser/media/router/providers/dial/dial_media_route_provider_unittest.cc
@@ -472,6 +472,33 @@
task_environment_.RunUntilIdle();
}
+TEST_F(DialMediaRouteProviderTest, AddSinkQueryCaseInsensitive) {
+ std::string youtube_source_lower("cast-dial:youtube");
+ std::vector<url::Origin> youtube_origins = {
+ url::Origin::Create(GURL("https://music.youtube.com/")),
+ url::Origin::Create(GURL("https://music-green-qa.youtube.com/")),
+ url::Origin::Create(GURL("https://music-release-qa.youtube.com/")),
+ url::Origin::Create(GURL("https://tv.youtube.com")),
+ url::Origin::Create(GURL("https://tv-green-qa.youtube.com")),
+ url::Origin::Create(GURL("https://tv-release-qa.youtube.com")),
+ url::Origin::Create(GURL("https://web-green-qa.youtube.com")),
+ url::Origin::Create(GURL("https://web-release-qa.youtube.com")),
+ url::Origin::Create(GURL("https://www.youtube.com"))};
+
+ EXPECT_CALL(mock_sink_service_,
+ DoStartMonitoringAvailableSinksForApp("youtube"));
+ base::RunLoop run_loop;
+ // Lowercase "youtube" should return the YouTube origin list.
+ EXPECT_CALL(mock_router_,
+ OnSinksReceived(mojom::MediaRouteProviderId::DIAL,
+ youtube_source_lower, IsEmpty(), youtube_origins))
+ .WillOnce([&run_loop]() { run_loop.Quit(); });
+ provider_->StartObservingMediaSinks(youtube_source_lower);
+ run_loop.Run();
+
+ provider_->StopObservingMediaSinks(youtube_source_lower);
+}
+
TEST_F(DialMediaRouteProviderTest, AddSinkQuerySameMediaSource) {
std::string youtube_source("cast-dial:YouTube");
EXPECT_CALL(mock_sink_service_,
diff --git a/components/media_router/common/media_source_unittest.cc b/components/media_router/common/media_source_unittest.cc
index 0f161f86..cf290cb6 100644
--- a/components/media_router/common/media_source_unittest.cc
+++ b/components/media_router/common/media_source_unittest.cc
@@ -258,7 +258,8 @@
EXPECT_FALSE(IsDialAppName(""));
EXPECT_FALSE(IsDialAppName("App Name"));
EXPECT_FALSE(IsDialAppName("App/Name"));
- EXPECT_FALSE(IsDialAppName("../Name"));
+ EXPECT_FALSE(IsDialAppName("."));
+ EXPECT_FALSE(IsDialAppName(".."));
EXPECT_FALSE(IsDialAppName("App\nName"));
}
Original Bug Report
Potential DIAL cache hit bypasses IsValidUrl re-validation after USN-keyed IP address change
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: A logic flaw in the DIAL device description cache allows cached entries to be served without verifying if the host of the cached app_url matches the device’s updated IP address. A local-network attacker can potentially leverage this desynchronization to bypass IP-pinning protections and perform SSRF targeting private network addresses. This could lead to unauthorized proxy-bypassing HTTP requests being issued to local intranet devices.
Affected files:
chrome/browser/media/router/discovery/dial/device_description_service.ccchrome/browser/media/router/discovery/dial/dial_device_data.ccchrome/browser/media/router/discovery/dial/dial_media_sink_service_impl.ccchrome/browser/media/router/providers/dial/dial_activity_manager.cc
Estimated timestamp from git blame: 2021-11-20
Summary
A potential logic vulnerability exists in the DIAL (Discovery and Launch) protocol implementation where the device-description cache lookup skips IsValidUrl re-validation after a USN-keyed IP address change. This desynchronizes the cached app_url from the current ip_address, potentially undermining the security invariant established by the GetApplicationInstanceURL IP-pinning fix. Consequently, an attacker on the local network could leverage this to send unauthorized proxy-bypassing HTTP requests (such as DELETE) to private network addresses.
Note: These are potential steps and vulnerabilities identified through manual code review; our tooling agent does not have the capability to execute code or run a live proof of concept.
Root Cause Analysis
In DeviceDescriptionService::CheckAndUpdateCache (chrome/browser/media/router/discovery/dial/device_description_service.cc), the cache lookup is keyed by the device’s label and invalidates cached entries only if there is a config_id mismatch or if the entry has expired. It does not verify whether the host of the cached app_url matches the device’s current IP address:
const DeviceDescriptionService::CacheEntry*
DeviceDescriptionService::CheckAndUpdateCache(
const DialDeviceData& device_data) {
const auto& it = description_cache_.find(device_data.label());
if (it == description_cache_.end()) {
return nullptr;
}
// If the entry's config_id does not match, or it has expired, remove it.
if (it->second.config_id != device_data.config_id() ||
GetNow() >= it->second.expire_time) {
description_cache_.erase(it);
return nullptr;
}
// Entry is valid.
return &it->second;
}
On a cache hit, the cached description (whose app_url was validated against the old IP address) is returned and used alongside the updated device data containing the new IP address. This completely bypasses both the IsValidUrl check in DeviceDescriptionFetcher::ProcessResponse and the verification in ValidateParsedDeviceDescription (which are only reachable when fetching a fresh description).
This results in a media sink being constructed with mismatched, desynchronized fields in DialMediaSinkServiceImpl::OnDeviceDescriptionAvailable (chrome/browser/media/router/discovery/dial/dial_media_sink_service_impl.cc):
DialSinkExtraData extra_data;
extra_data.app_url = description_data.app_url; // host == OLD ip (cached)
extra_data.model_name = description_data.model_name;
extra_data.ip_address = device_data.ip_address(); // NEW ip
When launching or terminating a cast session, this mismatch can bypass the security invariant that GetApplicationInstanceURL (chrome/browser/media/router/providers/dial/dial_activity_manager.cc) relies on to prevent SSRF.
Potential Attack Scenario / Trigger Path
Below is a potential sequence of events that a local network attacker could follow to exploit this vulnerability:
- First discovery cycle: An attacker sends an SSDP response from IP_A (controlled by the attacker) with:
USN: uuid:XCONFIGID.UPNP.ORG: 5LOCATION: http://IP_A:.../dd.xmlDialRegistry::OnDeviceDiscoveredprocesses this and creates a new entry with a fresh label (e.g.,"1").
- The description is fetched from
IP_A. The attacker servesApplication-URL: http://IP_A:.../apps. Since the host matchesIP_A,IsValidUrlpasses, and the description is cached withconfig_id = 5. - Second discovery cycle: The attacker sends another SSDP response with the same
USN: uuid:XandCONFIGID.UPNP.ORG: 5, but with a UDP source of IP_B (a target private IP, e.g., on a VPN or another routed interface) andLOCATION: http://IP_B:.../dd.xml.DialRegistry::UpdateFromoverwrites theip_address_withIP_Bwhile preserving the label"1". - When
GetDeviceDescriptionsruns,CheckAndUpdateCacheresults in a cache hit because theconfig_id(5) matches and the cache is not expired. The cached description (withapp_urlpointing toIP_A) is served with the updateddevice_data(ip_addresspointing toIP_B) without re-runningIsValidUrl. - The media sink is constructed with
app_urlhost pointing toIP_Aandip_addresspointing toIP_B. - A user attempts to cast to this sink. Chrome sends an HTTP
POSTtohttp://IP_A:.../apps/<app>(the attacker’s server). - The attacker’s server at
IP_Aresponds with201 Createdand aLOCATIONheader pointing tohttp://IP_B:<port>/<path>. GetApplicationInstanceURLvalidates the returnedLOCATIONhost (IP_B) againstexpected_ip(which isIP_B) and passes.- When the user stops casting, Chrome’s browser process issues a proxy-bypassing HTTP
DELETErequest tohttp://IP_B:<port>/<path>(withLOAD_BYPASS_PROXY), completing a confused-deputy attack targeting a private address.
Suggested Fix
In CheckAndUpdateCache (chrome/browser/media/router/discovery/dial/device_description_service.cc), the cache entry should be invalidated if the current device_data.ip_address() does not match the host of the cached description_data.app_url (or the IP address used to validate the description should be stored inside CacheEntry and verified on lookup). This ensures that the IsValidUrl invariant is consistently enforced across discovery cycles.
Evaluated with Chrome root at commit: fb72408a8493c46bc75fae1c70d03daec96b3040
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.