Add CONNECT-IP capsules

This CL introduces the capsules from draft-ietf-masque-connect-ip-03, along with tests. None of the new code is used yet.

PiperOrigin-RevId: 478570743
diff --git a/quiche/common/quiche_ip_address.cc b/quiche/common/quiche_ip_address.cc
index fd90deb..342597b 100644
--- a/quiche/common/quiche_ip_address.cc
+++ b/quiche/common/quiche_ip_address.cc
@@ -9,6 +9,7 @@
 #include <cstring>
 #include <string>
 
+#include "absl/strings/str_cat.h"
 #include "quiche/common/platform/api/quiche_bug_tracker.h"
 #include "quiche/common/platform/api/quiche_logging.h"
 #include "quiche/common/quiche_ip_address_family.h"
@@ -223,4 +224,35 @@
   return address_.v6;
 }
 
+QuicheIpPrefix::QuicheIpPrefix() : prefix_length_(0) {}
+QuicheIpPrefix::QuicheIpPrefix(const QuicheIpAddress& address)
+    : address_(address) {
+  if (address_.IsIPv6()) {
+    prefix_length_ = QuicheIpAddress::kIPv6AddressSize * 8;
+  } else if (address_.IsIPv4()) {
+    prefix_length_ = QuicheIpAddress::kIPv4AddressSize * 8;
+  } else {
+    prefix_length_ = 0;
+  }
+}
+QuicheIpPrefix::QuicheIpPrefix(const QuicheIpAddress& address,
+                               uint8_t prefix_length)
+    : address_(address), prefix_length_(prefix_length) {
+  QUICHE_DCHECK(prefix_length <= QuicheIpPrefix(address).prefix_length())
+      << "prefix_length cannot be longer than the size of the IP address";
+}
+
+std::string QuicheIpPrefix::ToString() const {
+  return absl::StrCat(address_.ToString(), "/", prefix_length_);
+}
+
+bool operator==(const QuicheIpPrefix& lhs, const QuicheIpPrefix& rhs) {
+  return lhs.address_ == rhs.address_ &&
+         lhs.prefix_length_ == rhs.prefix_length_;
+}
+
+bool operator!=(const QuicheIpPrefix& lhs, const QuicheIpPrefix& rhs) {
+  return !(lhs == rhs);
+}
+
 }  // namespace quiche
diff --git a/quiche/common/quiche_ip_address.h b/quiche/common/quiche_ip_address.h
index ef7714c..9267922 100644
--- a/quiche/common/quiche_ip_address.h
+++ b/quiche/common/quiche_ip_address.h
@@ -5,6 +5,7 @@
 #ifndef QUICHE_COMMON_QUICHE_IP_ADDRESS_H_
 #define QUICHE_COMMON_QUICHE_IP_ADDRESS_H_
 
+#include <cstdint>
 #if defined(_WIN32)
 #include <winsock2.h>
 #include <ws2tcpip.h>
@@ -93,6 +94,37 @@
   return os;
 }
 
+// Represents an IP prefix, which is an IP address and a prefix length in bits.
+class QUICHE_EXPORT_PRIVATE QuicheIpPrefix {
+ public:
+  QuicheIpPrefix();
+  explicit QuicheIpPrefix(const QuicheIpAddress& address);
+  explicit QuicheIpPrefix(const QuicheIpAddress& address,
+                          uint8_t prefix_length);
+
+  QuicheIpAddress address() const { return address_; }
+  uint8_t prefix_length() const { return prefix_length_; }
+  // Human-readable string representation of the prefix suitable for logging.
+  std::string ToString() const;
+
+  QuicheIpPrefix(const QuicheIpPrefix& other) = default;
+  QuicheIpPrefix& operator=(const QuicheIpPrefix& other) = default;
+  QuicheIpPrefix& operator=(QuicheIpPrefix&& other) = default;
+  QUICHE_EXPORT_PRIVATE friend bool operator==(const QuicheIpPrefix& lhs,
+                                               const QuicheIpPrefix& rhs);
+  QUICHE_EXPORT_PRIVATE friend bool operator!=(const QuicheIpPrefix& lhs,
+                                               const QuicheIpPrefix& rhs);
+
+ private:
+  QuicheIpAddress address_;
+  uint8_t prefix_length_;
+};
+
+inline std::ostream& operator<<(std::ostream& os, const QuicheIpPrefix prefix) {
+  os << prefix.ToString();
+  return os;
+}
+
 }  // namespace quiche
 
 #endif  // QUICHE_COMMON_QUICHE_IP_ADDRESS_H_
diff --git a/quiche/quic/core/http/capsule.cc b/quiche/quic/core/http/capsule.cc
index 3fba85e..8f653f1 100644
--- a/quiche/quic/core/http/capsule.cc
+++ b/quiche/quic/core/http/capsule.cc
@@ -14,7 +14,9 @@
 #include "quiche/quic/core/quic_data_writer.h"
 #include "quiche/quic/core/quic_types.h"
 #include "quiche/quic/platform/api/quic_bug_tracker.h"
+#include "quiche/quic/platform/api/quic_ip_address.h"
 #include "quiche/common/platform/api/quiche_logging.h"
+#include "quiche/common/quiche_ip_address.h"
 
 namespace quic {
 
@@ -26,6 +28,12 @@
       return "DATAGRAM_WITHOUT_CONTEXT";
     case CapsuleType::CLOSE_WEBTRANSPORT_SESSION:
       return "CLOSE_WEBTRANSPORT_SESSION";
+    case CapsuleType::ADDRESS_REQUEST:
+      return "ADDRESS_REQUEST";
+    case CapsuleType::ADDRESS_ASSIGN:
+      return "ADDRESS_ASSIGN";
+    case CapsuleType::ROUTE_ADVERTISEMENT:
+      return "ROUTE_ADVERTISEMENT";
   }
   return absl::StrCat("Unknown(", static_cast<uint64_t>(capsule_type), ")");
 }
@@ -41,7 +49,7 @@
       static_assert(
           std::is_standard_layout<LegacyDatagramCapsule>::value &&
               std::is_trivially_destructible<LegacyDatagramCapsule>::value,
-          "All capsule structs must have these properties");
+          "All inline capsule structs must have these properties");
       legacy_datagram_capsule_ = LegacyDatagramCapsule();
       break;
     case CapsuleType::DATAGRAM_WITHOUT_CONTEXT:
@@ -49,7 +57,7 @@
           std::is_standard_layout<DatagramWithoutContextCapsule>::value &&
               std::is_trivially_destructible<
                   DatagramWithoutContextCapsule>::value,
-          "All capsule structs must have these properties");
+          "All inline capsule structs must have these properties");
       datagram_without_context_capsule_ = DatagramWithoutContextCapsule();
       break;
     case CapsuleType::CLOSE_WEBTRANSPORT_SESSION:
