CVE-2026-17798
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 <mfoltz@chromium.org>
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 <muyaoxu@google.com>
Auto-Submit: Mark Foltz <mfoltz@chromium.org>
Commit-Queue: Muyao Xu <muyaoxu@google.com>
Commit-Queue: Mark Foltz <mfoltz@chromium.org>
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
Origin allowlist bypass in DIAL media routing via case-sensitive app name lookup
Flapjack, 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 DIAL media route provider uses a case-sensitive lookup for its origin allowlist. Requesting a known app with incorrect casing (e.g., “youtube” instead of “YouTube”) results in an empty origin list, which the media router interprets as allowing all origins, potentially exposing restricted local devices to unauthorized websites.
Affected files:
chrome/browser/media/router/providers/dial/dial_media_route_provider.cccomponents/media_router/browser/media_sinks_observer.cc
Estimated timestamp from git blame: Unknown (Google3 checkout)
Summary
A logic error exists in DialMediaRouteProvider::GetOrigins that allows malicious websites to bypass origin restrictions for well-known DIAL applications (such as YouTube, Netflix, Hulu, etc.). By using a case variation of the application name in a media source string (e.g., cast-dial:youtube instead of cast-dial:YouTube), an attacker can cause the browser to treat the application as having no origin restrictions. This enables an unauthorized origin to discover local DIAL devices and potentially prompt the user to launch applications with attacker-controlled payload data.
Vulnerability Details
In chrome/browser/media/router/providers/dial/dial_media_route_provider.cc, the GetOrigins function uses a base::flat_map to store an allowlist of origins for specific DIAL application names:
std::vector<url::Origin> DialMediaRouteProvider::GetOrigins(
const std::string& app_name) {
static const base::NoDestructor<
base::flat_map<std::string, std::vector<url::Origin>>>
origin_allowlist({
{"YouTube", {...}},
{"Netflix", {...}},
// ...
});
auto origins_it = origin_allowlist->find(app_name);
if (origins_it == origin_allowlist->end()) {
return std::vector<url::Origin>();
}
return origins_it->second;
}
The base::flat_map string lookup is case-sensitive. When a website requests a DIAL source with a variation in casing (e.g., lowercase “youtube”), the lookup fails and the function returns an empty std::vector<url::Origin>.
In the Chromium Media Router architecture, an empty origin list is interpreted as the source being allowed for all origins, rather than denying access. This logic is implemented in components/media_router/browser/media_sinks_observer.cc:
void MediaSinksObserver::OnSinksUpdated(
const std::vector<MediaSink>& sinks,
const std::vector<url::Origin>& origins) {
// ...
if (origins.empty() || std::ranges::contains(origins, origin_)) {
OnSinksReceived(sinks);
} else {
OnSinksReceived(std::vector<MediaSink>());
}
}
Many DIAL-capable devices (such as smart TVs) handle application paths case-insensitively (treating /apps/youtube the same as /apps/YouTube). Consequently, an attacker can successfully discover and interact with these services despite the intended origin restrictions.
Potential Attack Steps
(Note: These are suggested steps based on static analysis; our tooling agent cannot execute code to verify this end-to-end.)
- An attacker hosts a malicious website on a non-allowlisted origin.
- The website uses the HTML5 Presentation API to initiate a Cast session with a case-manipulated DIAL media source URL, including attacker-controlled POST data:
const request = new PresentationRequest("cast-dial:youtube?clientId=1234&dialPostData=malicious_payload"); - The browser extracts the app name
"youtube"and discovers local DIAL devices supporting the app (as the devices often match case-insensitively). DialMediaRouteProvider::GetOrigins("youtube")fails to find a match in the allowlist and returns an empty origin list.MediaSinksObserver::OnSinksUpdatedinterprets the empty list as “allow all origins” and makes the discovered local devices visible to the attacker’s origin via the Cast dialog.- The user, seeing their own device in the dialog, clicks it.
DialActivityManager::LaunchAppconstructs a DIAL launch HTTP POST request to the device, including the attacker’sdialPostDatapayload, successfully launching the app with malicious data.
Suggested Fix
There are two primary ways to fix this:
- Case-Insensitive Lookup: Modify
DialMediaRouteProvider::GetOriginsto perform a case-insensitive lookup, or normalize theapp_nameto a known case before querying theorigin_allowlist. Abase::flat_mapwithbase::CompareCase::INSENSITIVE_ASCIIcould be used. - Restrict Unknown Apps: If the intent is that only apps in the allowlist should be discoverable/launchable via the Presentation API,
GetOriginsshould not return an empty list for unknown apps, orMediaSinksObservershould have a clearer mechanism to distinguish “deny all” from “allow all”. However, if arbitrary DIAL apps are meant to be supported without an explicit allowlist, the first fix (case-insensitive lookup for the protected apps) is necessary.
Evaluated with Chrome root at commit: b7d0c4d810da1b31400f198c70d9720fc8f0e5a0
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.