Medium chrome Input Validation 📄 Reporter bug report 🔧 Commit mapped

Overview

Medium
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactImproper input validation in Mobile
DescriptionImproper input validation in Mobile
ComponentMobile
Sub-componentPasswords
Bug ClassInput Validation
Tracker517374100
Fix commit2db4b952ea1e (chromium/src) +182/-58
CISA KEVNot listed
CreditedGoogle
Disclosed2026-10-06

Changed Functions

FunctionChangeNotes
TEST_F
components/password_manager/core/browser/generation/password_generator_unittest.cc
modified

Files Changed

  • components/password_manager/core/browser/form_parsing/form_data_parser.cc
  • ios/components/credential_provider_extension/password_spec_fetcher_unittest.mm
  • components/password_manager/core/browser/generation/password_generator.h
  • components/password_manager/core/browser/generation/password_generator_unittest.cc
  • components/password_manager/core/browser/password_manager_util.cc
  • components/password_manager/core/browser/generation/BUILD.gn
  • components/password_manager/core/browser/votes_uploader.cc
  • components/autofill/core/common/password_generation_util.h
  • components/password_manager/core/common/password_manager_constants.h
  • ios/components/credential_provider_extension/BUILD.gn
  • ios/components/credential_provider_extension/DEPS
  • components/password_manager/core/browser/generation/password_generator.cc
  • ios/components/credential_provider_extension/password_spec_fetcher.mm
  • components/password_manager/core/browser/password_requirements_service.cc
  • components/password_manager/core/browser/password_manager_util.h
From 2db4b952ea1eafdcc5715c86c1dbcc9f71259607 Mon Sep 17 00:00:00 2001
From: Alexis Hétu <sugoi@chromium.org>
Date: Tue, 08 Sep 2026 07:47:06 -0700
Subject: [PATCH] [Passwords] Share generation requirements spec sanitization

PasswordRequirementsService validates network-supplied
PasswordRequirementsSpec messages before caching them, but the
sanitizer lived in an anonymous namespace and was unreachable
from the iOS Credential Provider Extension's PasswordSpecFetcher,
which uses its own NSURLSession-based fetch path and stored the
raw proto for use with GeneratePassword().

Lift the sanitizer into the lightweight generation:core target
as autofill::SanitizeRequirementsSpec(), have
PasswordRequirementsService delegate to it, and apply it in
PasswordSpecFetcher before storing the spec. This also covers
the //ios/web_view CWVCredentialProviderExtensionUtils entry
point, which goes through the same fetcher.