@@ -57,18 +65,50 @@
           std::is_standard_layout<CloseWebTransportSessionCapsule>::value &&
               std::is_trivially_destructible<
                   CloseWebTransportSessionCapsule>::value,
-          "All capsule structs must have these properties");
+          "All inline capsule structs must have these properties");
       close_web_transport_session_capsule_ = CloseWebTransportSessionCapsule();
       break;
+    case CapsuleType::ADDRESS_REQUEST:
+      address_request_capsule_ = new AddressRequestCapsule();
+      break;
+    case CapsuleType::ADDRESS_ASSIGN:
+      address_assign_capsule_ = new AddressAssignCapsule();
+      break;
+    case CapsuleType::ROUTE_ADVERTISEMENT:
+      route_advertisement_capsule_ = new RouteAdvertisementCapsule();
+      break;
     default:
       unknown_capsule_data_ = absl::string_view();
       break;
   }
 }
 
+void Capsule::Free() {
+  switch (capsule_type_) {
+    // Inlined capsule types.
+    case CapsuleType::LEGACY_DATAGRAM:
+    case CapsuleType::DATAGRAM_WITHOUT_CONTEXT:
+    case CapsuleType::CLOSE_WEBTRANSPORT_SESSION:
+      // Do nothing, these are guaranteed to be trivially destructible.
+      break;
+    // Out-of-line capsule types.
+    case CapsuleType::ADDRESS_REQUEST:
+      delete address_request_capsule_;
+      break;
+    case CapsuleType::ADDRESS_ASSIGN:
+      delete address_assign_capsule_;
+      break;
+    case CapsuleType::ROUTE_ADVERTISEMENT:
+      delete route_advertisement_capsule_;
+      break;
+  }
+  capsule_type_ = static_cast<CapsuleType>(0x17);  // Reserved unknown value.
+  unknown_capsule_data_ = absl::string_view();
+}
+Capsule::~Capsule() { Free(); }
+
 // static
-Capsule Capsule::LegacyDatagram(
-    absl::string_view http_datagram_payload) {
+Capsule Capsule::LegacyDatagram(absl::string_view http_datagram_payload) {
   Capsule capsule(CapsuleType::LEGACY_DATAGRAM);
   capsule.legacy_datagram_capsule().http_datagram_payload =
       http_datagram_payload;
@@ -94,6 +134,21 @@
 }
 
 // static
+Capsule Capsule::AddressRequest() {
+  return Capsule(CapsuleType::ADDRESS_REQUEST);
+}
+
+// static
+Capsule Capsule::AddressAssign() {
+  return Capsule(CapsuleType::ADDRESS_ASSIGN);
+}
+
+// static
+Capsule Capsule::RouteAdvertisement() {
+  return Capsule(CapsuleType::ROUTE_ADVERTISEMENT);
+}
+
+// static
 Capsule Capsule::Unknown(uint64_t capsule_type,
                          absl::string_view unknown_capsule_data) {
   Capsule capsule(static_cast<CapsuleType>(capsule_type));
@@ -102,6 +157,7 @@
 }
 
 Capsule& Capsule::operator=(const Capsule& other) {
+  Free();
   capsule_type_ = other.capsule_type_;
   switch (capsule_type_) {
     case CapsuleType::LEGACY_DATAGRAM:
@@ -115,6 +171,18 @@
       close_web_transport_session_capsule_ =
           other.close_web_transport_session_capsule_;
       break;
+    case CapsuleType::ADDRESS_ASSIGN:
+      address_assign_capsule_ = new AddressAssignCapsule();
+      *address_assign_capsule_ = *other.address_assign_capsule_;
+      break;
+    case CapsuleType::ADDRESS_REQUEST:
+      address_request_capsule_ = new AddressRequestCapsule();
+      *address_request_capsule_ = *other.address_request_capsule_;
+      break;
+    case CapsuleType::ROUTE_ADVERTISEMENT:
+      route_advertisement_capsule_ = new RouteAdvertisementCapsule();
+      *route_advertisement_capsule_ = *other.route_advertisement_capsule_;
+      break;
     default:
       unknown_capsule_data_ = other.unknown_capsule_data_;
       break;
@@ -142,6 +210,15 @@
                  other.close_web_transport_session_capsule_.error_code &&
              close_web_transport_session_capsule_.error_message ==
                  other.close_web_transport_session_capsule_.error_message;
+    case CapsuleType::ADDRESS_REQUEST:
+      return address_request_capsule_->requested_addresses ==
+             other.address_request_capsule_->requested_addresses;
+    case CapsuleType::ADDRESS_ASSIGN:
+      return address_assign_capsule_->assigned_addresses ==
+             other.address_assign_capsule_->assigned_addresses;
+    case CapsuleType::ROUTE_ADVERTISEMENT:
+      return route_advertisement_capsule_->ip_address_ranges ==
+             other.route_advertisement_capsule_->ip_address_ranges;
     default:
       return unknown_capsule_data_ == other.unknown_capsule_data_;
   }
@@ -169,6 +246,34 @@
           ",error_message=\"",
           close_web_transport_session_capsule_.error_message, "\")");
       break;
+    case CapsuleType::ADDRESS_REQUEST: {
+      absl::StrAppend(&rv, "[");
+      for (auto requested_address :
+           address_request_capsule_->requested_addresses) {
+        absl::StrAppend(&rv, "(", requested_address.request_id, "-",
+                        requested_address.ip_prefix.ToString(), ")");
+      }
+      absl::StrAppend(&rv, "]");
+    } break;
+    case CapsuleType::ADDRESS_ASSIGN: {
+      absl::StrAppend(&rv, "[");
+      for (auto assigned_address :
+           address_assign_capsule_->assigned_addresses) {
+        absl::StrAppend(&rv, "(", assigned_address.request_id, "-",
+                        assigned_address.ip_prefix.ToString(), ")");
+      }
+      absl::StrAppend(&rv, "]");
+    } break;
+    case CapsuleType::ROUTE_ADVERTISEMENT: {
+      absl::StrAppend(&rv, "[");
+      for (auto ip_address_range :
+           route_advertisement_capsule_->ip_address_ranges) {
+        absl::StrAppend(&rv, "(", ip_address_range.start_ip_address.ToString(),
+                        "-", ip_address_range.end_ip_address.ToString(), "-",
+                        static_cast<int>(ip_address_range.ip_protocol), ")");
+      }
+      absl::StrAppend(&rv, "]");
+    } break;
     default:
       absl::StrAppend(&rv, "[", absl::BytesToHexString(unknown_capsule_data_),
                       "]");
@@ -205,6 +310,42 @@
           sizeof(WebTransportSessionError) +
           capsule.close_web_transport_session_capsule().error_message.size();
       break;
+    case CapsuleType::ADDRESS_REQUEST:
+      capsule_data_length = 0;
+      for (auto requested_address :
+           capsule.address_request_capsule().requested_addresses) {
+        capsule_data_length +=
+            QuicDataWriter::GetVarInt62Len(requested_address.request_id) + 1 +
+            (requested_address.ip_prefix.address().IsIPv4()
+                 ? QuicIpAddress::kIPv4AddressSize
+                 : QuicIpAddress::kIPv6AddressSize) +
+            1;
+      }
+      break;
+    case CapsuleType::ADDRESS_ASSIGN:
+      capsule_data_length = 0;
+      for (auto assigned_address :
+           capsule.address_assign_capsule().assigned_addresses) {
+        capsule_data_length +=
+            QuicDataWriter::GetVarInt62Len(assigned_address.request_id) + 1 +
+            (assigned_address.ip_prefix.address().IsIPv4()
+                 ? QuicIpAddress::kIPv4AddressSize
+                 : QuicIpAddress::kIPv6AddressSize) +
+            1;
+      }
+      break;
+    case CapsuleType::ROUTE_ADVERTISEMENT:
+      capsule_data_length = 0;
+      for (auto ip_address_range :
+           capsule.route_advertisement_capsule().ip_address_ranges) {
+        capsule_data_length += 1 +
+                               (ip_address_range.start_ip_address.IsIPv4()
+                                    ? QuicIpAddress::kIPv4AddressSize
+                                    : QuicIpAddress::kIPv6AddressSize) *
+                                   2 +
+                               1;
+      }
+      break;
     default:
       capsule_data_length = capsule.unknown_capsule_data().length();
       break;
@@ -254,6 +395,88 @@
         return {};
       }
       break;
