Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in Cast
DescriptionInsufficient validation of untrusted input in Cast
ComponentCast
Bug ClassLogic Error
Tracker513022076
Fix commitdbd5ebafd072 (chromium/src) +99/-12
CISA KEVNot listed
CreditedGoogle
Disclosed2026-07-29

Changed Functions

FunctionChangeNotes
TEST_F
chrome/browser/media/router/discovery/dial/device_description_service_unittest.cc
modified
for
chrome/browser/media/router/providers/dial/dial_media_route_provider.cc
modified
TEST_F
chrome/browser/media/router/providers/dial/dial_media_route_provider_unittest.cc
modified

Files Changed

  • chrome/browser/media/router/discovery/dial/device_description_service.cc
  • chrome/browser/media/router/discovery/dial/device_description_service_unittest.cc
  • chrome/browser/media/router/providers/dial/dial_media_route_provider.cc
  • chrome/browser/media/router/providers/dial/dial_media_route_provider_unittest.cc
  • components/media_router/common/media_source.cc
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()) {
Loading diff…

Regression Test / PoC

shipped with the fix
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"));
 }
Loading diff…

Original Bug Report

reported by vm...@google.com

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.cc
  • chrome/browser/media/router/providers/dial/dial_media_route_provider.cc
  • chrome/browser/media/router/discovery/dial/dial_app_discovery_service.cc
  • chrome/browser/media/router/discovery/dial/dial_media_sink_service_impl.cc
  • chrome/browser/media/router/discovery/dial/dial_url_fetcher.cc
  • chrome/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:

  1. The attacker’s page calls new PresentationRequest(['cast-dial:..?clientId=1']).getAvailability().
  2. The browser process extracts the app name ".." from the cast-dial: URL. IsDialAppName("..") returns true.
  3. For every DIAL-compatible device discovered on the local network, the DialAppDiscoveryService constructs a URL by resolving ".." against the device’s Application-URL.
  4. The DialURLFetcher issues 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.

View on issue tracker