Bug: 517374100
Change-Id: Ib3dd99d12db9c57de868c6b583d1bff1906176a6
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8355129
Reviewed-by: Maria Kazinova <kazinova@google.com>
Commit-Queue: Alexis Hétu <sugoi@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1693784}
---
diff --git a/components/autofill/core/common/password_generation_util.h b/components/autofill/core/common/password_generation_util.h
index 79642d802..c1c2058 100644
--- a/components/autofill/core/common/password_generation_util.h
+++ b/components/autofill/core/common/password_generation_util.h
@@ -16,9 +16,6 @@
 
 namespace password_generation {
 
-// Minimal length of the generated password.
-inline constexpr int kMinimumPasswordLength = 9;
-
 // Minimum password length that allows to label the password as strong in the
 // UI. Must stay in sync with LENGTH_SUFFICIENT_FOR_STRONG_LABEL in
 // TouchToFillPasswordGenerationView.java
diff --git a/components/password_manager/core/browser/form_parsing/form_data_parser.cc b/components/password_manager/core/browser/form_parsing/form_data_parser.cc
index 28365b7..135ff936 100644
--- a/components/password_manager/core/browser/form_parsing/form_data_parser.cc
+++ b/components/password_manager/core/browser/form_parsing/form_data_parser.cc
@@ -30,7 +30,6 @@
 #include "components/autofill/core/common/autofill_regexes.h"
 #include "components/autofill/core/common/form_data.h"
 #include "components/autofill/core/common/form_field_data.h"
-#include "components/autofill/core/common/password_generation_util.h"
 #include "components/autofill/core/common/unique_ids.h"
 #include "components/password_manager/core/browser/features/password_features.h"
 #include "components/password_manager/core/browser/password_form.h"
@@ -1237,7 +1236,7 @@
 
 bool FieldMaxLengthAllowsPasswordGeneration(const FormFieldData& field) {
   return field.max_length() >=
-         autofill::password_generation::kMinimumPasswordLength;
+         password_manager::constants::kMinimumPasswordLength;
 }
 
 bool ShouldContinueParsing(bool parsing_complete_with_model_predictions,
diff --git a/components/password_manager/core/browser/generation/password_generator.cc b/components/password_manager/core/browser/generation/password_generator.cc
index 3428bc7..8f40722 100644
--- a/components/password_manager/core/browser/generation/password_generator.cc
+++ b/components/password_manager/core/browser/generation/password_generator.cc
@@ -14,6 +14,7 @@
 #include "base/rand_util.h"
 #include "base/strings/utf_string_conversions.h"
 #include "components/autofill/core/browser/proto/password_requirements.pb.h"
+#include "components/password_manager/core/common/password_manager_constants.h"
 
 namespace autofill {
 
@@ -23,7 +24,6 @@
 // prediction is smaller than the default.)
 const uint32_t kDefaultPasswordLength = 15;
 
-
 namespace {
 
 // Default character sets used if the spec does not override the character set.
@@ -209,6 +209,40 @@
 
 }  // namespace
 
+PasswordRequirementsSpec SanitizeRequirementsSpec(
+    const PasswordRequirementsSpec& spec) {
+  if (spec.has_lower_case() && spec.lower_case().has_character_set()) {
+    return PasswordRequirementsSpec();
+  }
+  if (spec.has_upper_case() && spec.upper_case().has_character_set()) {
+    return PasswordRequirementsSpec();
+  }
+  if (spec.has_alphabetic() && spec.alphabetic().has_character_set()) {
+    return PasswordRequirementsSpec();
+  }
+  if (spec.has_numeric() && spec.numeric().has_character_set()) {
+    return PasswordRequirementsSpec();
+  }
+  if (spec.has_max_length() &&
+      spec.max_length() < password_manager::constants::kMinimumPasswordLength) {
+    return PasswordRequirementsSpec();
+  }
+  if (spec.has_symbols() && spec.symbols().has_character_set() &&
+      !std::ranges::all_of(spec.symbols().character_set(), [](char c) {
+        return password_manager::constants::kSpecialSymbols.contains(c);
+      })) {
+    return PasswordRequirementsSpec();
+  }
+  if (spec.has_lower_case() && spec.lower_case().has_max() &&
+      spec.lower_case().max() == 0 && spec.has_upper_case() &&
+      spec.upper_case().has_max() && spec.upper_case().max() == 0 &&
+      spec.has_numeric() && spec.numeric().has_max() &&
+      spec.numeric().max() == 0) {
+    return PasswordRequirementsSpec();
+  }
+  return spec;
+}
+
 void ConditionallyAddNumericDigitsToAlphabet(PasswordRequirementsSpec* spec) {
   DCHECK(spec);
   if (spec->lower_case().max() == 0 && spec->upper_case().max() == 0) {
diff --git a/components/password_manager/core/browser/generation/password_generator.h b/components/password_manager/core/browser/generation/password_generator.h
index 058d585..076406e 100644
--- a/components/password_manager/core/browser/generation/password_generator.h
+++ b/components/password_manager/core/browser/generation/password_generator.h
@@ -20,6 +20,12 @@
 
 extern const uint32_t kDefaultPasswordLength;
 
+// Ensures that `spec` is a valid spec that the crowdsourcing server can
+// produce. If not, replaces it with an empty spec. Intended to be applied to
+// any spec received from the network before it is passed to GeneratePassword().
+PasswordRequirementsSpec SanitizeRequirementsSpec(
+    const PasswordRequirementsSpec& spec);
+
 // Returns a password that follows the |spec| as well as possible. If this is
 // impossible, a password that nearly meets the requirements can be returned.
 // In this case the user is asked to fix the password themselves.
diff --git a/components/password_manager/core/browser/password_manager_util.cc b/components/password_manager/core/browser/password_manager_util.cc
index 18b9e85..bfdb724 100644
--- a/components/password_manager/core/browser/password_manager_util.cc
+++ b/components/password_manager/core/browser/password_manager_util.cc
@@ -41,6 +41,7 @@
 #include "components/password_manager/core/browser/password_store/password_store_util.h"
 #include "components/password_manager/core/browser/password_store/stored_credential.h"
 #include "components/password_manager/core/browser/password_sync_util.h"
+#include "components/password_manager/core/common/password_manager_constants.h"
 #include "components/password_manager/core/common/password_manager_pref_names.h"
 #include "components/prefs/pref_service.h"
 #include "components/signin/public/base/signin_metrics.h"
@@ -51,6 +52,7 @@
 using autofill::password_generation::PasswordGenerationType;
 using password_manager::PasswordForm;
 using password_manager::StoredCredential;
+using password_manager::constants::kSpecialSymbols;
 
 namespace password_manager_util {
 namespace {
diff --git a/components/password_manager/core/browser/password_manager_util.h b/components/password_manager/core/browser/password_manager_util.h
index a250910b..40e163f 100644
--- a/components/password_manager/core/browser/password_manager_util.h
+++ b/components/password_manager/core/browser/password_manager_util.h
@@ -198,10 +198,6 @@
                                            bool enabled);
 #endif
 
-// Contains all special symbols considered for password-generation.
-inline constexpr std::u16string_view kSpecialSymbols =
-    u"!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~";
-
 // Helper functions for character type classification. The built-in functions
 // depend on locale, platform and other stuff. To make the output more
 // predictable, the function are re-implemented here.
@@ -214,7 +210,8 @@
 bool IsUppercaseLetter(char16_t c);
 
 // Checks if a supplied character |c| is a special symbol.
-// Special symbols are defined by the string |kSpecialSymbols|.
+// Special symbols are defined by
+// `password_manager::constants::kSpecialSymbols`.
 bool IsSpecialSymbol(char16_t c);
 
 // Returns true if 'type' is a username in a password-less form.
diff --git a/components/password_manager/core/browser/password_requirements_service.cc b/components/password_manager/core/browser/password_requirements_service.cc
index 8e90ba7..3e97531 100644
--- a/components/password_manager/core/browser/password_requirements_service.cc
+++ b/components/password_manager/core/browser/password_requirements_service.cc
@@ -4,16 +4,13 @@
 
 #include "components/password_manager/core/browser/password_requirements_service.h"
 
-#include <algorithm>
-
 #include "base/functional/bind.h"
 #include "base/logging.h"
 #include "base/metrics/field_trial_params.h"
 #include "base/strings/string_number_conversions.h"
-#include "components/autofill/core/common/password_generation_util.h"
+#include "components/password_manager/core/browser/generation/password_generator.h"
 #include "components/password_manager/core/browser/generation/password_requirements_spec_fetcher_impl.h"
 #include "components/password_manager/core/browser/generation/password_requirements_spec_printer.h"
-#include "components/password_manager/core/browser/password_manager_util.h"
 #include "components/password_manager/core/common/password_manager_features.h"
 #include "services/network/public/cpp/shared_url_loader_factory.h"
 
@@ -25,43 +22,6 @@
 constexpr size_t kCacheSizeForDomainKeyedSpecs = 200;
 constexpr size_t kCacheSizeForSignatureKeyedSpecs = 500;
 
-// Ensures that `spec` is a valid spec that the crowdsourcing server can
-// produce. If not, replaces it with an empty spec.
-PasswordRequirementsSpec GetSanitizedSpec(
-    const autofill::PasswordRequirementsSpec& spec) {
-  if (spec.has_lower_case() && spec.lower_case().has_character_set()) {
-    return PasswordRequirementsSpec();
-  }
-  if (spec.has_upper_case() && spec.upper_case().has_character_set()) {
-    return PasswordRequirementsSpec();
-  }
-  if (spec.has_alphabetic() && spec.alphabetic().has_character_set()) {
-    return PasswordRequirementsSpec();
-  }
-  if (spec.has_numeric() && spec.numeric().has_character_set()) {
-    return PasswordRequirementsSpec();
-  }
-  if (spec.has_max_length() &&
-      spec.max_length() <
-          autofill::password_generation::kMinimumPasswordLength) {
-    return PasswordRequirementsSpec();
-  }
-  if (spec.has_symbols() && spec.symbols().has_character_set() &&
-      !std::ranges::all_of(spec.symbols().character_set(),
-                           password_manager_util::IsSpecialSymbol)) {
-    return PasswordRequirementsSpec();
-  }
-  if (spec.has_lower_case() && spec.lower_case().has_max() &&
-      spec.lower_case().max() == 0 && spec.has_upper_case() &&
-      spec.upper_case().has_max() && spec.upper_case().max() == 0 &&
-      spec.has_numeric() && spec.numeric().has_max() &&
-      spec.numeric().max() == 0) {
-    return PasswordRequirementsSpec();
-  }
-
-  return spec;
-}
-
 }  // namespace
 
 namespace password_manager {
@@ -140,7 +100,8 @@
     const PasswordRequirementsSpec& spec) {
   VLOG(1) << "PasswordRequirementsService::OnFetchedRequirements("
           << main_frame_domain << ", " << spec << ")";
-  specs_for_domains_.Put(main_frame_domain, GetSanitizedSpec(spec));
+  specs_for_domains_.Put(main_frame_domain,
+                         autofill::SanitizeRequirementsSpec(spec));
 }
 
 void PasswordRequirementsService::AddSpec(
@@ -150,7 +111,8 @@
     const PasswordRequirementsSpec& spec) {
   VLOG(1) << "PasswordRequirementsService::AddSpec(" << form_signature << ", "
           << field_signature << ", " << spec << ")";
-  PasswordRequirementsSpec sanitized_spec = GetSanitizedSpec(spec);
+  PasswordRequirementsSpec sanitized_spec =
+      autofill::SanitizeRequirementsSpec(spec);
   specs_for_signatures_.Put(std::make_pair(form_signature, field_signature),
                             sanitized_spec);
 
@@ -200,7 +162,8 @@
     const GURL& main_frame_domain,
     FetchPasswordRequirementsSpecCallback callback,
     const PasswordRequirementsSpec& spec) {
-  PasswordRequirementsSpec sanitized_spec = GetSanitizedSpec(spec);
+  PasswordRequirementsSpec sanitized_spec =
+      autofill::SanitizeRequirementsSpec(spec);
   specs_for_domains_.Put(main_frame_domain, sanitized_spec);
   std::move(callback).Run(sanitized_spec);
 }
diff --git a/components/password_manager/core/browser/votes_uploader.cc b/components/password_manager/core/browser/votes_uploader.cc
index 1bdb55f..6fb00c7 100644
--- a/components/password_manager/core/browser/votes_uploader.cc
+++ b/components/password_manager/core/browser/votes_uploader.cc
@@ -50,6 +50,7 @@
 using autofill::FormStructure;
 using autofill::IsMostRecentSingleUsernameCandidate;
 using autofill::RandomizedEncoder;
+using password_manager::constants::kSpecialSymbols;
 using password_manager_util::FindCredentialByUsername;
 
 using Logger = autofill::SavePasswordProgressLogger;
@@ -175,8 +176,7 @@
 // Returns a uniformly distributed random symbol from the set of random symbols
 // defined by the string |kSpecialSymbols|.
 int GetRandomSpecialSymbol() {
-  return password_manager_util::kSpecialSymbols[base::RandGenerator(
-      password_manager_util::kSpecialSymbols.size())];
+  return kSpecialSymbols[base::RandGenerator(kSpecialSymbols.size())];
 }
 
 // Returns a random special symbol used in |password|.
diff --git a/components/password_manager/core/common/password_manager_constants.h b/components/password_manager/core/common/password_manager_constants.h
index 39e83712..41dcc83 100644
--- a/components/password_manager/core/common/password_manager_constants.h
+++ b/components/password_manager/core/common/password_manager_constants.h
@@ -7,10 +7,19 @@
 #ifndef COMPONENTS_PASSWORD_MANAGER_CORE_COMMON_PASSWORD_MANAGER_CONSTANTS_H_
 #define COMPONENTS_PASSWORD_MANAGER_CORE_COMMON_PASSWORD_MANAGER_CONSTANTS_H_
 
+#include <string_view>
+
 #include "base/time/time.h"
 
 namespace password_manager::constants {
 
+// Minimal length of a generated password.
+inline constexpr int kMinimumPasswordLength = 9;
+
+// Contains all special symbols considered for password-generation.
+inline constexpr std::u16string_view kSpecialSymbols =
+    u"!\"#$%&'()*+,-./:;<=>?@[\\]^_`{|}~";
+
 // The character used to obfuscate password labels.
 inline constexpr char16_t kPasswordReplacementChar = 0x2022;
 
diff --git a/ios/components/credential_provider_extension/password_spec_fetcher.mm b/ios/components/credential_provider_extension/password_spec_fetcher.mm
index bc70e11..2e2bf3be 100644
--- a/ios/components/credential_provider_extension/password_spec_fetcher.mm
+++ b/ios/components/credential_provider_extension/password_spec_fetcher.mm
@@ -8,6 +8,7 @@
 
 #import "base/base64.h"
 #import "components/autofill/core/browser/proto/password_requirements.pb.h"
+#import "components/password_manager/core/browser/generation/password_generator.h"
 
 using autofill::DomainSuggestions;
 using autofill::PasswordRequirementsSpec;
@@ -118,7 +119,8 @@
     DomainSuggestions suggestions;
     suggestions.ParseFromString(decoded);
     if (suggestions.has_password_requirements()) {
-      self.spec = suggestions.password_requirements();
+      self.spec = autofill::SanitizeRequirementsSpec(
+          suggestions.password_requirements());
     }
   }
   [self executeCompletion];
diff --git a/components/password_manager/core/browser/generation/password_generator_unittest.cc b/components/password_manager/core/browser/generation/password_generator_unittest.cc
index c3e696d..99e324d 100644
--- a/components/password_manager/core/browser/generation/password_generator_unittest.cc
+++ b/components/password_manager/core/browser/generation/password_generator_unittest.cc
@@ -306,6 +306,57 @@
   EXPECT_EQ(kDefaultPasswordLength, GeneratePassword(spec_).length());
 }
 
+TEST_F(PasswordGeneratorTest, SanitizeRequirementsSpecAcceptsValidSpec) {
+  spec_.set_max_length(12u);
+  spec_.set_priority(10u);
+  spec_.mutable_lower_case()->set_min(2u);
+  spec_.mutable_symbols()->set_character_set("!@#");
+  PasswordRequirementsSpec sanitized = SanitizeRequirementsSpec(spec_);
+  EXPECT_EQ(12u, sanitized.max_length());
+  EXPECT_EQ(10u, sanitized.priority());
+  EXPECT_EQ(2u, sanitized.lower_case().min());
+  EXPECT_EQ("!@#", sanitized.symbols().character_set());
+}
+
+TEST_F(PasswordGeneratorTest, SanitizeRequirementsSpecRejectsInvalidSpec) {
+  PasswordRequirementsSpec spec_lower_case;
+  spec_lower_case.mutable_lower_case()->set_character_set("a");
+
+  PasswordRequirementsSpec spec_upper_case;
+  spec_upper_case.mutable_upper_case()->set_character_set("A");
+
+  PasswordRequirementsSpec spec_alphabetic;
+  spec_alphabetic.mutable_alphabetic()->set_character_set("aA");
+
+  PasswordRequirementsSpec spec_numeric;
+  spec_numeric.mutable_numeric()->set_character_set("1");
+
+  PasswordRequirementsSpec spec_short;
+  spec_short.set_max_length(4u);
+
+  PasswordRequirementsSpec spec_symbols_with_letters;
+  spec_symbols_with_letters.mutable_symbols()->set_character_set("!@a");
+
+  PasswordRequirementsSpec spec_no_alphanumeric;
+  spec_no_alphanumeric.mutable_lower_case()->set_max(0u);
+  spec_no_alphanumeric.mutable_upper_case()->set_max(0u);
+  spec_no_alphanumeric.mutable_numeric()->set_max(0u);
+
+  for (auto* spec :
+       {&spec_lower_case, &spec_upper_case, &spec_alphabetic, &spec_numeric,
+        &spec_short, &spec_symbols_with_letters, &spec_no_alphanumeric}) {
+    spec->set_priority(100u);
+    PasswordRequirementsSpec sanitized = SanitizeRequirementsSpec(*spec);
+    EXPECT_FALSE(sanitized.has_priority());
+    EXPECT_FALSE(sanitized.has_max_length());
+    EXPECT_FALSE(sanitized.has_lower_case());
... (file diff truncated)
Loading diff…

Regression Test / PoC

shipped with the fix
diff --git a/components/password_manager/core/browser/generation/password_generator_unittest.cc b/components/password_manager/core/browser/generation/password_generator_unittest.cc
index c3e696d..99e324d 100644
--- a/components/password_manager/core/browser/generation/password_generator_unittest.cc
+++ b/components/password_manager/core/browser/generation/password_generator_unittest.cc
@@ -306,6 +306,57 @@
   EXPECT_EQ(kDefaultPasswordLength, GeneratePassword(spec_).length());
 }
 
+TEST_F(PasswordGeneratorTest, SanitizeRequirementsSpecAcceptsValidSpec) {
+  spec_.set_max_length(12u);
+  spec_.set_priority(10u);
+  spec_.mutable_lower_case()->set_min(2u);
+  spec_.mutable_symbols()->set_character_set("!@#");
+  PasswordRequirementsSpec sanitized = SanitizeRequirementsSpec(spec_);
+  EXPECT_EQ(12u, sanitized.max_length());
+  EXPECT_EQ(10u, sanitized.priority());
+  EXPECT_EQ(2u, sanitized.lower_case().min());
+  EXPECT_EQ("!@#", sanitized.symbols().character_set());
+}
+
+TEST_F(PasswordGeneratorTest, SanitizeRequirementsSpecRejectsInvalidSpec) {
+  PasswordRequirementsSpec spec_lower_case;
+  spec_lower_case.mutable_lower_case()->set_character_set("a");
+
+  PasswordRequirementsSpec spec_upper_case;
+  spec_upper_case.mutable_upper_case()->set_character_set("A");
+
+  PasswordRequirementsSpec spec_alphabetic;
+  spec_alphabetic.mutable_alphabetic()->set_character_set("aA");
+
+  PasswordRequirementsSpec spec_numeric;
+  spec_numeric.mutable_numeric()->set_character_set("1");
+
+  PasswordRequirementsSpec spec_short;
+  spec_short.set_max_length(4u);
+
+  PasswordRequirementsSpec spec_symbols_with_letters;
+  spec_symbols_with_letters.mutable_symbols()->set_character_set("!@a");
+
+  PasswordRequirementsSpec spec_no_alphanumeric;
+  spec_no_alphanumeric.mutable_lower_case()->set_max(0u);
+  spec_no_alphanumeric.mutable_upper_case()->set_max(0u);
+  spec_no_alphanumeric.mutable_numeric()->set_max(0u);
+
+  for (auto* spec :
+       {&spec_lower_case, &spec_upper_case, &spec_alphabetic, &spec_numeric,
+        &spec_short, &spec_symbols_with_letters, &spec_no_alphanumeric}) {
+    spec->set_priority(100u);
+    PasswordRequirementsSpec sanitized = SanitizeRequirementsSpec(*spec);
+    EXPECT_FALSE(sanitized.has_priority());
+    EXPECT_FALSE(sanitized.has_max_length());
+    EXPECT_FALSE(sanitized.has_lower_case());
+    EXPECT_FALSE(sanitized.has_upper_case());
+    EXPECT_FALSE(sanitized.has_alphabetic());
+    EXPECT_FALSE(sanitized.has_numeric());
+    EXPECT_FALSE(sanitized.has_symbols());
+  }
+}
+
 }  // namespace
 
 }  // namespace autofill
diff --git a/ios/components/credential_provider_extension/password_spec_fetcher_unittest.mm b/ios/components/credential_provider_extension/password_spec_fetcher_unittest.mm
index da7e28c..f13beab 100644
--- a/ios/components/credential_provider_extension/password_spec_fetcher_unittest.mm
+++ b/ios/components/credential_provider_extension/password_spec_fetcher_unittest.mm
@@ -17,6 +17,25 @@
 using autofill::PasswordRequirementsSpec;
 using autofill::PasswordRequirementsSpec_CharacterClass;
 
+@interface PasswordSpecFetcher (Testing)
+- (void)onReceivedData:(NSData*)data
+              response:(NSURLResponse*)response
+                 error:(NSError*)error;
+@end
+
+namespace {
+
+// Returns the base64-encoded body that the fetcher expects, wrapping `spec` in
+// a `DomainSuggestions` message.
+NSData* EncodedResponseBodyForSpec(const PasswordRequirementsSpec& spec) {
+  DomainSuggestions suggestions;
+  *suggestions.mutable_password_requirements() = spec;
+  std::string encoded = base::Base64Encode(suggestions.SerializeAsString());
+  return [NSData dataWithBytes:encoded.data() length:encoded.size()];
+}
+
+}  // namespace
+
 class PasswordSpecFetcherTest : public PlatformTest {};
 
 // Tests a dummy proto can be parsed.
@@ -67,3 +86,45 @@
         return block_ran;
       }));
 }