+    case CapsuleType::ADDRESS_REQUEST:
+      for (auto requested_address :
+           capsule.address_request_capsule().requested_addresses) {
+        if (!writer.WriteVarInt62(requested_address.request_id)) {
+          QUIC_BUG(address request capsule id write fail)
+              << "Failed to write ADDRESS_REQUEST ID";
+          return {};
+        }
+        if (!writer.WriteUInt8(
+                requested_address.ip_prefix.address().IsIPv4() ? 4 : 6)) {
+          QUIC_BUG(address request capsule family write fail)
+              << "Failed to write ADDRESS_REQUEST family";
+          return {};
+        }
+        if (!writer.WriteStringPiece(
+                requested_address.ip_prefix.address().ToPackedString())) {
+          QUIC_BUG(address request capsule address write fail)
+              << "Failed to write ADDRESS_REQUEST address";
+          return {};
+        }
+        if (!writer.WriteUInt8(requested_address.ip_prefix.prefix_length())) {
+          QUIC_BUG(address request capsule prefix length write fail)
+              << "Failed to write ADDRESS_REQUEST prefix length";
+          return {};
+        }
+      }
+      break;
+    case CapsuleType::ADDRESS_ASSIGN:
+      for (auto assigned_address :
+           capsule.address_assign_capsule().assigned_addresses) {
+        if (!writer.WriteVarInt62(assigned_address.request_id)) {
+          QUIC_BUG(address request capsule id write fail)
+              << "Failed to write ADDRESS_ASSIGN ID";
+          return {};
+        }
+        if (!writer.WriteUInt8(
+                assigned_address.ip_prefix.address().IsIPv4() ? 4 : 6)) {
+          QUIC_BUG(address request capsule family write fail)
+              << "Failed to write ADDRESS_ASSIGN family";
+          return {};
+        }
+        if (!writer.WriteStringPiece(
+                assigned_address.ip_prefix.address().ToPackedString())) {
+          QUIC_BUG(address request capsule address write fail)
+              << "Failed to write ADDRESS_ASSIGN address";
+          return {};
+        }
+        if (!writer.WriteUInt8(assigned_address.ip_prefix.prefix_length())) {
+          QUIC_BUG(address request capsule prefix length write fail)
+              << "Failed to write ADDRESS_ASSIGN prefix length";
+          return {};
+        }
+      }
+      break;
+    case CapsuleType::ROUTE_ADVERTISEMENT:
+      for (auto ip_address_range :
+           capsule.route_advertisement_capsule().ip_address_ranges) {
+        if (!writer.WriteUInt8(
+                ip_address_range.start_ip_address.IsIPv4() ? 4 : 6)) {
+          QUIC_BUG(route advertisement capsule family write fail)
+              << "Failed to write ROUTE_ADVERTISEMENT family";
+          return {};
+        }
+        if (!writer.WriteStringPiece(
+                ip_address_range.start_ip_address.ToPackedString())) {
+          QUIC_BUG(route advertisement capsule start address write fail)
+              << "Failed to write ROUTE_ADVERTISEMENT start address";
+          return {};
+        }
+        if (!writer.WriteStringPiece(
+                ip_address_range.end_ip_address.ToPackedString())) {
+          QUIC_BUG(route advertisement capsule end address write fail)
+              << "Failed to write ROUTE_ADVERTISEMENT end address";
+          return {};
+        }
+        if (!writer.WriteUInt8(ip_address_range.ip_protocol)) {
+          QUIC_BUG(route advertisement capsule IP protocol write fail)
+              << "Failed to write ROUTE_ADVERTISEMENT IP protocol";
+          return {};
+        }
+      }
+      break;
     default:
       if (!writer.WriteStringPiece(capsule.unknown_capsule_data())) {
         QUIC_BUG(capsule data write fail) << "Failed to write CAPSULE data";
@@ -334,6 +557,154 @@
       capsule.close_web_transport_session_capsule().error_message =
           capsule_data_reader.ReadRemainingPayload();
       break;
+    case CapsuleType::ADDRESS_REQUEST: {
+      while (!capsule_data_reader.IsDoneReading()) {
+        PrefixWithId requested_address;
+        if (!capsule_data_reader.ReadVarInt62(&requested_address.request_id)) {
+          ReportParseFailure(
+              "Unable to parse capsule ADDRESS_REQUEST request ID");
+          return 0;
+        }
+        uint8_t address_family;
+        if (!capsule_data_reader.ReadUInt8(&address_family)) {
+          ReportParseFailure("Unable to parse capsule ADDRESS_REQUEST family");
+          return 0;
+        }
+        if (address_family != 4 && address_family != 6) {
+          ReportParseFailure("Bad ADDRESS_REQUEST family");
+          return 0;
+        }
+        absl::string_view ip_address_bytes;
+        if (!capsule_data_reader.ReadStringPiece(
+                &ip_address_bytes, address_family == 4
+                                       ? QuicIpAddress::kIPv4AddressSize
+                                       : QuicIpAddress::kIPv6AddressSize)) {
+          ReportParseFailure("Unable to read capsule ADDRESS_REQUEST address");
+          return 0;
+        }
+        quiche::QuicheIpAddress ip_address;
+        if (!ip_address.FromPackedString(ip_address_bytes.data(),
+                                         ip_address_bytes.size())) {
+          ReportParseFailure("Unable to parse capsule ADDRESS_REQUEST address");
+          return 0;
+        }
+        uint8_t ip_prefix_length;
+        if (!capsule_data_reader.ReadUInt8(&ip_prefix_length)) {
+          ReportParseFailure(
+              "Unable to parse capsule ADDRESS_REQUEST IP prefix length");
+          return 0;
+        }
+        if (ip_prefix_length >
+            quiche::QuicheIpPrefix(ip_address).prefix_length()) {
+          ReportParseFailure("Invalid IP prefix length");
+          return 0;
+        }
+        requested_address.ip_prefix =
+            quiche::QuicheIpPrefix(ip_address, ip_prefix_length);
+        capsule.address_request_capsule().requested_addresses.push_back(
+            requested_address);
+      }
+    } break;
+    case CapsuleType::ADDRESS_ASSIGN: {
+      while (!capsule_data_reader.IsDoneReading()) {
+        PrefixWithId assigned_address;
+        if (!capsule_data_reader.ReadVarInt62(&assigned_address.request_id)) {
+          ReportParseFailure(
+              "Unable to parse capsule ADDRESS_ASSIGN request ID");
+          return 0;
+        }
+        uint8_t address_family;
+        if (!capsule_data_reader.ReadUInt8(&address_family)) {
+          ReportParseFailure("Unable to parse capsule ADDRESS_ASSIGN family");
+          return 0;
+        }
+        if (address_family != 4 && address_family != 6) {
+          ReportParseFailure("Bad ADDRESS_ASSIGN family");
+          return 0;
+        }
+        absl::string_view ip_address_bytes;
+        if (!capsule_data_reader.ReadStringPiece(
+                &ip_address_bytes, address_family == 4
+                                       ? QuicIpAddress::kIPv4AddressSize
+                                       : QuicIpAddress::kIPv6AddressSize)) {
+          ReportParseFailure("Unable to read capsule ADDRESS_ASSIGN address");
+          return 0;
+        }
+        quiche::QuicheIpAddress ip_address;
+        if (!ip_address.FromPackedString(ip_address_bytes.data(),
+                                         ip_address_bytes.size())) {
+          ReportParseFailure("Unable to parse capsule ADDRESS_ASSIGN address");
+          return 0;
+        }
+        uint8_t ip_prefix_length;
+        if (!capsule_data_reader.ReadUInt8(&ip_prefix_length)) {
+          ReportParseFailure(
+              "Unable to parse capsule ADDRESS_ASSIGN IP prefix length");
+          return 0;
+        }
+        if (ip_prefix_length >
+            quiche::QuicheIpPrefix(ip_address).prefix_length()) {
+          ReportParseFailure("Invalid IP prefix length");
+          return 0;
+        }
+        assigned_address.ip_prefix =
+            quiche::QuicheIpPrefix(ip_address, ip_prefix_length);
+        capsule.address_assign_capsule().assigned_addresses.push_back(
+            assigned_address);
+      }
+    } break;
+    case CapsuleType::ROUTE_ADVERTISEMENT: {
+      while (!capsule_data_reader.IsDoneReading()) {
+        uint8_t address_family;
+        if (!capsule_data_reader.ReadUInt8(&address_family)) {
+          ReportParseFailure(
+              "Unable to parse capsule ROUTE_ADVERTISEMENT family");
+          return 0;
+        }
+        if (address_family != 4 && address_family != 6) {
+          ReportParseFailure("Bad ROUTE_ADVERTISEMENT family");
+          return 0;
+        }
+        IpAddressRange ip_address_range;
+        absl::string_view start_ip_address_bytes;
+        if (!capsule_data_reader.ReadStringPiece(
+                &start_ip_address_bytes,
+                address_family == 4 ? QuicIpAddress::kIPv4AddressSize
+                                    : QuicIpAddress::kIPv6AddressSize)) {
+          ReportParseFailure(
+              "Unable to read capsule ROUTE_ADVERTISEMENT start address");
+          return 0;
+        }
+        if (!ip_address_range.start_ip_address.FromPackedString(
+                start_ip_address_bytes.data(), start_ip_address_bytes.size())) {
+          ReportParseFailure(
+              "Unable to parse capsule ROUTE_ADVERTISEMENT start address");
+          return 0;
+        }
+        absl::string_view end_ip_address_bytes;
+        if (!capsule_data_reader.ReadStringPiece(
+                &end_ip_address_bytes, address_family == 4
+                                           ? QuicIpAddress::kIPv4AddressSize
+                                           : QuicIpAddress::kIPv6AddressSize)) {
+          ReportParseFailure(
+              "Unable to read capsule ROUTE_ADVERTISEMENT end address");
+          return 0;
+        }
+        if (!ip_address_range.end_ip_address.FromPackedString(
+                end_ip_address_bytes.data(), end_ip_address_bytes.size())) {
+          ReportParseFailure(
+              "Unable to parse capsule ROUTE_ADVERTISEMENT end address");
+          return 0;
+        }
+        if (!capsule_data_reader.ReadUInt8(&ip_address_range.ip_protocol)) {
+          ReportParseFailure(
+              "Unable to parse capsule ROUTE_ADVERTISEMENT IP protocol");
+          return 0;
+        }
+        capsule.route_advertisement_capsule().ip_address_ranges.push_back(
+            ip_address_range);
+      }
+    } break;
     default:
       capsule.unknown_capsule_data() =
           capsule_data_reader.ReadRemainingPayload();
@@ -363,4 +734,28 @@
   }
 }
 
