Overview

Low
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
Tracker496298665
Fix commit8e0a7712b6b7 (chromium/src) +68/-11
CISA KEVNot listed
CreditedGoogle
Disclosed2026-05-05

Changed Functions

FunctionChangeNotes
if
chrome/browser/media/router/providers/dial/dial_activity_manager.cc
modified
InvokeWithoutArgs
chrome/browser/media/router/providers/dial/dial_activity_manager_unittest.cc
modified
TEST_F
chrome/browser/media/router/providers/dial/dial_activity_manager_unittest.cc
modified

Files Changed

  • chrome/browser/media/router/providers/dial/dial_activity_manager.cc
  • chrome/browser/media/router/providers/dial/dial_activity_manager_unittest.cc
  • chrome/browser/media/router/test/provider_test_helpers.cc
  • chrome/browser/media/router/test/provider_test_helpers.h
From 8e0a7712b6b7f6f6d2e65a36769648c2db895438 Mon Sep 17 00:00:00 2001
From: Muyao Xu <[email protected]>
Date: Tue, 31 Mar 2026 12:43:36 -0700
Subject: [PATCH] [Media Router] Validate DIAL app instance URL IP address

This CL updates DialActivityManager to always verify that the host IP
address of the application instance URL (received via the Location
header) matches the expected IP address of the DIAL sink.

Bug: 496298665
Change-Id: Ia1df1747a96ea742fee79aa1de5e8905bd8c083a
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7712956
Commit-Queue: Jordan Bayles <[email protected]>
Reviewed-by: Jordan Bayles <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1608027}
---

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 ac56878..a0ec269 100644
--- a/chrome/browser/media/router/providers/dial/dial_activity_manager.cc
+++ b/chrome/browser/media/router/providers/dial/dial_activity_manager.cc
@@ -30,7 +30,8 @@
 // Returns the Application Instance URL from the POST response headers given by
 // |response_info|.
 GURL GetApplicationInstanceURL(
-    const network::mojom::URLResponseHead& response_info) {
+    const network::mojom::URLResponseHead& response_info,
+    const net::IPAddress& expected_ip) {
   if (!response_info.headers) {
     return GURL();
   }
@@ -53,6 +54,12 @@
     return GURL();
   }
 
+  net::IPAddress host_address;
+  if (!net::ParseURLHostnameToAddress(app_instance_url.host(), &host_address) ||
+      host_address != expected_ip) {
+    return GURL();
+  }
+
   return app_instance_url;
 }
 
@@ -301,7 +308,8 @@
       record->pending_launch_request->fetcher->GetResponseHead();
 
   DCHECK(response_info);
-  record->app_instance_url = GetApplicationInstanceURL(*response_info);
+  record->app_instance_url = GetApplicationInstanceURL(
+      *response_info, record->activity.sink.dial_data().ip_address);
   record->state = DialActivityManager::Record::State::kLaunched;
   std::move(record->pending_launch_request->callback).Run(true);
   record->pending_launch_request.reset();
diff --git a/chrome/browser/media/router/providers/dial/dial_activity_manager_unittest.cc b/chrome/browser/media/router/providers/dial/dial_activity_manager_unittest.cc
index a202da90..2fb90c8b 100644
--- a/chrome/browser/media/router/providers/dial/dial_activity_manager_unittest.cc
+++ b/chrome/browser/media/router/providers/dial/dial_activity_manager_unittest.cc
@@ -80,8 +80,11 @@
     loader_factory_.AddResponse(activity.launch_info.app_launch_url,
                                 std::move(response_head), "",
                                 network::URLLoaderCompletionStatus());
-    EXPECT_CALL(*this, OnAppLaunchResult(true));
-    base::RunLoop().RunUntilIdle();
+    base::RunLoop run_loop;
+    EXPECT_CALL(*this, OnAppLaunchResult(true))
+        .WillOnce(
+            testing::InvokeWithoutArgs([&run_loop]() { run_loop.Quit(); }));
+    run_loop.Run();
 
     auto routes = manager_.GetRoutes();
     EXPECT_EQ(1u, routes.size());