+
+// Tests that a valid response body is stored as the spec.
+TEST_F(PasswordSpecFetcherTest, ValidResponseStoredAsSpec) {
+  PasswordSpecFetcher* fetcher = [[PasswordSpecFetcher alloc] initWithHost:@""
+                                                                    APIKey:@""];
+  PasswordRequirementsSpec spec;
+  spec.set_max_length(12u);
+  spec.mutable_lower_case()->set_min(2u);
+  [fetcher onReceivedData:EncodedResponseBodyForSpec(spec)
+                 response:nil
+                    error:nil];
+  EXPECT_EQ(12u, fetcher.spec.max_length());
+  EXPECT_EQ(2u, fetcher.spec.lower_case().min());
+}
+
+// Tests that a response body that overrides the lower case character set is
+// replaced with an empty spec.
+TEST_F(PasswordSpecFetcherTest, ResponseWithCharacterSetOverrideRejected) {
+  PasswordSpecFetcher* fetcher = [[PasswordSpecFetcher alloc] initWithHost:@""
+                                                                    APIKey:@""];
+  PasswordRequirementsSpec spec;
+  spec.set_max_length(12u);
+  spec.mutable_lower_case()->set_character_set("a");
+  [fetcher onReceivedData:EncodedResponseBodyForSpec(spec)
+                 response:nil
+                    error:nil];
+  EXPECT_FALSE(fetcher.spec.has_max_length());
+  EXPECT_FALSE(fetcher.spec.has_lower_case());
+}
+
+// Tests that a response body with a max length below the minimum that the
+// generator should produce is replaced with an empty spec.
+TEST_F(PasswordSpecFetcherTest, ResponseWithShortMaxLengthRejected) {
+  PasswordSpecFetcher* fetcher = [[PasswordSpecFetcher alloc] initWithHost:@""
+                                                                    APIKey:@""];
+  PasswordRequirementsSpec spec;
+  spec.set_max_length(4u);
+  [fetcher onReceivedData:EncodedResponseBodyForSpec(spec)
+                 response:nil
+                    error:nil];
+  EXPECT_FALSE(fetcher.spec.has_max_length());
+}
Loading diff…