+bool PrefixWithId::operator==(const PrefixWithId& other) const {
+  return request_id == other.request_id && ip_prefix == other.ip_prefix;
+}
+
+bool IpAddressRange::operator==(const IpAddressRange& other) const {
+  return start_ip_address == other.start_ip_address &&
+         end_ip_address == other.end_ip_address &&
+         ip_protocol == other.ip_protocol;
+}
+
+bool AddressAssignCapsule::operator==(const AddressAssignCapsule& other) const {
+  return assigned_addresses == other.assigned_addresses;
+}
+
+bool AddressRequestCapsule::operator==(
+    const AddressRequestCapsule& other) const {
+  return requested_addresses == other.requested_addresses;
+}
+
+bool RouteAdvertisementCapsule::operator==(
+    const RouteAdvertisementCapsule& other) const {
+  return ip_address_ranges == other.ip_address_ranges;
+}
+
 }  // namespace quic
diff --git a/quiche/quic/core/http/capsule.h b/quiche/quic/core/http/capsule.h
index 7b4cc46..3d83a55 100644
--- a/quiche/quic/core/http/capsule.h
+++ b/quiche/quic/core/http/capsule.h
@@ -7,6 +7,7 @@
 
 #include <cstdint>
 #include <string>
+#include <vector>
 
 #include "absl/strings/str_cat.h"
 #include "absl/strings/string_view.h"