@@ -129,9 +132,13 @@
     loader_factory_.AddResponse(
         app_instance_url, network::mojom::URLResponseHead::New(), "",
         network::URLLoaderCompletionStatus(net::HTTP_SERVICE_UNAVAILABLE));
+    base::RunLoop run_loop;
+    EXPECT_CALL(app_discovery_service_, DoFetchDialAppInfo(_, _))
+        .WillOnce(
+            testing::InvokeWithoutArgs([&run_loop]() { run_loop.Quit(); }));
     EXPECT_CALL(*this,
                 OnStopAppResult(_, Not(mojom::RouteRequestResultCode::OK)));
-    base::RunLoop().RunUntilIdle();
+    run_loop.Run();
     return activity;
   }
 
@@ -217,8 +224,10 @@
       activity->launch_info.app_launch_url,
       network::mojom::URLResponseHead::New(), "",
       network::URLLoaderCompletionStatus(net::HTTP_SERVICE_UNAVAILABLE));
-  EXPECT_CALL(*this, OnAppLaunchResult(false));
-  base::RunLoop().RunUntilIdle();
+  base::RunLoop run_loop;
+  EXPECT_CALL(*this, OnAppLaunchResult(false))
+      .WillOnce(testing::InvokeWithoutArgs([&run_loop]() { run_loop.Quit(); }));
+  run_loop.Run();
 
   // Activity is removed on failure.
   EXPECT_TRUE(manager_.GetRoutes().empty());
@@ -248,9 +257,11 @@
   loader_factory_.AddResponse(app_instance_url,
                               network::mojom::URLResponseHead::New(), "",
                               network::URLLoaderCompletionStatus());
+  base::RunLoop run_loop;
   EXPECT_CALL(*this, OnStopAppResult(testing::Eq(std::nullopt),
-                                     mojom::RouteRequestResultCode::OK));
-  base::RunLoop().RunUntilIdle();
+                                     mojom::RouteRequestResultCode::OK))
+      .WillOnce(testing::InvokeWithoutArgs([&run_loop]() { run_loop.Quit(); }));
+  run_loop.Run();
 
   EXPECT_TRUE(manager_.GetRoutes().empty());
 }
@@ -271,9 +282,11 @@
   loader_factory_.AddResponse(app_instance_url,
                               network::mojom::URLResponseHead::New(), "",
                               network::URLLoaderCompletionStatus());
+  base::RunLoop run_loop;
   EXPECT_CALL(*this, OnStopAppResult(testing::Eq(std::nullopt),
-                                     mojom::RouteRequestResultCode::OK));
-  base::RunLoop().RunUntilIdle();
+                                     mojom::RouteRequestResultCode::OK))
+      .WillOnce(testing::InvokeWithoutArgs([&run_loop]() { run_loop.Quit(); }));
+  run_loop.Run();
 
   EXPECT_TRUE(manager_.GetRoutes().empty());
 }
@@ -306,4 +319,35 @@
   EXPECT_TRUE(manager_.GetRoutes().empty());
 }
 
+TEST_F(DialActivityManagerTest, StopAppMaliciousLocationHeader) {
+  auto activity =
+      DialActivity::From(presentation_id_, sink_, source_id_, origin_);
+  ASSERT_TRUE(activity);
+  manager_.AddActivity(*activity);
+
+  // Malicious URL targeting localhost
+  GURL malicious_url("http://127.0.0.1:8080/api/admin/delete_all");
+
+  // Launch the app and receive the malicious LOCATION header
+  TestLaunchApp(*activity, std::nullopt, malicious_url);
+
+  // When stopping the app, it should NOT send a request to the malicious URL.
+  // Instead, it should use the fallback URL because the malicious one was
+  // rejected.
+  GURL fallback_url(activity->launch_info.app_launch_url.spec() + "/run");
+  manager_.SetExpectedRequest(fallback_url, "DELETE", std::nullopt);
+  StopApp(activity->route.media_route_id());
+
+  loader_factory_.AddResponse(fallback_url,
+                              network::mojom::URLResponseHead::New(), "",
+                              network::URLLoaderCompletionStatus());
+  base::RunLoop run_loop;
+  EXPECT_CALL(*this, OnStopAppResult(testing::Eq(std::nullopt),
+                                     mojom::RouteRequestResultCode::OK))
+      .WillOnce(testing::InvokeWithoutArgs([&run_loop]() { run_loop.Quit(); }));
+  run_loop.Run();
+
+  EXPECT_TRUE(manager_.GetRoutes().empty());
+}
+
 }  // namespace media_router