Original Bug Report

reported by vm...@google.com

Password spec sanitization bypass in iOS Credential Provider Extension and WebView

Project Fortify, 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 iOS Credential Provider Extension (CPE) and the CWV passwords utility fetch password specifications from the remote autofill service but bypass sanitization. A network attacker capable of intercepting TLS traffic can potentials supply a crafted specification to downgrade generated strong passwords to low-entropy values. This occurs because the parsed proto specifications are passed directly to the generator without validation against minimum security criteria.

Affected files:

  • ios/components/credential_provider_extension/password_spec_fetcher.mm
  • ios/chrome/credential_provider_extension/ui/new_password_mediator.mm
  • ios/web_view/internal/passwords/cwv_credential_provider_extension_utils.mm

Estimated timestamp from git blame: 2021-09-01

Root Cause Analysis

In Chromium, server-supplied password specifications (PasswordRequirementsSpec) are sanitized to prevent malicious or misconfigured network data from weakening generated passwords. This sanitization is implemented via GetSanitizedSpec() in components/password_manager/core/browser/password_requirements_service.cc, which rejects specifications overriding character_set or setting max_length below kMinimumPasswordLength (9):

PasswordRequirementsSpec GetSanitizedSpec(
    const autofill::PasswordRequirementsSpec& spec) {
  if (spec.has_lower_case() && spec.lower_case().has_character_set()) {
    return PasswordRequirementsSpec();
  }
  // ... checks for upper_case, alphabetic, numeric ...
  if (spec.has_max_length() &&
      spec.max_length() < autofill::password_generation::kMinimumPasswordLength) {
    return PasswordRequirementsSpec();
  }
  return spec;
}