@@ -15,15 +16,20 @@
 #include "quiche/quic/core/quic_types.h"
 #include "quiche/common/platform/api/quiche_logging.h"
 #include "quiche/common/quiche_buffer_allocator.h"
+#include "quiche/common/quiche_ip_address.h"
 
 namespace quic {
 
 enum class CapsuleType : uint64_t {
-  // Casing in this enum matches the IETF specification.
+  // Casing in this enum matches the IETF specifications.
   LEGACY_DATAGRAM = 0xff37a0,  // draft-ietf-masque-h3-datagram-04.
   DATAGRAM_WITHOUT_CONTEXT =
       0xff37a5,  // draft-ietf-masque-h3-datagram-05 to -08.
   CLOSE_WEBTRANSPORT_SESSION = 0x2843,
+  // draft-ietf-masque-connect-ip-03.
+  ADDRESS_ASSIGN = 0x1ECA6A00,
+  ADDRESS_REQUEST = 0x1ECA6A01,
+  ROUTE_ADVERTISEMENT = 0x1ECA6A02,
 };
 
 QUIC_EXPORT_PRIVATE std::string CapsuleTypeToString(CapsuleType capsule_type);
@@ -40,6 +46,29 @@
   WebTransportSessionError error_code;
   absl::string_view error_message;
 };
+struct QUIC_EXPORT_PRIVATE PrefixWithId {
+  uint64_t request_id;
+  quiche::QuicheIpPrefix ip_prefix;
+  bool operator==(const PrefixWithId& other) const;
+};
+struct QUIC_EXPORT_PRIVATE IpAddressRange {
+  quiche::QuicheIpAddress start_ip_address;
+  quiche::QuicheIpAddress end_ip_address;
+  uint8_t ip_protocol;
+  bool operator==(const IpAddressRange& other) const;
+};
+struct QUIC_EXPORT_PRIVATE AddressAssignCapsule {
+  std::vector<PrefixWithId> assigned_addresses;
+  bool operator==(const AddressAssignCapsule& other) const;
+};
+struct QUIC_EXPORT_PRIVATE AddressRequestCapsule {
+  std::vector<PrefixWithId> requested_addresses;
+  bool operator==(const AddressRequestCapsule& other) const;
+};
+struct QUIC_EXPORT_PRIVATE RouteAdvertisementCapsule {
+  std::vector<IpAddressRange> ip_address_ranges;
+  bool operator==(const RouteAdvertisementCapsule& other) const;
+};
 
 // Capsule from draft-ietf-masque-h3-datagram.
 // IMPORTANT NOTE: Capsule does not own any of the absl::string_view memory it
@@ -55,11 +84,15 @@
   static Capsule CloseWebTransportSession(
       WebTransportSessionError error_code = 0,
       absl::string_view error_message = "");
+  static Capsule AddressRequest();
+  static Capsule AddressAssign();
+  static Capsule RouteAdvertisement();
   static Capsule Unknown(
       uint64_t capsule_type,
       absl::string_view unknown_capsule_data = absl::string_view());
 
   explicit Capsule(CapsuleType capsule_type);
+  ~Capsule();
   Capsule(const Capsule& other);
   Capsule& operator=(const Capsule& other);
   bool operator==(const Capsule& other) const;
@@ -96,27 +129,61 @@
     QUICHE_DCHECK_EQ(capsule_type_, CapsuleType::CLOSE_WEBTRANSPORT_SESSION);
     return close_web_transport_session_capsule_;
   }
+  AddressRequestCapsule& address_request_capsule() {
+    QUICHE_DCHECK_EQ(capsule_type_, CapsuleType::ADDRESS_REQUEST);
+    return *address_request_capsule_;
+  }
+  const AddressRequestCapsule& address_request_capsule() const {
+    QUICHE_DCHECK_EQ(capsule_type_, CapsuleType::ADDRESS_REQUEST);
+    return *address_request_capsule_;
+  }
+  AddressAssignCapsule& address_assign_capsule() {
+    QUICHE_DCHECK_EQ(capsule_type_, CapsuleType::ADDRESS_ASSIGN);
+    return *address_assign_capsule_;
+  }
+  const AddressAssignCapsule& address_assign_capsule() const {
+    QUICHE_DCHECK_EQ(capsule_type_, CapsuleType::ADDRESS_ASSIGN);
+    return *address_assign_capsule_;
+  }
+  RouteAdvertisementCapsule& route_advertisement_capsule() {
+    QUICHE_DCHECK_EQ(capsule_type_, CapsuleType::ROUTE_ADVERTISEMENT);
+    return *route_advertisement_capsule_;
+  }
+  const RouteAdvertisementCapsule& route_advertisement_capsule() const {
+    QUICHE_DCHECK_EQ(capsule_type_, CapsuleType::ROUTE_ADVERTISEMENT);
+    return *route_advertisement_capsule_;
+  }
   absl::string_view& unknown_capsule_data() {
     QUICHE_DCHECK(capsule_type_ != CapsuleType::LEGACY_DATAGRAM &&
                   capsule_type_ != CapsuleType::DATAGRAM_WITHOUT_CONTEXT &&
-                  capsule_type_ != CapsuleType::CLOSE_WEBTRANSPORT_SESSION)
+                  capsule_type_ != CapsuleType::CLOSE_WEBTRANSPORT_SESSION &&
+                  capsule_type_ != CapsuleType::ADDRESS_REQUEST &&
+                  capsule_type_ != CapsuleType::ADDRESS_ASSIGN &&
+                  capsule_type_ != CapsuleType::ROUTE_ADVERTISEMENT)
         << capsule_type_;
     return unknown_capsule_data_;
   }
   const absl::string_view& unknown_capsule_data() const {
     QUICHE_DCHECK(capsule_type_ != CapsuleType::LEGACY_DATAGRAM &&
                   capsule_type_ != CapsuleType::DATAGRAM_WITHOUT_CONTEXT &&
-                  capsule_type_ != CapsuleType::CLOSE_WEBTRANSPORT_SESSION)
+                  capsule_type_ != CapsuleType::CLOSE_WEBTRANSPORT_SESSION &&
+                  capsule_type_ != CapsuleType::ADDRESS_REQUEST &&
+                  capsule_type_ != CapsuleType::ADDRESS_ASSIGN &&
+                  capsule_type_ != CapsuleType::ROUTE_ADVERTISEMENT)
         << capsule_type_;
     return unknown_capsule_data_;
   }
 
  private:
