CVE-2026-11069
Overview
Files Changed
chrome/browser/media/router/discovery/dial/dial_app_discovery_service.ccchrome/browser/media/router/discovery/dial/dial_app_discovery_service.hchrome/browser/media/router/discovery/dial/dial_app_discovery_service_unittest.ccchrome/browser/media/router/providers/dial/dial_activity_manager.cccomponents/media_router/common/media_source.cccomponents/media_router/common/media_source.h
Patch
From b63c8cab982b50e82e41596a7c3346e272f193bd Mon Sep 17 00:00:00 2001 From: mark a. foltz <[email protected]> Date: Tue, 07 Apr 2026 22:33:43 -0700 Subject: [PATCH] [media router] Sanitize DIAL app names and use safe URL construction. This change improves DIAL app name validation by: 1. Validating DIAL app names to only contain safe characters (A-Za-z0-9-._~). 2. Using GURL::Resolve instead of raw string concatenation when constructing DIAL application URLs. AI disclosure: Prepared with the help of gemini_cli. Bug: 499213367 Change-Id: Ib9e1564b54f7e09f61047cc2d03df954b6c659dd Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7735240 Commit-Queue: Mark Foltz <[email protected]> Reviewed-by: Muyao Xu <[email protected]> Cr-Commit-Position: refs/heads/main@{#1611236} --- diff --git a/chrome/browser/media/router/discovery/dial/dial_app_discovery_service.cc b/chrome/browser/media/router/discovery/dial/dial_app_discovery_service.cc index 3c30441..95438f97 100644 --- a/chrome/browser/media/router/discovery/dial/dial_app_discovery_service.cc +++ b/chrome/browser/media/router/discovery/dial/dial_app_discovery_service.cc @@ -13,6 +13,7 @@ #include "base/strings/to_string.h" #include "base/time/default_clock.h" #include "chrome/browser/media/router/data_decoder_util.h" +#include "components/media_router/common/media_source.h" #include "net/http/http_status_code.h" #include "url/gurl.h" @@ -22,14 +23,6 @@ const char kLoggerComponent[] = "DialAppDiscoveryService"; -GURL GetAppUrl(const media_router::MediaSinkInternal& sink, - const std::string& app_name) { - // The DIAL spec (Section 5.4) implies that the app URL must not have a - // trailing slash. - GURL partial_app_url = sink.dial_data().app_url; - return GURL(partial_app_url.spec() + "/" + app_name); -} - void RecordDialFetchAppInfo(DialAppInfoResultCode result_code) { UMA_HISTOGRAM_ENUMERATION("MediaRouter.Dial.FetchAppInfo", result_code, DialAppInfoResultCode::kCount); @@ -65,9 +58,18 @@ DialAppInfoCallback app_info_cb) { DCHECK_CALLED_ON_VALID_SEQUENCE(sequence_checker_); + GURL app_url = GetDialAppUrl(sink.dial_data().app_url, app_name); + if (!app_url.is_valid()) { + std::move(app_info_cb) + .Run(sink.sink().id(), app_name, + DialAppInfoResult(nullptr, DialAppInfoResultCode::kNetworkError, + "Invalid app URL")); + return; + } + pending_requests_.push_back( std::make_unique<DialAppDiscoveryService::PendingRequest>( - sink, app_name, std::move(app_info_cb), this)); + sink, app_name, app_url, std::move(app_info_cb), this)); pending_requests_.back()->Start(); } @@ -86,11 +88,12 @@ DialAppDiscoveryService::PendingRequest::PendingRequest( const MediaSinkInternal& sink, const std::string& app_name, + const GURL& app_url, DialAppInfoCallback app_info_cb, DialAppDiscoveryService* const service) : sink_id_(sink.sink().id()), app_name_(app_name), - app_url_(GetAppUrl(sink, app_name)), + app_url_(app_url), // |base::Unretained(this)| since |fetcher_| is owned by |this|. fetcher_( base::BindOnce(&DialAppDiscoveryService::PendingRequest:: diff --git a/chrome/browser/media/router/discovery/dial/dial_app_discovery_service.h b/chrome/browser/media/router/discovery/dial/dial_app_discovery_service.h index 20d25b9..b03c9c9e 100644 --- a/chrome/browser/media/router/discovery/dial/dial_app_discovery_service.h +++ b/chrome/browser/media/router/discovery/dial/dial_app_discovery_service.h @@ -93,6 +93,7 @@ public: PendingRequest(const MediaSinkInternal& sink, const std::string& app_name, + const GURL& app_url, DialAppInfoCallback app_info_cb, DialAppDiscoveryService* const service); diff --git a/chrome/browser/media/router/discovery/dial/dial_app_discovery_service_unittest.cc b/chrome/browser/media/router/discovery/dial/dial_app_discovery_service_unittest.cc index bd9dd88..0e47f843d 100644 --- a/chrome/browser/media/router/discovery/dial/dial_app_discovery_service_unittest.cc +++ b/chrome/browser/media/router/discovery/dial/dial_app_discovery_service_unittest.cc @@ -86,8 +86,9 @@ DialAppDiscoveryService::PendingRequest* AddFetchRequest( const MediaSinkInternal& sink, const std::string& app_name) { + GURL app_url = GetDialAppUrl(sink.dial_data().app_url, app_name); auto request = std::make_unique<DialAppDiscoveryService::PendingRequest>( - sink, app_name, + sink, app_name, app_url, base::BindOnce(&DialAppDiscoveryServiceTest::OnAppInfo, base::Unretained(this)), &dial_app_discovery_service_); diff --git a/chrome/browser/media/router/providers/dial/dial_activity_manager.cc b/chrome/browser/media/router/providers/dial/dial_activity_manager.cc index a0ec269..69a1b256b 100644 --- a/chrome/browser/media/router/providers/dial/dial_activity_manager.cc +++ b/chrome/browser/media/router/providers/dial/dial_activity_manager.cc @@ -20,13 +20,6 @@ namespace { -// Returns the URL to use to launch |app_name| on |sink|. -GURL GetAppURL(const MediaSinkInternal& sink, const std::string& app_name) { - // The DIAL spec (Section 5.4) implies that the app URL must not have a - // trailing slash. - return GURL(sink.dial_data().app_url.spec() + "/" + app_name); -} - // Returns the Application Instance URL from the POST response headers given by // |response_info|. GURL GetApplicationInstanceURL( @@ -112,8 +105,10 @@ return nullptr; } - GURL app_launch_url = GetAppURL(sink, app_name); - DCHECK(app_launch_url.is_valid()); + GURL app_launch_url = GetDialAppUrl(sink.dial_data().app_url, app_name); + if (!app_launch_url.is_valid()) { + return nullptr; + } const MediaSink::Id& sink_id = sink.sink().id(); DialLaunchInfo launch_info(app_name, post_data, client_id, app_launch_url); diff --git a/components/media_router/common/media_source.cc b/components/media_router/common/media_source.cc index f4d09a73..8c43f41 100644 --- a/components/media_router/common/media_source.cc +++ b/components/media_router/common/media_source.cc @@ -77,6 +77,30 @@ } // namespace +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 == '~'; + }); +} + +GURL GetDialAppUrl(const GURL& app_url, const std::string& app_name) { + if (!IsDialAppName(app_name) || !app_url.is_valid()) { + return GURL(); + } + + // The DIAL spec (Section 5.4) implies that the app URL must not have a + // trailing slash. + std::string spec = app_url.spec(); + if (!spec.empty() && spec.back() != '/') { + spec += "/"; + } + return GURL(spec).Resolve(app_name); +} + bool IsLegacyCastPresentationUrl(const GURL& url) { return base::StartsWith(url.spec(), kLegacyCastPresentationUrlPrefix, base::CompareCase::INSENSITIVE_ASCII); @@ -236,7 +260,11 @@ } std::string MediaSource::AppNameFromDialSource() const { - return IsDialSource() ? url_.GetPath() : ""; + if (!IsDialSource()) { + return ""; + } + std::string app_name = url_.GetPath(); + return IsDialAppName(app_name) ? app_name : ""; } std::string MediaSource::TruncateForLogging(size_t max_length) const { diff --git a/components/media_router/common/media_source.h b/components/media_router/common/media_source.h index f50e0056..9bf4487b2 100644 --- a/components/media_router/common/media_source.h +++ b/components/media_router/common/media_source.h @@ -50,6 +50,14 @@ // Returns true if |media_source| has a valid presentation URL. bool IsValidStandardPresentationSource(const std::string& media_source); +// Returns true if |app_name| contains only safe characters allowed for a DIAL +// app name. +bool IsDialAppName(std::string_view app_name); + +// Returns a DIAL app URL from the device's |app_url| and |app_name|.
Regression Test / PoC
diff --git a/chrome/browser/media/router/discovery/dial/dial_app_discovery_service_unittest.cc b/chrome/browser/media/router/discovery/dial/dial_app_discovery_service_unittest.cc
index bd9dd88..0e47f843d 100644
--- a/chrome/browser/media/router/discovery/dial/dial_app_discovery_service_unittest.cc
+++ b/chrome/browser/media/router/discovery/dial/dial_app_discovery_service_unittest.cc
@@ -86,8 +86,9 @@
DialAppDiscoveryService::PendingRequest* AddFetchRequest(
const MediaSinkInternal& sink,
const std::string& app_name) {
+ GURL app_url = GetDialAppUrl(sink.dial_data().app_url, app_name);
auto request = std::make_unique<DialAppDiscoveryService::PendingRequest>(
- sink, app_name,
+ sink, app_name, app_url,
base::BindOnce(&DialAppDiscoveryServiceTest::OnAppInfo,
base::Unretained(this)),
&dial_app_discovery_service_);
diff --git a/components/media_router/common/media_source_unittest.cc b/components/media_router/common/media_source_unittest.cc
index 5340b7b..0f161f86 100644
--- a/components/media_router/common/media_source_unittest.cc
+++ b/components/media_router/common/media_source_unittest.cc
@@ -251,11 +251,28 @@
.IsDialSource());
}
+TEST(MediaSourceTest, IsDialAppName) {
+ EXPECT_TRUE(IsDialAppName("YouTube"));
+ EXPECT_TRUE(IsDialAppName("com.google.YouTube"));
+ EXPECT_TRUE(IsDialAppName("App_Name-1.2~"));
+ EXPECT_FALSE(IsDialAppName(""));
+ EXPECT_FALSE(IsDialAppName("App Name"));
+ EXPECT_FALSE(IsDialAppName("App/Name"));
+ EXPECT_FALSE(IsDialAppName("../Name"));
+ EXPECT_FALSE(IsDialAppName("App\nName"));
+}
+
TEST(MediaSourceTest, AppNameFromDialSource) {
MediaSource media_source(
"cast-dial:YouTube?dialPostData=postData&clientId=1234");
EXPECT_EQ("YouTube", media_source.AppNameFromDialSource());
+ media_source = MediaSource("cast-dial:../YouTube");
+ EXPECT_TRUE(media_source.AppNameFromDialSource().empty());
+
+ media_source = MediaSource("cast-dial:App/Name");
+ EXPECT_TRUE(media_source.AppNameFromDialSource().empty());
+
media_source = MediaSource("dial:YouTube");
EXPECT_TRUE(media_source.AppNameFromDialSource().empty());
Original Bug Report
SSRF and PNA Bypass via Path Traversal in cast-dial URLs during DIAL App Discovery
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 security team.
Overview: A potential vulnerability in Chrome’s Media Router allows any HTTPS website to perform a write-only Server-Side Request Forgery (SSRF) against local network devices. By providing a cast-dial: URL containing path traversal sequences to the PresentationRequest API, an attacker can bypass Private Network Access (PNA) protections. This occurs because the browser process concatenates the uncanonicalized URL path and issues a privileged HTTP GET request without requiring a user gesture.
Affected files:
chrome/browser/media/router/discovery/dial/dial_app_discovery_service.cccomponents/media_router/common/media_source.ccchrome/browser/media/router/discovery/dial/dial_url_fetcher.ccchrome/browser/media/router/providers/dial/dial_media_route_provider.cc
Estimated timestamp from git blame: 2025-09-30
Summary
A potential vulnerability exists in the browser process where a malicious web page can force Chrome to issue privileged HTTP GET requests to arbitrary paths on DIAL-capable devices (e.g., Smart TVs, streaming sticks) on the user’s local network. This bypasses Private Network Access (PNA) protections and Cross-Origin Resource Sharing (CORS).
Technical Details
1. Scheme Canonicalization Asymmetry:
The vulnerability originates from how different URL schemes are canonicalized. The cast-dial: scheme is not registered as a standard URL scheme. When GURL parses a cast-dial: URL (e.g., cast-dial:../../admin/reboot), it treats it as a ‘path URL’ and does not resolve or collapse .. segments.
In components/media_router/common/media_source.cc, MediaSource::AppNameFromDialSource simply returns url_.GetPath(), which yields the raw, uncanonicalized string containing the traversal segments.
2. Path Traversal via String Concatenation:
In chrome/browser/media/router/discovery/dial/dial_app_discovery_service.cc, GetAppUrl performs a raw string concatenation of the DIAL device’s base URL and the extracted app name:
GURL(partial_app_url.spec() + "/" + app_name)
When app_name contains path traversal characters (e.g., ../../admin/reboot) and the base URL is a standard HTTP URL (e.g., http://192.168.1.100:8008/apps), the concatenated string becomes http://192.168.1.100:8008/apps/../../admin/reboot. When this is passed back into the GURL constructor, standard http: canonicalization kicks in, collapsing the path to http://192.168.1.100:8008/admin/reboot.
3. Confused Deputy and PNA Bypass:
The Browser process issues this DIAL probe via DialURLFetcher, which obtains a URLLoaderFactory from the SystemNetworkContextManager. This factory is highly privileged (is_trusted = true) and is created without a ClientSecurityState. Consequently, the Network Service (LocalNetworkAccessChecker) returns kAllowedMissingClientSecurityState, bypassing all Private Network Access (PNA) checks.
Furthermore, while PresentationRequest.start() requires a user gesture, calling PresentationRequest.getAvailability() does not, allowing a background script to trigger this sequence silently.
Impact
This is a write-only Server-Side Request Forgery (SSRF). While the attacker cannot read the HTTP response, they can trigger side-effecting GET requests against unauthenticated REST endpoints commonly found on IoT devices and Smart TVs on the local network.
Potential Reproduction Steps
Note: Our tooling agent does not currently have the ability to run code. These are suggested steps to theoretically trigger the vulnerability based on static analysis.
- Host a DIAL-advertising server on the local network (or use a real Smart TV) that advertises an
Application-URL(e.g.,http://192.168.1.100:8008/apps). - From any HTTPS origin, execute the following JavaScript:
new PresentationRequest(['cast-dial:../../poc/triggered']).getAvailability(); - Observe the network traffic on the DIAL server. It will receive an HTTP GET request to
/poc/triggeredrather than the intended/apps/...path.
Suggested Fix
- Sanitize App Name: In
MediaSource::AppNameFromDialSourceorDialAppDiscoveryService::GetAppUrl, validate that theapp_namedoes not contain invalid characters like/,\, or... A DIAL app name should typically be an alphanumeric identifier. - Safe URL Construction: Avoid raw string concatenation when building URLs.
- PNA Enforcement: Consider explicitly attaching a
ClientSecurityStateor routing these requests through a less privileged network context so that PNA policies are appropriately enforced, even for browser-initiated discovery probes.
Evaluated with Chrome root at commit: ff3d2b74fa39431785bd60e51463b08fcc71ee33
Results so far have been promising, but there can be wrong deductions. If this proves to be a false positive, please close as WAI; 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.