diff --git a/chrome/browser/media/router/test/provider_test_helpers.cc b/chrome/browser/media/router/test/provider_test_helpers.cc
index fa9de8f..ca56987 100644
--- a/chrome/browser/media/router/test/provider_test_helpers.cc
+++ b/chrome/browser/media/router/test/provider_test_helpers.cc
@@ -66,6 +66,10 @@
   return std::move(app_info_cb_);
 }
 
+bool MockDialAppDiscoveryService::HasPendingCallback() const {
+  return !app_info_cb_.is_null();
+}
+
 TestDialURLFetcher::TestDialURLFetcher(
     DialURLFetcher::SuccessCallback success_cb,
     DialURLFetcher::ErrorCallback error_cb,
diff --git a/chrome/browser/media/router/test/provider_test_helpers.h b/chrome/browser/media/router/test/provider_test_helpers.h
index ef2da27..b4d1d20be 100644
--- a/chrome/browser/media/router/test/provider_test_helpers.h
+++ b/chrome/browser/media/router/test/provider_test_helpers.h
@@ -97,6 +97,7 @@
                void(const MediaSink::Id& sink_id, const std::string& app_name));
 
   DialAppInfoCallback PassCallback();
+  bool HasPendingCallback() const;
 
  private:
   DialAppInfoCallback app_info_cb_;
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/media/router/providers/dial/dial_activity_manager_unittest.cc b/chrome/browser/media/router/providers/dial/dial_activity_manager_unittest.cc
index a202da90..2fb90c8b 100644
--- a/chrome/browser/media/router/providers/dial/dial_activity_manager_unittest.cc
+++ b/chrome/browser/media/router/providers/dial/dial_activity_manager_unittest.cc
@@ -80,8 +80,11 @@
     loader_factory_.AddResponse(activity.launch_info.app_launch_url,
                                 std::move(response_head), "",
                                 network::URLLoaderCompletionStatus());
-    EXPECT_CALL(*this, OnAppLaunchResult(true));
-    base::RunLoop().RunUntilIdle();
+    base::RunLoop run_loop;
+    EXPECT_CALL(*this, OnAppLaunchResult(true))
+        .WillOnce(
+            testing::InvokeWithoutArgs([&run_loop]() { run_loop.Quit(); }));
+    run_loop.Run();
 
     auto routes = manager_.GetRoutes();
     EXPECT_EQ(1u, routes.size());
@@ -129,9 +132,13 @@
     loader_factory_.AddResponse(
         app_instance_url, network::mojom::URLResponseHead::New(), "",
         network::URLLoaderCompletionStatus(net::HTTP_SERVICE_UNAVAILABLE));
+    base::RunLoop run_loop;
+    EXPECT_CALL(app_discovery_service_, DoFetchDialAppInfo(_, _))
+        .WillOnce(
+            testing::InvokeWithoutArgs([&run_loop]() { run_loop.Quit(); }));
     EXPECT_CALL(*this,
                 OnStopAppResult(_, Not(mojom::RouteRequestResultCode::OK)));
-    base::RunLoop().RunUntilIdle();
+    run_loop.Run();
     return activity;
   }
 
@@ -217,8 +224,10 @@
       activity->launch_info.app_launch_url,
       network::mojom::URLResponseHead::New(), "",
       network::URLLoaderCompletionStatus(net::HTTP_SERVICE_UNAVAILABLE));
-  EXPECT_CALL(*this, OnAppLaunchResult(false));
-  base::RunLoop().RunUntilIdle();
+  base::RunLoop run_loop;
+  EXPECT_CALL(*this, OnAppLaunchResult(false))
+      .WillOnce(testing::InvokeWithoutArgs([&run_loop]() { run_loop.Quit(); }));
+  run_loop.Run();
 
   // Activity is removed on failure.
   EXPECT_TRUE(manager_.GetRoutes().empty());
