Overview

Low
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient validation of untrusted input in WebAuthentication
DescriptionInsufficient validation of untrusted input in WebAuthentication
ComponentWebAuthentication
Bug ClassLogic Error
Tracker497609145
Fix commit24b759e61e43 (chromium/src) +14/-961
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-02

Changed Functions

FunctionChangeNotes
if
chrome/browser/webauthn/authenticator_request_dialog_controller.cc
modified
GetChallengeUrlFetcher
chrome/browser/webauthn/authenticator_request_dialog_controller.cc
modified
GetWeakPtr
chrome/browser/webauthn/authenticator_request_dialog_controller.cc
modified
ChallengeUrlFetcher
chrome/browser/webauthn/authenticator_request_dialog_controller.h
modified
PasskeyUpgradeRequestController
chrome/browser/webauthn/authenticator_request_dialog_controller.h
modified
Profile
chrome/browser/webauthn/authenticator_request_dialog_controller.h
modified

Files Changed

  • chrome/browser/BUILD.gn
  • chrome/browser/ui/views/webauthn/sheet_view_factory.cc
  • chrome/browser/webauthn/authenticator_request_dialog_controller.cc
  • chrome/browser/webauthn/authenticator_request_dialog_controller.h
From 24b759e61e43eeb9f539ef32999ac69eea3823bd Mon Sep 17 00:00:00 2001
From: Ken Buchanan <[email protected]>
Date: Thu, 09 Apr 2026 12:59:07 -0700
Subject: [PATCH] [WebAuthn] Remove ChallengeURL prototype

The prototype is not currently on track to be turned into a shipping
feature, as developer demand for it is unclear.

There is a small maintenance burden from having it in the code base.
This CL removes the implementation. If a decision is made later to
commit to developing this feature it can be improved and re-written
at that time.

Fixed: 497609145
Change-Id: I1fe30d57e5d4f97d11aa6b29baa055afa29ef8ee
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7737001
Auto-Submit: Ken Buchanan <[email protected]>
Reviewed-by: Rainhard Findling <[email protected]>
Reviewed-by: Nina Satragno <[email protected]>
Reviewed-by: Dave Tapuska <[email protected]>
Commit-Queue: Ken Buchanan <[email protected]>
Reviewed-by: Stephen McGruer <[email protected]>
Cr-Commit-Position: refs/heads/main@{#1612451}
---

diff --git a/chrome/browser/BUILD.gn b/chrome/browser/BUILD.gn
index 0704bd8..7a690fe 100644
--- a/chrome/browser/BUILD.gn
+++ b/chrome/browser/BUILD.gn
@@ -3524,8 +3524,6 @@
       "webauthn/authenticator_request_scheduler.h",
       "webauthn/cablev2_devices.cc",
       "webauthn/cablev2_devices.h",
-      "webauthn/challenge_url_fetcher.cc",
-      "webauthn/challenge_url_fetcher.h",
       "webauthn/change_pin_controller.cc",
       "webauthn/change_pin_controller.h",
       "webauthn/change_pin_controller_impl.cc",
diff --git a/chrome/browser/ui/views/webauthn/sheet_view_factory.cc b/chrome/browser/ui/views/webauthn/sheet_view_factory.cc
index 2a4881e..f033aa8 100644
--- a/chrome/browser/ui/views/webauthn/sheet_view_factory.cc
+++ b/chrome/browser/ui/views/webauthn/sheet_view_factory.cc
@@ -387,11 +387,6 @@
       sheet_view = std::make_unique<AuthenticatorRequestSheetView>(
           std::make_unique<AuthenticatorGPMLockedPinSheetModel>(dialog_model));
       break;
-    case Step::kErrorFetchingChallenge:
-      sheet_view = std::make_unique<AuthenticatorRequestSheetView>(
-          std::make_unique<AuthenticatorChallengeFetchErrorModel>(
-              dialog_model));
-      break;
     case Step::kNotStarted:
     case Step::kPasskeyAutofill:
     case Step::kPasskeyUpgrade:
diff --git a/chrome/browser/webauthn/authenticator_request_dialog_controller.cc b/chrome/browser/webauthn/authenticator_request_dialog_controller.cc
index 6a2e164..da26a12f7 100644
--- a/chrome/browser/webauthn/authenticator_request_dialog_controller.cc
+++ b/chrome/browser/webauthn/authenticator_request_dialog_controller.cc
@@ -49,7 +49,6 @@
 #include "chrome/browser/webauthn/authenticator_reference.h"
 #include "chrome/browser/webauthn/authenticator_request_dialog_model.h"
 #include "chrome/browser/webauthn/authenticator_transport.h"
-#include "chrome/browser/webauthn/challenge_url_fetcher.h"
 #include "chrome/browser/webauthn/change_pin_controller_impl.h"
 #include "chrome/browser/webauthn/credential_sorter_desktop.h"
 #include "chrome/browser/webauthn/gpm_enclave_transaction.h"
@@ -716,7 +715,6 @@
          model_->step() == Step::kKeyAlreadyRegistered ||
          model_->step() == Step::kMissingCapability ||
          model_->step() == Step::kErrorWindowsHelloNotEnabled ||
-         model_->step() == Step::kErrorFetchingChallenge ||
          model_->step() == Step::kClosed;
 }
 