However, GetSanitizedSpec is located in an anonymous namespace inside password_requirements_service.cc and is only invoked along the PasswordRequirementsService paths inside the main browser.

The iOS Credential Provider Extension (CPE) and the //ios/web_view internal passwords framework do not use PasswordRequirementsService. Instead, they depend on PasswordSpecFetcher (ios/components/credential_provider_extension/password_spec_fetcher.mm), which performs its own parsing of fetched DomainSuggestions protobuf payloads but completely lacks any sanitization or validation logic:

// ios/components/credential_provider_extension/password_spec_fetcher.mm
- (void)onReceivedData:(NSData*)data ... {
  ...
  if (base::Base64Decode(encoded_bytes, &decoded)) {
    DomainSuggestions suggestions;
    suggestions.ParseFromString(decoded);
    if (suggestions.has_password_requirements()) {
      self.spec = suggestions.password_requirements(); // <-- Raw unsanitized spec assignment
    }
  }
  [self executeCompletion];
}

This raw, unsanitized spec is then extracted and passed directly to autofill::GeneratePassword(spec) within the extension’s mediator and the web view utilities:

  • ios/chrome/credential_provider_extension/ui/new_password_mediator.mm (line 69)
  • ios/web_view/internal/passwords/cwv_credential_provider_extension_utils.mm (line 34)

