Low watch CVSS 4.3 chrome Input Validation 📄 Reporter bug report 🔧 Commit mapped

Overview

Low
Severity
4.3
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in Cast
DescriptionInsufficient validation of untrusted input in Cast
ComponentCast
Sub-componentMedia Router
Bug ClassInput Validation
Action tierwatch — logic/policy bug — security-relevant, no memory-unsafety
Computed by PatchGap's deterministic exploitability ruler from the fixing commit. patch-now = RCE-class & attacker-reachable in a default config, or exploited in the wild; watch = real security signal below that bar; noise = no security relevance.
Tracker496298665
Fix commit8e0a7712b6b7 (chromium/src) +68/-11
CWECWE-20 (Improper input validation)
CVSS vectorCVSS:3.1/AV:A/AC:L/PR:N/UI:N/S:U/C:L/I:N/A:N
CISA KEVNot listed
CreditedGoogle
Reported2026-03-25 (per vendor advisory)
Fix landed2026-03-31 (public git)
Disclosed2026-05-05
Patch gap35 days — fix public in source before the advisory

Background

DIAL
The Discovery And Launch protocol Chrome’s Media Router uses to launch and stop apps on networked devices (e.g. smart TVs); a launch POST response returns an application-instance URL.
Application Instance URL
The URL, taken from the launch response’s Location header, that Chrome later uses to control (e.g. stop/DELETE) the launched app.
`GetApplicationInstanceURL`
The helper that extracts and validates that URL from network::mojom::URLResponseHead.
`net::ParseURLHostnameToAddress`
Parses a URL hostname into a net::IPAddress, enabling comparison against the known sink IP.

Root Cause Analysis

GetApplicationInstanceURL extracted the application-instance URL solely from the Location response header and returned it without verifying that its host pointed at the DIAL sink Chrome was actually talking to. Because the Location header is attacker-influenceable (a malicious or spoofed device, or a network attacker on the response path), the returned URL’s host could be redirected to an arbitrary address — including internal/loopback endpoints such as http://127.0.0.1:8080/api/admin/delete_all — which Chrome would store as record->app_instance_url and later issue control requests (e.g. a DELETE on stop) against, yielding a server-side request forgery. The violated invariant is that the app-instance URL must reside on the same host as the DIAL sink.

The fix adds an expected_ip parameter (passed as record->activity.sink.dial_data().ip_address from the caller), parses the candidate URL’s host with net::ParseURLHostnameToAddress, and returns an empty GURL() if parsing fails or host_address != expected_ip; the empty URL causes the manager to fall back to the safe default URL instead of the attacker’s target, as the regression test StopAppMaliciousLocationHeader asserts.

Key insight
The app-instance URL was trusted from the device’s Location header with no host check; the fix validates that the URL’s host IP equals the known DIAL sink’s IP and rejects (empties) it otherwise, preventing redirection of control requests to arbitrary hosts.

Attack Path

  1. Establish a DIAL activity A user launches a DIAL app via Media Router to a sink on the local network.
  2. Return a malicious Location header A malicious/compromised DIAL device (or network attacker) responds to the launch POST with a Location header pointing at an attacker-chosen host, e.g. http://127.0.0.1:8080/api/admin/delete_all.
  3. Chrome stores the forged URL Pre-patch, GetApplicationInstanceURL returns that URL unchecked and the caller records it as record->app_instance_url.
  4. Control request to attacker target On a later stop/control action, Chrome issues a request (e.g. DELETE) to the stored URL, reaching an internal/loopback service it should never target (server-side request forgery); the full impact of such internal requests is not shown by the diff (inferred).

Impact Assessment

A malicious DIAL device or on-path network attacker could redirect Chrome’s DIAL app-control requests to an arbitrary host, including loopback/internal services, by supplying a crafted Location header, giving a server-side-request-forgery primitive issued from the browser process. Preconditions are an active DIAL activity and the ability to control the launch response’s Location header (a device on the user’s network or on the response path). The vendor advisory rates this low severity and classifies it as insufficient validation of untrusted input in Cast; no exploitation-in-the-wild is stated.

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

Audit Directions

  • Header-derived URLs
    Audit code that builds a request target from a response header (Location, redirect URLs) from a networked peer and ensure the resulting host is validated against the expected endpoint before any follow-up request.
  • Loopback/internal SSRF sinks
    Review Media Router and other device-integration code for paths that fetch or POST to URLs whose host originates from untrusted device responses, checking for loopback/private-range restrictions.
  • Sink-identity binding
    Confirm that URLs used to control a discovered device are bound to that device’s known IP/identity rather than accepted from the device’s own response data.
From 8e0a7712b6b7f6f6d2e65a36769648c2db895438 Mon Sep 17 00:00:00 2001
From: Muyao Xu <muyaoxu@google.com>
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 <jophba@chromium.org>
Reviewed-by: Jordan Bayles <jophba@chromium.org>
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 vm...@google.com

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