@@ -792,8 +790,6 @@
       transport_availability_.platform_has_biometrics);
   constexpr bool kIsMac = BUILDFLAG(IS_MAC);
 
-  MaybeStartChallengeFetch();
-
   if (pending_step_) {
     SetCurrentStep(*pending_step_);
     pending_step_.reset();
@@ -1369,12 +1365,6 @@
   return true;
 }
 
-void AuthenticatorRequestDialogController::OnChallengeUrlFailure() {
-  if (!is_request_complete()) {
-    SetCurrentStep(Step::kErrorFetchingChallenge);
-  }
-}
-
 void AuthenticatorRequestDialogController::BluetoothAdapterStatusChanged(
     BleStatus ble_status) {
   transport_availability_.ble_status = ble_status;
@@ -1473,8 +1463,6 @@
   account_preselected_callback_.Run(*cred);
   model_->preselected_cred = *cred;
 
-  MaybeStartChallengeFetch();
-
   // `source` should not be `kPhone` here.
   if (source != AuthenticatorType::kEnclave) {
     HideDialogAndDispatchToPlatformAuthenticator(source);
@@ -1709,22 +1697,6 @@
   model_->set_ui_presentation(modality);
 }
 
-void AuthenticatorRequestDialogController::ProvideChallengeUrl(
-    const GURL& url,
-    base::OnceCallback<void(std::optional<base::span<const uint8_t>>)>
-        callback) {
-  CHECK(url.is_valid());
-  challenge_url_ = url;
-  challenge_callback_ = std::move(callback);
-
-  // Conditional requests don't initiate a challenge fetch unless and until the
-  // user triggers it, but modal requests always perform the fetch so it can
-  // be started immediately.
-  if (IsModalRequest(ui_presentation())) {
-    MaybeStartChallengeFetch();
-  }
-}
-
 void AuthenticatorRequestDialogController::InitializeEnclaveRequestCallback(
     device::FidoDiscoveryFactory* discovery_factory) {
   CHECK(!enclave_request_callback_);
@@ -1736,49 +1708,6 @@
   discovery_factory->set_enclave_ui_request_stream(std::move(event_stream));
 }
 
-void AuthenticatorRequestDialogController::MaybeStartChallengeFetch() {
-  if (!challenge_callback_) {
-    return;
-  }
-
-  auto challenge_or_error = GetChallengeUrlFetcher()->GetChallenge();
-  if (!challenge_or_error.has_value() &&
-      challenge_or_error.error() ==
-          ChallengeUrlFetcher::ChallengeNotAvailableReason::kNotRequested) {
-    GetChallengeUrlFetcher()->FetchUrl(
-        challenge_url_,
-        base::BindOnce(
-            &AuthenticatorRequestDialogController::OnChallengeFetched,
-            weak_factory_.GetWeakPtr()));
-  }
-}
-
-void AuthenticatorRequestDialogController::OnChallengeFetched() {
-  auto challenge_or_error = GetChallengeUrlFetcher()->GetChallenge();
-
-  if (challenge_or_error.has_value()) {
-    std::move(challenge_callback_).Run(challenge_or_error.value());
-    return;
-  }
-
-  CHECK_EQ(challenge_or_error.error(),
-           ChallengeUrlFetcher::ChallengeNotAvailableReason::
-               kErrorFetchingChallenge);
-
-  std::move(challenge_callback_).Run(std::nullopt);
-}
-
-ChallengeUrlFetcher*
-AuthenticatorRequestDialogController::GetChallengeUrlFetcher() {
-  if (!challenge_url_fetcher_) {
-    challenge_url_fetcher_ = std::make_unique<ChallengeUrlFetcher>(
-        Profile::FromBrowserContext(GetRenderFrameHost()->GetBrowserContext())
-            ->GetDefaultStoragePartition()
-            ->GetURLLoaderFactoryForBrowserProcess());
-  }
-  return challenge_url_fetcher_.get();
-}
-
 base::WeakPtr<AuthenticatorRequestDialogController>
 AuthenticatorRequestDialogController::GetWeakPtr() {
   return weak_factory_.GetWeakPtr();
diff --git a/chrome/browser/webauthn/authenticator_request_dialog_controller.h b/chrome/browser/webauthn/authenticator_request_dialog_controller.h
index 40431bcf..8ffb776 100644
--- a/chrome/browser/webauthn/authenticator_request_dialog_controller.h
+++ b/chrome/browser/webauthn/authenticator_request_dialog_controller.h
@@ -31,7 +31,6 @@
 #include "third_party/blink/public/mojom/credentialmanagement/credential_type_flags.mojom.h"
 #include "url/gurl.h"
 
-class ChallengeUrlFetcher;
 class PasskeyUpgradeRequestController;
 class Profile;
 
@@ -229,9 +228,6 @@
   // request should never have been sent to iCloud Keychain in the first place.
   bool OnNoPasskeys();
 
-  // To be called when fetching a challenge from a provided URL failed.
-  void OnChallengeUrlFailure();
-
   // To be called when the Bluetooth adapter status changes.
   void BluetoothAdapterStatusChanged(
       device::FidoRequestHandlerBase::BleStatus ble_status);
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/chrome/browser/webauthn/challenge_url_fetcher_unittest.cc b/chrome/browser/webauthn/challenge_url_fetcher_unittest.cc
deleted file mode 100644
index f4340488..0000000
--- a/chrome/browser/webauthn/challenge_url_fetcher_unittest.cc
+++ /dev/null
@@ -1,161 +0,0 @@
-// Copyright 2024 The Chromium Authors
-// Use of this source code is governed by a BSD-style license that can be
-// found in the LICENSE file.
-
-#include "chrome/browser/webauthn/challenge_url_fetcher.h"
-
-#include <iterator>
-#include <memory>
-#include <utility>
-
-#include "base/memory/scoped_refptr.h"
-#include "base/test/bind.h"
-#include "base/test/task_environment.h"
-#include "base/test/test_future.h"
-#include "net/http/http_response_headers.h"
-#include "services/network/public/cpp/url_loader_completion_status.h"
-#include "services/network/public/cpp/weak_wrapper_shared_url_loader_factory.h"
-#include "services/network/public/mojom/fetch_api.mojom.h"
-#include "services/network/public/mojom/url_response_head.mojom.h"
-#include "services/network/test/test_url_loader_factory.h"
-#include "testing/gtest/include/gtest/gtest.h"
-#include "url/gurl.h"
-
-namespace {
-constexpr char kUrl[] = "https://example.com/challenge_endpoint";
-constexpr uint8_t kTestChallenge[] = {0, 1, 2,  3,  4,  5,  6,  7,
-                                      8, 9, 10, 11, 12, 13, 14, 15};
-constexpr uint8_t kSmallChallenge[] = {0, 1, 2,  3,  4,  5,  6, 7,
-                                       8, 9, 10, 11, 12, 13, 14};
-
-constexpr char kChallengeContentType[] = "application/x-webauthn-challenge";
-}  // namespace
-
-class ChallengeUrlFetcherTest : public testing::Test {
- public:
-  ChallengeUrlFetcherTest() = default;
-
-  void SetUp() override {
-    fetcher_ = std::make_unique<ChallengeUrlFetcher>(
-        url_loader_factory_.GetSafeWeakWrapper());
-  }
-
-  ChallengeUrlFetcher* fetcher() { return fetcher_.get(); }
-
-  network::TestURLLoaderFactory* url_loader_factory() {
-    return &url_loader_factory_;
-  }
-
-  base::expected<std::vector<uint8_t>,
-                 ChallengeUrlFetcher::ChallengeNotAvailableReason>
-  FetchChallengeAndWait() {
-    base::test::TestFuture<void> future;
-    fetcher()->FetchUrl(GURL(kUrl), future.GetCallback());
-    EXPECT_TRUE(future.Wait());
-    return fetcher()->GetChallenge();
-  }
-
- private:
-  base::test::TaskEnvironment task_env_;
-  std::unique_ptr<ChallengeUrlFetcher> fetcher_;
-  network::TestURLLoaderFactory url_loader_factory_;
-};
-
-TEST_F(ChallengeUrlFetcherTest, ChallengeFetchSuccess) {
-  url_loader_factory()->SetInterceptor(base::BindLambdaForTesting(
-      [this](const network::ResourceRequest& request) {
-        EXPECT_EQ(request.redirect_mode, network::mojom::RedirectMode::kError);
-        EXPECT_EQ(request.credentials_mode,
-                  network::mojom::CredentialsMode::kOmit);
-        EXPECT_EQ(fetcher()->GetChallenge().error(),
-                  ChallengeUrlFetcher::ChallengeNotAvailableReason::
-                      kWaitingForChallenge);
-        auto head = network::mojom::URLResponseHead::New();
-        head->headers = base::MakeRefCounted<net::HttpResponseHeaders>("");
-        head->headers->AddHeader(net::HttpRequestHeaders::kContentType,
-                                 kChallengeContentType);
-        std::string body(std::begin(kTestChallenge), std::end(kTestChallenge));
-        url_loader_factory()->AddResponse(
-            GURL(kUrl), std::move(head), body,
-            network::URLLoaderCompletionStatus(net::Error::OK));
-      }));
-
-  auto result = FetchChallengeAndWait();
-  std::vector<uint8_t> expected(std::begin(kTestChallenge),
-                                std::end(kTestChallenge));
-  ASSERT_TRUE(result.has_value());
-  EXPECT_EQ(expected, result.value());
-}
-
-TEST_F(ChallengeUrlFetcherTest, ChallengeFetchError) {
-  url_loader_factory()->SetInterceptor(base::BindLambdaForTesting(
-      [this](const network::ResourceRequest& request) {
-        EXPECT_EQ(request.redirect_mode, network::mojom::RedirectMode::kError);
-        EXPECT_EQ(request.credentials_mode,
-                  network::mojom::CredentialsMode::kOmit);
-        EXPECT_EQ(fetcher()->GetChallenge().error(),
-                  ChallengeUrlFetcher::ChallengeNotAvailableReason::
-                      kWaitingForChallenge);
-        auto head = network::mojom::URLResponseHead::New();
-        head->headers = base::MakeRefCounted<net::HttpResponseHeaders>("");
-        head->headers->AddHeader(net::HttpRequestHeaders::kContentType,
-                                 kChallengeContentType);
-        std::string body(std::begin(kTestChallenge), std::end(kTestChallenge));
-        url_loader_factory()->AddResponse(
-            GURL(kUrl), std::move(head), body,
-            network::URLLoaderCompletionStatus(net::HTTP_NOT_FOUND));
-      }));
-
-  auto result = FetchChallengeAndWait();
-  ASSERT_FALSE(result.has_value());
-  EXPECT_EQ(
-      ChallengeUrlFetcher::ChallengeNotAvailableReason::kErrorFetchingChallenge,
-      result.error());
-}
-
-TEST_F(ChallengeUrlFetcherTest, ChallengeFetchMissingHeader) {
-  url_loader_factory()->SetInterceptor(base::BindLambdaForTesting(
-      [this](const network::ResourceRequest& request) {
-        EXPECT_EQ(request.redirect_mode, network::mojom::RedirectMode::kError);
-        EXPECT_EQ(request.credentials_mode,
-                  network::mojom::CredentialsMode::kOmit);
-        EXPECT_EQ(fetcher()->GetChallenge().error(),
-                  ChallengeUrlFetcher::ChallengeNotAvailableReason::
-                      kWaitingForChallenge);
-        std::string body(std::begin(kTestChallenge), std::end(kTestChallenge));
-        url_loader_factory()->AddResponse(kUrl, body);
-      }));
-
-  auto result = FetchChallengeAndWait();
-  ASSERT_FALSE(result.has_value());
-  EXPECT_EQ(
-      ChallengeUrlFetcher::ChallengeNotAvailableReason::kErrorFetchingChallenge,
-      result.error());
-}
-
-TEST_F(ChallengeUrlFetcherTest, ChallengeTooSmall) {
-  url_loader_factory()->SetInterceptor(base::BindLambdaForTesting(
-      [this](const network::ResourceRequest& request) {
-        EXPECT_EQ(request.redirect_mode, network::mojom::RedirectMode::kError);
-        EXPECT_EQ(request.credentials_mode,
-                  network::mojom::CredentialsMode::kOmit);
-        EXPECT_EQ(fetcher()->GetChallenge().error(),
-                  ChallengeUrlFetcher::ChallengeNotAvailableReason::
-                      kWaitingForChallenge);
-        auto head = network::mojom::URLResponseHead::New();
-        head->headers = base::MakeRefCounted<net::HttpResponseHeaders>("");
-        head->headers->AddHeader(net::HttpRequestHeaders::kContentType,
-                                 kChallengeContentType);
-        std::string body(std::begin(kSmallChallenge),
-                         std::end(kSmallChallenge));
-        url_loader_factory()->AddResponse(
-            GURL(kUrl), std::move(head), body,
-            network::URLLoaderCompletionStatus(net::Error::OK));
-      }));
-
-  auto result = FetchChallengeAndWait();
-  ASSERT_FALSE(result.has_value());
-  EXPECT_EQ(
-      ChallengeUrlFetcher::ChallengeNotAvailableReason::kErrorFetchingChallenge,
-      result.error());
-}
diff --git a/chrome/browser/webauthn/chrome_webauthn_browsertest.cc b/chrome/browser/webauthn/chrome_webauthn_browsertest.cc
index dd0b37cd..d6a8925 100644
--- a/chrome/browser/webauthn/chrome_webauthn_browsertest.cc
+++ b/chrome/browser/webauthn/chrome_webauthn_browsertest.cc
@@ -1157,292 +1157,6 @@
   EXPECT_EQ(observer_->accounts_.at(0), "0102030405060708090A0B0C0D0E0F10");
 }
 
-class ChallengeUrlBrowserTest : public WebAuthnBrowserTest {
- public:
-  static constexpr char kValidChallenge[] = "1234567890123456";
-
-  class DelegateObserver
-      : public ChromeAuthenticatorRequestDelegate::TestObserver {
-   public:
-    explicit DelegateObserver(ChallengeUrlBrowserTest* test_instance)
-        : test_instance_(test_instance) {}
-    virtual ~DelegateObserver() = default;
-
-    void WaitForUI() {
-      ui_shown_run_loop_->Run();
-      ui_shown_run_loop_ = std::make_unique<base::RunLoop>();
-    }
-
-    // ChromeAuthenticatorRequestDelegate::TestObserver:
-    void Created(ChromeAuthenticatorRequestDelegate* delegate) override {
-      test_instance_->UpdateRequestDelegate(delegate);
-    }
-
-    void OnDestroy(ChromeAuthenticatorRequestDelegate* delegate) override {
-      test_instance_->UpdateRequestDelegate(nullptr);
-    }
-
-    void UIShown(ChromeAuthenticatorRequestDelegate* delegate) override {
-      ui_shown_run_loop_->QuitWhenIdle();
-    }
-
-   private:
-    raw_ptr<ChallengeUrlBrowserTest> test_instance_;
-    std::unique_ptr<base::RunLoop> ui_shown_run_loop_ =
-        std::make_unique<base::RunLoop>();
-  };
-  class ModelObserver : public AuthenticatorRequestDialogModel::Observer {
-   public:
-    explicit ModelObserver(AuthenticatorRequestDialogModel* model)
-        : model_(model) {
-      model_->observers.AddObserver(this);
-    }
-
-    ~ModelObserver() override {
-      if (model_) {
-        model_->observers.RemoveObserver(this);
-        model_ = nullptr;
-      }
-    }
-
-    // Call this before the state transition you are looking to observe.
-    void SetStepToObserve(AuthenticatorRequestDialogModel::Step step) {
-      ASSERT_FALSE(run_loop_);
-      step_ = step;
-      run_loop_ = std::make_unique<base::RunLoop>();
-    }
-
-    // Call this to observer the next step change, whatever it might be.
-    void ObserveNextStep() {
-      ASSERT_FALSE(run_loop_);
-      run_loop_ = std::make_unique<base::RunLoop>();
-    }
-
-    // This will return after a transition to the state previously specified by
-    // `SetStepToObserve`. Returns immediately if the current step matches.
-    void WaitForStep() {
-      if (model_->step() == step_) {
-        run_loop_.reset();
-        return;
-      }
-      ASSERT_TRUE(run_loop_);
-      run_loop_->Run();
-      // When waiting for `kClosed` the model is deleted at this point.
-      if (step_ != AuthenticatorRequestDialogModel::Step::kClosed) {
-        CHECK_EQ(step_, model_->step());
-      }
-      Reset();
-    }
-
-    // AuthenticatorRequestDialogModel::Observer:
-    void OnStepTransition() override {
-      if (run_loop_ && step_ == model_->step()) {
-        run_loop_->QuitWhenIdle();
-      }
-    }
-
-    void OnModelDestroyed(AuthenticatorRequestDialogModel* model) override {
-      model_ = nullptr;
-    }
-
-    void Reset() {
-      step_ = AuthenticatorRequestDialogModel::Step::kNotStarted;
-      run_loop_.reset();
-    }
-
-   private:
-    raw_ptr<AuthenticatorRequestDialogModel> model_;
-    AuthenticatorRequestDialogModel::Step step_ =
-        AuthenticatorRequestDialogModel::Step::kNotStarted;
-    std::unique_ptr<base::RunLoop> run_loop_;
-  };
-
-  void SetUpOnMainThread() override {
-    // Handlers have to be registered before the server is started.
-    https_server_.RegisterRequestHandler(
-        base::BindRepeating(&ChallengeUrlBrowserTest::HandleChallengeRequest,
-                            base::Unretained(this)));
-    WebAuthnBrowserTest::SetUpOnMainThread();
-
-    auto virtual_device_factory =
-        std::make_unique<device::test::VirtualFidoDeviceFactory>();
-    virtual_device_factory_ = virtual_device_factory.get();
-    virtual_device_factory->mutable_state()->InjectResidentKey(
-        kCredentialID, "www.example.com", std::vector<uint8_t>{5, 6, 7, 8},
-        "flandre", "Flandre Scarlet");
-    virtual_device_factory->mutable_state()->fingerprints_enrolled = true;
-    device::VirtualCtap2Device::Config config;
-    config.resident_key_support = true;
-    config.internal_uv_support = true;
-    virtual_device_factory->SetCtap2Config(std::move(config));
-    auth_env_ =
-        std::make_unique<content::ScopedAuthenticatorEnvironmentForTesting>(
-            std::move(virtual_device_factory));
-
-    ASSERT_TRUE(ui_test_utils::NavigateToURL(
-        browser(), https_server_.GetURL("www.example.com", "/title1.html")));
-
... (truncated)
Loading diff…

Original Bug Report

reported by [email protected]

SSRF and PNA Bypass in WebAuthn via challenge_url

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

Overview: A compromised renderer can exploit the WebAuthn API to perform Server-Side Request Forgery (SSRF) and bypass Private Network Access (PNA) protections. By supplying a malicious challenge_url via Mojo IPC, the browser process fetches the URL using a highly privileged URLLoaderFactory without enforcing scheme or origin restrictions.

Affected files:

  • chrome/browser/webauthn/challenge_url_fetcher.cc
  • content/browser/webauth/authenticator_common_impl.cc
  • chrome/browser/webauthn/authenticator_request_dialog_controller.cc

Estimated timestamp from git blame: 2025-01-10

Description

There is a potential Server-Side Request Forgery (SSRF) vulnerability in the WebAuthn implementation’s handling of the challenge_url parameter. When a WebAuthn GetAssertion request is made, a compromised renderer can bypass Blink-level feature flags and supply an arbitrary challenge_url directly via Mojo IPC.

The browser process receives this request in AuthenticatorCommonImpl, which only checks if the URL is syntactically valid but fails to validate the scheme (e.g., blocking file:// or chrome://) or enforce Private Network Access (PNA) restrictions.

The request is then passed to AuthenticatorRequestDialogController::ProvideChallengeUrl, which instantiates a ChallengeUrlFetcher using Profile::GetDefaultStoragePartition()->GetURLLoaderFactoryForBrowserProcess(). As documented in content/public/browser/storage_partition.h, this factory is highly privileged and disables Opaque Response Blocking (ORB) and request_initiator_origin_lock. Furthermore, ChallengeUrlFetcher::FetchUrl constructs the network::ResourceRequest without setting a request_initiator.

Because the request uses a privileged browser-process factory and lacks an initiator, the Network Service treats it as a trusted internal fetch. This completely bypasses CORS and PNA checks, allowing the attacker to force the browser to issue blind GET requests to local or internal network resources.

Impact

  • SSRF and PNA Bypass: A compromised renderer can issue GET requests to localhost, RFC1918 addresses, or cloud metadata endpoints, bypassing network isolation boundaries.
  • Timing Oracle: The renderer receives a NOT_ALLOWED_ERROR upon fetch failure. The time taken to receive this error differs based on network behavior (e.g., fast TCP connection refused vs. slower HTTP 200 OK with incorrect Content-Type), creating a reliable timing oracle for scanning internal ports and services.
  • Scheme Abuse: Lack of URL scheme filtering may allow the browser process to fetch file:// or chrome:// resources, though reading the exact response body requires the target to serve the specific WebAuthn challenge content type.

Potential Attacker Steps

Note: These are suggested steps; our tooling has not executed a live proof-of-concept.

  1. An attacker compromises a renderer process (e.g., via a V8 exploit) or uses a renderer with MojoJS enabled.
  2. The attacker crafts a blink::mojom::Authenticator::GetAssertion Mojo message.
  3. In the PublicKeyCredentialRequestOptions, the attacker omits the challenge and sets challenge_url to a sensitive internal endpoint (e.g., http://127.0.0.1:8080/internal-api).
  4. The compromised renderer sends the Mojo IPC to the browser process.
  5. The browser process validates the Relying Party ID and immediately triggers the ChallengeUrlFetcher to perform a GET request against the attacker-supplied internal URL.
  6. The attacker measures the time it takes for the WebAuthn promise to reject to infer the state of the internal endpoint.

Suggested Fix

  1. Use a Restricted URLLoaderFactory: Do not use GetURLLoaderFactoryForBrowserProcess() for web-influenced fetches. Instead, use a URLLoaderFactory bound to the origin of the requesting renderer frame, ensuring standard web security models apply.
  2. Set Request Initiator: Explicitly set the request_initiator on the network::ResourceRequest in ChallengeUrlFetcher::FetchUrl to the origin of the relying party.
  3. Strict URL Validation: In AuthenticatorCommonImpl, reject challenge_url values that use non-HTTP(S) schemes (e.g., file://, chrome://, data://).
  4. Enforce PNA: Ensure Private Network Access checks are applied to the fetch, preventing public websites from fetching challenges from local/internal network endpoints.

Evaluated with Chrome root at commit: 876d480da1f794d87813cfa2e6ff4fcf9771e939


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. And please feel free to reach out to me directly if you have concerns or feedback on the project.

View on issue tracker