@@ -248,9 +257,11 @@
   loader_factory_.AddResponse(app_instance_url,
                               network::mojom::URLResponseHead::New(), "",
                               network::URLLoaderCompletionStatus());
+  base::RunLoop run_loop;
   EXPECT_CALL(*this, OnStopAppResult(testing::Eq(std::nullopt),
-                                     mojom::RouteRequestResultCode::OK));
-  base::RunLoop().RunUntilIdle();
+                                     mojom::RouteRequestResultCode::OK))
+      .WillOnce(testing::InvokeWithoutArgs([&run_loop]() { run_loop.Quit(); }));
+  run_loop.Run();
 
   EXPECT_TRUE(manager_.GetRoutes().empty());
 }
@@ -271,9 +282,11 @@
   loader_factory_.AddResponse(app_instance_url,
                               network::mojom::URLResponseHead::New(), "",
                               network::URLLoaderCompletionStatus());
+  base::RunLoop run_loop;
   EXPECT_CALL(*this, OnStopAppResult(testing::Eq(std::nullopt),
-                                     mojom::RouteRequestResultCode::OK));
-  base::RunLoop().RunUntilIdle();
+                                     mojom::RouteRequestResultCode::OK))
+      .WillOnce(testing::InvokeWithoutArgs([&run_loop]() { run_loop.Quit(); }));
+  run_loop.Run();
 
   EXPECT_TRUE(manager_.GetRoutes().empty());
 }
@@ -306,4 +319,35 @@
   EXPECT_TRUE(manager_.GetRoutes().empty());
 }
 
+TEST_F(DialActivityManagerTest, StopAppMaliciousLocationHeader) {
+  auto activity =
+      DialActivity::From(presentation_id_, sink_, source_id_, origin_);
+  ASSERT_TRUE(activity);
+  manager_.AddActivity(*activity);
+
+  // Malicious URL targeting localhost
+  GURL malicious_url("http://127.0.0.1:8080/api/admin/delete_all");
+
+  // Launch the app and receive the malicious LOCATION header
+  TestLaunchApp(*activity, std::nullopt, malicious_url);
+
+  // When stopping the app, it should NOT send a request to the malicious URL.
+  // Instead, it should use the fallback URL because the malicious one was
+  // rejected.
+  GURL fallback_url(activity->launch_info.app_launch_url.spec() + "/run");
+  manager_.SetExpectedRequest(fallback_url, "DELETE", std::nullopt);
+  StopApp(activity->route.media_route_id());
+
+  loader_factory_.AddResponse(fallback_url,
+                              network::mojom::URLResponseHead::New(), "",
+                              network::URLLoaderCompletionStatus());
+  base::RunLoop run_loop;
+  EXPECT_CALL(*this, OnStopAppResult(testing::Eq(std::nullopt),
+                                     mojom::RouteRequestResultCode::OK))
+      .WillOnce(testing::InvokeWithoutArgs([&run_loop]() { run_loop.Quit(); }));
+  run_loop.Run();
+
+  EXPECT_TRUE(manager_.GetRoutes().empty());
+}
+
 }  // namespace media_router
diff --git a/chrome/browser/media/router/test/provider_test_helpers.cc b/chrome/browser/media/router/test/provider_test_helpers.cc
index fa9de8f..ca56987 100644
--- a/chrome/browser/media/router/test/provider_test_helpers.cc
+++ b/chrome/browser/media/router/test/provider_test_helpers.cc
@@ -66,6 +66,10 @@
   return std::move(app_info_cb_);
 }
 
+bool MockDialAppDiscoveryService::HasPendingCallback() const {
+  return !app_info_cb_.is_null();
+}
+
 TestDialURLFetcher::TestDialURLFetcher(
     DialURLFetcher::SuccessCallback success_cb,
     DialURLFetcher::ErrorCallback error_cb,
diff --git a/chrome/browser/media/router/test/provider_test_helpers.h b/chrome/browser/media/router/test/provider_test_helpers.h
index ef2da27..b4d1d20be 100644
--- a/chrome/browser/media/router/test/provider_test_helpers.h
+++ b/chrome/browser/media/router/test/provider_test_helpers.h
@@ -97,6 +97,7 @@
                void(const MediaSink::Id& sink_id, const std::string& app_name));
 
   DialAppInfoCallback PassCallback();