+  void Free();
   CapsuleType capsule_type_;
   union {
     LegacyDatagramCapsule legacy_datagram_capsule_;
     DatagramWithoutContextCapsule datagram_without_context_capsule_;
     CloseWebTransportSessionCapsule close_web_transport_session_capsule_;
+    AddressRequestCapsule* address_request_capsule_;
+    AddressAssignCapsule* address_assign_capsule_;
+    RouteAdvertisementCapsule* route_advertisement_capsule_;
     absl::string_view unknown_capsule_data_;
   };
 };
diff --git a/quiche/quic/core/http/capsule_test.cc b/quiche/quic/core/http/capsule_test.cc
index 1791bb6..a686095 100644
--- a/quiche/quic/core/http/capsule_test.cc
+++ b/quiche/quic/core/http/capsule_test.cc
@@ -13,6 +13,7 @@
 #include "absl/strings/string_view.h"
 #include "quiche/quic/platform/api/quic_test.h"
 #include "quiche/quic/test_tools/quic_test_utils.h"
+#include "quiche/common/quiche_ip_address.h"
 #include "quiche/common/test_tools/quiche_test_utils.h"
 
 using ::testing::_;
@@ -116,6 +117,123 @@
   TestSerialization(expected_capsule, capsule_fragment);
 }
 
+TEST_F(CapsuleTest, AddressAssignCapsule) {
+  std::string capsule_fragment = absl::HexStringToBytes(
+      "9ECA6A00"  // ADDRESS_ASSIGN capsule type
+      "1A"        // capsule length = 26
+      // first assigned address
+      "00"        // request ID = 0
+      "04"        // IP version = 4
+      "C000022A"  // 192.0.2.42
+      "1F"        // prefix length = 31
+      // second assigned address
+      "01"                                // request ID = 1
+      "06"                                // IP version = 6
+      "20010db8123456780000000000000000"  // 2001:db8:1234:5678::
+      "40"                                // prefix length = 64
+  );
+  Capsule expected_capsule = Capsule::AddressAssign();
+  quiche::QuicheIpAddress ip_address1;
+  ip_address1.FromString("192.0.2.42");
+  PrefixWithId assigned_address1;
+  assigned_address1.request_id = 0;
+  assigned_address1.ip_prefix =
+      quiche::QuicheIpPrefix(ip_address1, /*prefix_length=*/31);
+  expected_capsule.address_assign_capsule().assigned_addresses.push_back(
+      assigned_address1);
+  quiche::QuicheIpAddress ip_address2;
+  ip_address2.FromString("2001:db8:1234:5678::");
+  PrefixWithId assigned_address2;
+  assigned_address2.request_id = 1;
+  assigned_address2.ip_prefix =
+      quiche::QuicheIpPrefix(ip_address2, /*prefix_length=*/64);
+  expected_capsule.address_assign_capsule().assigned_addresses.push_back(
+      assigned_address2);
+  {
+    EXPECT_CALL(visitor_, OnCapsule(expected_capsule));
+    ASSERT_TRUE(capsule_parser_.IngestCapsuleFragment(capsule_fragment));
+  }
+  ValidateParserIsEmpty();
+  TestSerialization(expected_capsule, capsule_fragment);
+}
+
+TEST_F(CapsuleTest, AddressRequestCapsule) {
+  std::string capsule_fragment = absl::HexStringToBytes(
+      "9ECA6A01"  // ADDRESS_REQUEST capsule type
+      "1A"        // capsule length = 26
+      // first requested address
+      "00"        // request ID = 0
+      "04"        // IP version = 4
+      "C000022A"  // 192.0.2.42
+      "1F"        // prefix length = 31
+      // second requested address
+      "01"                                // request ID = 1
+      "06"                                // IP version = 6
+      "20010db8123456780000000000000000"  // 2001:db8:1234:5678::
+      "40"                                // prefix length = 64
+  );
+  Capsule expected_capsule = Capsule::AddressRequest();
+  quiche::QuicheIpAddress ip_address1;
+  ip_address1.FromString("192.0.2.42");
+  PrefixWithId requested_address1;
+  requested_address1.request_id = 0;
+  requested_address1.ip_prefix =
+      quiche::QuicheIpPrefix(ip_address1, /*prefix_length=*/31);
+  expected_capsule.address_request_capsule().requested_addresses.push_back(
+      requested_address1);
+  quiche::QuicheIpAddress ip_address2;
+  ip_address2.FromString("2001:db8:1234:5678::");
+  PrefixWithId requested_address2;
+  requested_address2.request_id = 1;
+  requested_address2.ip_prefix =
+      quiche::QuicheIpPrefix(ip_address2, /*prefix_length=*/64);
+  expected_capsule.address_request_capsule().requested_addresses.push_back(
+      requested_address2);
+  {
+    EXPECT_CALL(visitor_, OnCapsule(expected_capsule));
+    ASSERT_TRUE(capsule_parser_.IngestCapsuleFragment(capsule_fragment));
+  }
+  ValidateParserIsEmpty();
+  TestSerialization(expected_capsule, capsule_fragment);
+}
+
+TEST_F(CapsuleTest, RouteAdvertisementCapsule) {
+  std::string capsule_fragment = absl::HexStringToBytes(
+      "9ECA6A02"  // ROUTE_ADVERTISEMENT capsule type
+      "2C"        // capsule length = 44
+      // first IP address range
+      "04"        // IP version = 4
+      "C0000218"  // 192.0.2.24
+      "C000022A"  // 192.0.2.42
+      "00"        // ip protocol = 0
+      // second IP address range
+      "06"                                // IP version = 6
+      "00000000000000000000000000000000"  // ::
+      "ffffffffffffffffffffffffffffffff"  // all ones IPv6 address
+      "01"                                // ip protocol = 1 (ICMP)
+  );
+  Capsule expected_capsule = Capsule::RouteAdvertisement();
+  IpAddressRange ip_address_range1;
+  ip_address_range1.start_ip_address.FromString("192.0.2.24");
+  ip_address_range1.end_ip_address.FromString("192.0.2.42");
+  ip_address_range1.ip_protocol = 0;
+  expected_capsule.route_advertisement_capsule().ip_address_ranges.push_back(
+      ip_address_range1);
+  IpAddressRange ip_address_range2;
+  ip_address_range2.start_ip_address.FromString("::");
+  ip_address_range2.end_ip_address.FromString(
+      "ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff");
+  ip_address_range2.ip_protocol = 1;
+  expected_capsule.route_advertisement_capsule().ip_address_ranges.push_back(
+      ip_address_range2);
+  {
+    EXPECT_CALL(visitor_, OnCapsule(expected_capsule));
+    ASSERT_TRUE(capsule_parser_.IngestCapsuleFragment(capsule_fragment));
+  }
+  ValidateParserIsEmpty();
+  TestSerialization(expected_capsule, capsule_fragment);
+}
+
 TEST_F(CapsuleTest, UnknownCapsule) {
   std::string capsule_fragment = absl::HexStringToBytes(
       "33"                // unknown capsule type of 0x33
diff --git a/quiche/quic/core/http/quic_spdy_stream.cc b/quiche/quic/core/http/quic_spdy_stream.cc
index c2c269a..7b580be 100644
--- a/quiche/quic/core/http/quic_spdy_stream.cc
+++ b/quiche/quic/core/http/quic_spdy_stream.cc
@@ -292,6 +292,10 @@
     }
   }
 
