CVE-2026-17769
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
SSRF on local network via path traversal in DIAL app-name sanitization
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 without the Chrome Security team. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: The DIAL protocol implementation in Chrome contains a path traversal vulnerability that permits unauthorized HTTP requests to local network devices. This occurs because application name validation incorrectly allows the traversal token ‘..’, enabling an attacker to escape the intended API namespace.
Affected files:
components/media_router/common/media_source.ccchrome/browser/media/router/providers/dial/dial_media_route_provider.ccchrome/browser/media/router/discovery/dial/dial_app_discovery_service.ccchrome/browser/media/router/discovery/dial/dial_media_sink_service_impl.ccchrome/browser/media/router/discovery/dial/dial_url_fetcher.ccchrome/browser/media/router/providers/dial/dial_activity_manager.cc
Estimated timestamp from git blame: 2026-04-07
Summary
A potential path traversal vulnerability exists in the DIAL (Discovery and Launch) component of Chrome’s Media Router. The sanitization logic for DIAL application names incorrectly allows the dot character (’.’), permitting the traversal token ‘..’ to be accepted. This allows an attacker to cause the browser process to issue unauthorized HTTP GET or POST requests to local network devices, bypassing Private Network Access (PNA) protections.
Root Cause Analysis
The vulnerability is located in IsDialAppName within components/media_router/common/media_source.cc. This function is intended to validate DIAL application names against a whitelist of allowed characters:
bool IsDialAppName(std::string_view app_name) {
if (app_name.empty()) {
return false;
}
return std::ranges::all_of(app_name, [](char c) {
return base::IsAsciiAlpha(c) || base::IsAsciiDigit(c) || c == '-' ||
c == '.' || c == '_' || c == '~';
});
}
The inclusion of . in the whitelist allows the string ".." to pass validation. When this name is subsequently resolved against a device’s DIAL Application-URL (e.g., http://192.168.1.50:8008/apps/) using GURL::Resolve(), it results in a path traversal that escapes the /apps/ directory, resolving to the root of the device (http://192.168.1.50:8008/).
Potential Exploitation Path
An attacker can potentially trigger this behavior from a malicious website using the Presentation API without user interaction:
- The attacker’s page calls
new PresentationRequest(['cast-dial:..?clientId=1']).getAvailability(). - The browser process extracts the app name
".."from thecast-dial:URL.IsDialAppName("..")returnstrue. - For every DIAL-compatible device discovered on the local network, the
DialAppDiscoveryServiceconstructs a URL by resolving".."against the device’s Application-URL. - The
DialURLFetcherissues an HTTP GET request to the resulting traversed URL (e.g., the device’s root path).
Because these requests are issued by the browser process using the system network context and a privileged internal Origin header, they bypass Private Network Access (PNA) restrictions. This allows an attacker to interact with local network services and potentially discover or probe internal devices.
Note: These are potential steps based on code analysis; our current environment does not allow for a live proof-of-concept execution.
Impact
This is a Server-Side Request Forgery (SSRF) vulnerability impacting the browser process. It allows for unauthorized interaction with local network devices and a bypass of the security boundaries established by PNA and DIAL sanitization.
Suggested Fix
Modify IsDialAppName in components/media_router/common/media_source.cc to explicitly reject application names that are exactly "." or "..". Additionally, consider validating that the URL produced by GetDialAppUrl is a subpath of the original app_url to prevent any form of resolution-based traversal.
Evaluated with Chrome root at commit: b3153093eb3c78c3e88ccf562bcbc20437a04b0e
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.