+  bool HasPendingCallback() const;
 
  private:
   DialAppInfoCallback app_info_cb_;
Loading diff…

Original Bug Report

reported by [email protected]

SSRF via unvalidated DIAL Application Instance URL

Project Fortify, an experimental security project, has identified the following potential security issue.

Overview: Chrome’s DIAL implementation fails to validate the host in the Application Instance URL returned by a DIAL server. This allows a malicious local device to force the browser process to send an unauthenticated HTTP DELETE request to arbitrary destinations, including localhost, bypassing Private Network Access protections.

Affected files:

  • chrome/browser/media/router/providers/dial/dial_activity_manager.cc
  • chrome/browser/media/router/discovery/dial/dial_url_fetcher.cc
  • chrome/browser/media/router/discovery/dial/dial_device_data.cc

Estimated timestamp from git blame: 2024-12-23

Description

Chrome’s Media Router implements the DIAL (Discovery and Launch) protocol to discover and interact with devices on the local network. A potential blind SSRF (Server-Side Request Forgery) vulnerability exists because the browser fails to validate the host of the Application Instance URL returned by a device during an app launch.

When a user initiates a Cast session to a DIAL device, Chrome sends an HTTP POST request to the device. A successful response (HTTP 201 Created) includes a LOCATION header containing the Application Instance URL.

In chrome/browser/media/router/providers/dial/dial_activity_manager.cc, the GetApplicationInstanceURL function extracts this URL:

  GURL app_instance_url(*location_header);
  if (!app_instance_url.is_valid() || !app_instance_url.SchemeIs("http")) {
    return GURL();
  }
  return app_instance_url;

Crucially, it only verifies that the URL is a valid HTTP URL. It does not validate the host/IP address. Other DIAL-related URLs are strictly validated (e.g., via DialDeviceData::IsValidUrl) to ensure they point to a non-publicly routable IP that matches the device’s original SSDP source IP.

When the user later stops the Cast session, DialActivityManager::StopApp retrieves this unvalidated URL and uses DialURLFetcher::Delete(app_instance_url) to terminate the app.

DialURLFetcher constructs an uncredentialed DELETE request using the system URLLoaderFactory (which is trusted). Because the request originates directly from the browser process, it bypasses CORS and Private Network Access (PNA) restrictions. This allows a LAN attacker to pivot through the victim’s browser to send blind DELETE requests to arbitrary targets, such as 127.0.0.1 or internal enterprise endpoints.

Potential Reproduction Steps

Note: These are potential steps to trigger the vulnerability, as our setup does not currently have the ability to run code to verify them dynamically.

  1. An attacker on the same local network as the victim runs a malicious SSDP responder, advertising a fake DIAL device.
  2. The victim opens the Chrome Cast menu and selects the malicious device to start a session.
  3. Chrome sends a POST request to the device’s application URL.
  4. The attacker’s server responds with HTTP/1.1 201 Created and includes a malicious header, e.g., Location: http://127.0.0.1:8080/api/admin/delete_all.
  5. The victim subsequently stops the cast session (either manually or because the session fails to play media).
  6. Chrome’s browser process blindly issues an HTTP DELETE request to http://127.0.0.1:8080/api/admin/delete_all, bypassing PNA restrictions.

Suggested Fix

The Application Instance URL must be validated to ensure its host matches the expected IP address of the DIAL device.

In DialActivityManager, the validation logic should be updated to ensure the host of the app_instance_url matches the IP address of the MediaSinkInternal (the DIAL device) being interacted with, similar to the checks performed in DialDeviceData::IsValidUrl().

Evaluated with Chrome root at commit: 0eb4855bda702feaaa8b899336664f97e3df88b8


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. Please feel free to reach out to me if you have concerns or feedback.

View on issue tracker