+  if (connect_ip_visitor_ != nullptr) {
+    connect_ip_visitor_->OnHeadersWritten();
+  }
+
   return bytes_written;
 }
 
@@ -1361,6 +1365,24 @@
           capsule.close_web_transport_session_capsule().error_code,
           capsule.close_web_transport_session_capsule().error_message);
     } break;
+    case CapsuleType::ADDRESS_ASSIGN:
+      if (connect_ip_visitor_ == nullptr) {
+        return true;
+      }
+      return connect_ip_visitor_->OnAddressAssignCapsule(
+          capsule.address_assign_capsule());
+    case CapsuleType::ADDRESS_REQUEST:
+      if (connect_ip_visitor_ == nullptr) {
+        return true;
+      }
+      return connect_ip_visitor_->OnAddressRequestCapsule(
+          capsule.address_request_capsule());
+    case CapsuleType::ROUTE_ADVERTISEMENT:
+      if (connect_ip_visitor_ == nullptr) {
+        return true;
+      }
+      return connect_ip_visitor_->OnRouteAdvertisementCapsule(
+          capsule.route_advertisement_capsule());
   }
   return true;
 }
@@ -1441,6 +1463,43 @@
   datagram_visitor_ = visitor;
 }
 
+void QuicSpdyStream::RegisterConnectIpVisitor(ConnectIpVisitor* visitor) {
+  if (visitor == nullptr) {
+    QUIC_BUG(null connect - ip visitor)
+        << ENDPOINT << "Null connect-ip visitor for stream ID " << id();
+    return;
+  }
+  QUIC_DLOG(INFO) << ENDPOINT
+                  << "Registering CONNECT-IP visitor with stream ID " << id();
+
+  if (connect_ip_visitor_ != nullptr) {
+    QUIC_BUG(connect - ip double registration)
+        << ENDPOINT << "Attempted to doubly register CONNECT-IP with stream ID "
+        << id();
+    return;
+  }
+  connect_ip_visitor_ = visitor;
+}
+
+void QuicSpdyStream::UnregisterConnectIpVisitor() {
+  if (connect_ip_visitor_ == nullptr) {
+    QUIC_BUG(connect - ip visitor empty during unregistration)
+        << ENDPOINT << "Cannot unregister CONNECT-IP visitor for stream ID "
+        << id();
+    return;
+  }
+  QUIC_DLOG(INFO) << ENDPOINT
+                  << "Unregistering CONNECT-IP visitor for stream ID " << id();
+  connect_ip_visitor_ = nullptr;
+}
+
+void QuicSpdyStream::ReplaceConnectIpVisitor(ConnectIpVisitor* visitor) {
+  QUIC_BUG_IF(connect - ip unknown move, connect_ip_visitor_ == nullptr)
+      << "Attempted to move missing CONNECT-IP visitor on HTTP/3 stream ID "
+      << id();
+  connect_ip_visitor_ = visitor;
+}
+
 void QuicSpdyStream::SetMaxDatagramTimeInQueue(
     QuicTime::Delta max_time_in_queue) {
   spdy_session_->SetMaxDatagramTimeInQueueForStreamId(id(), max_time_in_queue);
diff --git a/quiche/quic/core/http/quic_spdy_stream.h b/quiche/quic/core/http/quic_spdy_stream.h
index 62b0eea..b5b6e9e 100644
--- a/quiche/quic/core/http/quic_spdy_stream.h
+++ b/quiche/quic/core/http/quic_spdy_stream.h
@@ -279,6 +279,31 @@
   // Mainly meant to be used by the visitors' move operators.
   void ReplaceHttp3DatagramVisitor(Http3DatagramVisitor* visitor);
 
+  class QUIC_EXPORT_PRIVATE ConnectIpVisitor {
+   public:
+    virtual ~ConnectIpVisitor() {}
+
+    virtual bool OnAddressAssignCapsule(
+        const AddressAssignCapsule& capsule) = 0;
+    virtual bool OnAddressRequestCapsule(
+        const AddressRequestCapsule& capsule) = 0;
+    virtual bool OnRouteAdvertisementCapsule(
+        const RouteAdvertisementCapsule& capsule) = 0;
+    virtual void OnHeadersWritten() = 0;
+  };
+
+  // Registers |visitor| to receive CONNECT-IP capsules. |visitor| must be
+  // valid until a corresponding call to UnregisterConnectIpVisitor.
+  void RegisterConnectIpVisitor(ConnectIpVisitor* visitor);
+
+  // Unregisters a CONNECT-IP visitor. Must only be called after a call to
+  // RegisterConnectIpVisitor.
+  void UnregisterConnectIpVisitor();
+
+  // Replaces the current CONNECT-IP visitor with a different visitor.
+  // Mainly meant to be used by the visitors' move operators.
+  void ReplaceConnectIpVisitor(ConnectIpVisitor* visitor);
+
   // Sets max datagram time in queue.
   void SetMaxDatagramTimeInQueue(QuicTime::Delta max_time_in_queue);
 
@@ -449,6 +474,8 @@
 
   // HTTP/3 Datagram support.
   Http3DatagramVisitor* datagram_visitor_ = nullptr;
+  // CONNECT-IP support.
+  ConnectIpVisitor* connect_ip_visitor_ = nullptr;
 };
 
 }  // namespace quic
diff --git a/quiche/quic/core/http/quic_spdy_stream_test.cc b/quiche/quic/core/http/quic_spdy_stream_test.cc
index 4695f25..c6cd92c 100644
--- a/quiche/quic/core/http/quic_spdy_stream_test.cc
+++ b/quiche/quic/core/http/quic_spdy_stream_test.cc
@@ -15,6 +15,7 @@
 #include "absl/strings/str_cat.h"
 #include "absl/strings/string_view.h"
 #include "quiche/quic/core/crypto/null_encrypter.h"