Potential Attack Scenario

(Note: These are potential steps as our tooling is limited to static analysis and we do not have a live running environment to verify)

  1. Setup: An iOS user configures Chrome as their system AutoFill provider. A network adversary with a trusted certificate (e.g., in an enterprise-managed MDM environment or a compromised CA authority) intercepts the iOS device’s traffic destined for the Autofill service.
  2. Interception: When the user triggers password generation, the proxy intercepts the API query targeting https://content-autofill.googleapis.com/v1/domainSuggestions/<target-host>?alt=proto and replies with a crafted, base64-encoded DomainSuggestions payload containing:
    password_requirements {
      max_length: 4
      lower_case { character_set: "a" }
      upper_case { character_set: "A" }
      numeric    { character_set: "1" }
    }
    
  3. Extraction: PasswordSpecFetcher parses this spec and stores it in self.spec without sanitizing it.
  4. Generation & Entropy Collapse: GeneratePassword(spec) merges the malicious specification into the default template via actual_spec.MergeFrom(spec). Since the scalar and string fields in proto2 are overwritten by the incoming message, the required password length becomes 4, and the possible character classes are restricted to "a", "A", and "1". Because the final generated password has a length of 4, it successfully bypasses the fallback check in password_generator.cc (which only resets the spec if password.size() < 4), returning a predictable low-entropy password to the user.

Suggested Fix

Move the GetSanitizedSpec() sanitization utility out of the anonymous namespace in password_requirements_service.cc into a shared core location, such as components/password_manager/core/browser/generation/password_generator.h or a shared helper inside components/autofill/core/common/. Ensure that PasswordSpecFetcher sanitizes any fetched specification prior to returning it to callers or processing it in GeneratePassword.

Evaluated with Chrome root at commit: b1520ef4a76878853a31f0943b565e42060edec8


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