CVE-2026-8005
Overview
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
Locationheader, 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.
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
- Establish a DIAL activity A user launches a DIAL app via Media Router to a sink on the local network.
- Return a malicious Location header
A malicious/compromised DIAL device (or network attacker) responds to the launch POST with a
Locationheader pointing at an attacker-chosen host, e.g.http://127.0.0.1:8080/api/admin/delete_all. - Chrome stores the forged URL
Pre-patch,
GetApplicationInstanceURLreturns that URL unchecked and the caller records it asrecord->app_instance_url. - 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
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
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/media/router/providers/dial/dial_activity_manager.cc |
modified | |
InvokeWithoutArgschrome/browser/media/router/providers/dial/dial_activity_manager_unittest.cc |
modified | |
TEST_Fchrome/browser/media/router/providers/dial/dial_activity_manager_unittest.cc |
modified |
Files Changed
chrome/browser/media/router/providers/dial/dial_activity_manager.ccchrome/browser/media/router/providers/dial/dial_activity_manager_unittest.ccchrome/browser/media/router/test/provider_test_helpers.ccchrome/browser/media/router/test/provider_test_helpers.h
Audit Directions
- Header-derived URLsAudit 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 sinksReview 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 bindingConfirm 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.
Patch
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_;
Regression Test / PoC
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_;
Original Bug Report
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.ccchrome/browser/media/router/discovery/dial/dial_url_fetcher.ccchrome/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.
- An attacker on the same local network as the victim runs a malicious SSDP responder, advertising a fake DIAL device.
- The victim opens the Chrome Cast menu and selects the malicious device to start a session.
- Chrome sends a POST request to the device’s application URL.
- The attacker’s server responds with
HTTP/1.1 201 Createdand includes a malicious header, e.g.,Location: http://127.0.0.1:8080/api/admin/delete_all. - The victim subsequently stops the cast session (either manually or because the session fails to play media).
- Chrome’s browser process blindly issues an HTTP
DELETErequest tohttp://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.