+#include "quiche/quic/core/http/capsule.h"
 #include "quiche/quic/core/http/http_encoder.h"
 #include "quiche/quic/core/http/quic_spdy_session.h"
 #include "quiche/quic/core/http/spdy_utils.h"
@@ -36,6 +37,7 @@
 #include "quiche/quic/test_tools/quic_spdy_stream_peer.h"
 #include "quiche/quic/test_tools/quic_stream_peer.h"
 #include "quiche/quic/test_tools/quic_test_utils.h"
+#include "quiche/common/quiche_ip_address.h"
 #include "quiche/common/quiche_mem_slice_storage.h"
 #include "quiche/common/simple_buffer_allocator.h"
 
@@ -3161,6 +3163,64 @@
   EXPECT_GT(stream_->GetMaxDatagramSize(), 512u);
 }
 
+TEST_P(QuicSpdyStreamTest, Capsules) {
+  if (!UsesHttp3()) {
+    return;
+  }
+  Initialize(kShouldProcessData);
+  session_->set_local_http_datagram_support(HttpDatagramSupport::kDraft09);
+  QuicSpdySessionPeer::SetHttpDatagramSupport(session_.get(),
+                                              HttpDatagramSupport::kDraft09);
+  SavingHttp3DatagramVisitor h3_datagram_visitor;
+  stream_->RegisterHttp3DatagramVisitor(&h3_datagram_visitor);
+  SavingConnectIpVisitor connect_ip_visitor;
+  stream_->RegisterConnectIpVisitor(&connect_ip_visitor);
+  headers_[":method"] = "CONNECT";
+  headers_[":protocol"] = "fake-capsule-protocol";
+  ProcessHeaders(/*fin=*/false, headers_);
+  // Datagram capsule.
+  std::string http_datagram_payload = {1, 2, 3, 4, 5, 6};
+  stream_->OnCapsule(Capsule::DatagramWithoutContext(http_datagram_payload));
+  EXPECT_THAT(h3_datagram_visitor.received_h3_datagrams(),
+              ElementsAre(SavingHttp3DatagramVisitor::SavedHttp3Datagram{
+                  stream_->id(), http_datagram_payload}));
+  // Address assign capsule.
+  PrefixWithId ip_prefix_with_id;
+  ip_prefix_with_id.request_id = 1;
+  quiche::QuicheIpAddress ip_address;
+  ip_address.FromString("::");
+  ip_prefix_with_id.ip_prefix =
+      quiche::QuicheIpPrefix(ip_address, /*prefix_length=*/96);
+  Capsule address_assign_capsule = Capsule::AddressAssign();
+  address_assign_capsule.address_assign_capsule().assigned_addresses.push_back(
+      ip_prefix_with_id);
+  stream_->OnCapsule(address_assign_capsule);
+  EXPECT_THAT(connect_ip_visitor.received_address_assign_capsules(),
+              ElementsAre(address_assign_capsule.address_assign_capsule()));
+  // Address request capsule.
+  Capsule address_request_capsule = Capsule::AddressRequest();
+  address_request_capsule.address_request_capsule()
+      .requested_addresses.push_back(ip_prefix_with_id);
+  stream_->OnCapsule(address_request_capsule);
+  EXPECT_THAT(connect_ip_visitor.received_address_request_capsules(),
+              ElementsAre(address_request_capsule.address_request_capsule()));
+  // Route advertisement capsule.
+  Capsule route_advertisement_capsule = Capsule::RouteAdvertisement();
+  IpAddressRange ip_address_range;
+  ip_address_range.start_ip_address.FromString("192.0.2.24");
+  ip_address_range.end_ip_address.FromString("192.0.2.42");
+  ip_address_range.ip_protocol = 0;
+  route_advertisement_capsule.route_advertisement_capsule()
+      .ip_address_ranges.push_back(ip_address_range);
+  stream_->OnCapsule(route_advertisement_capsule);
+  EXPECT_THAT(
+      connect_ip_visitor.received_route_advertisement_capsules(),
+      ElementsAre(route_advertisement_capsule.route_advertisement_capsule()));
+  // Cleanup.
+  stream_->UnregisterHttp3DatagramVisitor();
+  stream_->UnregisterConnectIpVisitor();
+}
+
 TEST_P(QuicSpdyStreamTest,
        QUIC_TEST_DISABLED_IN_CHROME(HeadersAccumulatorNullptr)) {
   if (!UsesHttp3()) {
diff --git a/quiche/quic/test_tools/quic_test_utils.h b/quiche/quic/test_tools/quic_test_utils.h
index 7981335..9808498 100644
--- a/quiche/quic/test_tools/quic_test_utils.h
+++ b/quiche/quic/test_tools/quic_test_utils.h
@@ -2100,6 +2100,46 @@
   std::vector<SavedHttp3Datagram> received_h3_datagrams_;
 };
 
+// Implementation of ConnectIpVisitor which saves all received capsules.
+class SavingConnectIpVisitor : public QuicSpdyStream::ConnectIpVisitor {
+ public:
+  const std::vector<AddressAssignCapsule>& received_address_assign_capsules()
+      const {
+    return received_address_assign_capsules_;
+  }
+  const std::vector<AddressRequestCapsule>& received_address_request_capsules()
+      const {
+    return received_address_request_capsules_;
+  }
+  const std::vector<RouteAdvertisementCapsule>&
+  received_route_advertisement_capsules() const {
+    return received_route_advertisement_capsules_;
+  }
+  bool headers_written() const { return headers_written_; }
+
+  // From QuicSpdyStream::ConnectIpVisitor.
+  bool OnAddressAssignCapsule(const AddressAssignCapsule& capsule) override {
+    received_address_assign_capsules_.push_back(capsule);
+    return true;
+  }
+  bool OnAddressRequestCapsule(const AddressRequestCapsule& capsule) override {
+    received_address_request_capsules_.push_back(capsule);
+    return true;
+  }
+  bool OnRouteAdvertisementCapsule(
+      const RouteAdvertisementCapsule& capsule) override {
+    received_route_advertisement_capsules_.push_back(capsule);
+    return true;
+  }
+  void OnHeadersWritten() override { headers_written_ = true; }
+
+ private:
+  std::vector<AddressAssignCapsule> received_address_assign_capsules_;
+  std::vector<AddressRequestCapsule> received_address_request_capsules_;
+  std::vector<RouteAdvertisementCapsule> received_route_advertisement_capsules_;
+  bool headers_written_ = false;
+};
+
 inline std::string EscapeTestParamName(absl::string_view name) {
   std::string result(name);
   // Escape all characters that are not allowed by gtest ([a-zA-Z0-9_]).