Medium chrome Logic Error 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInappropriate implementation in Cast
DescriptionInappropriate implementation in Cast
ComponentCast
Bug ClassLogic Error
Tracker514460133
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 <[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()) {
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 [email protected]

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.cc
  • components/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.)

  1. An attacker hosts a malicious website on a non-allowlisted origin.
  2. 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");
  3. The browser extracts the app name "youtube" and discovers local DIAL devices supporting the app (as the devices often match case-insensitively).
  4. DialMediaRouteProvider::GetOrigins("youtube") fails to find a match in the allowlist and returns an empty origin list.
  5. MediaSinksObserver::OnSinksUpdated interprets the empty list as “allow all origins” and makes the discovered local devices visible to the attacker’s origin via the Cast dialog.
  6. The user, seeing their own device in the dialog, clicks it.
  7. DialActivityManager::LaunchApp constructs a DIAL launch HTTP POST request to the device, including the attacker’s dialPostData payload, successfully launching the app with malicious data.

Suggested Fix

There are two primary ways to fix this:

  1. Case-Insensitive Lookup: Modify DialMediaRouteProvider::GetOrigins to perform a case-insensitive lookup, or normalize the app_name to a known case before querying the origin_allowlist. A base::flat_map with base::CompareCase::INSENSITIVE_ASCII could be used.
  2. Restrict Unknown Apps: If the intent is that only apps in the allowlist should be discoverable/launchable via the Presentation API, GetOrigins should not return an empty list for unknown apps, or MediaSinksObserver should 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.

View on issue tracker