diff --git a/lib/internal/quic/quic.js b/lib/internal/quic/quic.js index 6ca59469faf2de..4b58048ab9197a 100644 --- a/lib/internal/quic/quic.js +++ b/lib/internal/quic/quic.js @@ -3942,8 +3942,8 @@ class QuicEndpoint { const { retryTokenExpiration, tokenExpiration, - maxConnectionsPerHost = 0, - maxConnectionsTotal = 0, + maxConnectionsPerHost = 100, + maxConnectionsTotal = 10_000, maxStatelessResetsPerHost, disableStatelessReset, addressLRUSize, diff --git a/src/quic/endpoint.cc b/src/quic/endpoint.cc index e871d59545a6e8..8a61f51b088750 100644 --- a/src/quic/endpoint.cc +++ b/src/quic/endpoint.cc @@ -966,22 +966,31 @@ void Endpoint::SendRetry(const PathDescriptor& options) { void Endpoint::SendVersionNegotiation(const PathDescriptor& options) { Debug(this, "Sending version negotiation on path %s", options); - // While creating and sending a version negotiation packet does consume a - // small amount of system resources, and while it is fairly trivial for a - // malicious peer to force a version negotiation to be sent, these are more - // trivial to create than the cryptographically generated retry and stateless - // reset packets. If the packet is sent, then we'll at least increment the - // version_negotiation_count statistic so that application code can keep an - // eye on it. + // A malicious peer can trivially force version negotiation packets by + // sending packets with unsupported QUIC versions, potentially from + // spoofed source addresses. Rate-limit per remote host to prevent + // amplification attacks. + const auto exceeds_limits = [&] { + SocketAddressInfoTraits::Type* counts = + addr_validation_lru_.Peek(options.remote_address); + auto count = counts != nullptr ? counts->version_negotiation_count : 0; + return count >= kMaxVersionNegotiations; + }; + + if (exceeds_limits()) { + Debug(this, + "Version negotiation rate limit exceeded for %s", + options.remote_address); + return; + } + auto packet = Packet::CreateVersionNegotiationPacket(*this, options); if (packet) { + addr_validation_lru_.Upsert(options.remote_address) + ->version_negotiation_count++; STAT_INCREMENT(Stats, version_negotiation_count); Send(std::move(packet)); } - - // If creating the packet is unsuccessful, we just drop things on the floor. - // It's not worth committing any further resources to this one packet. We - // might want to log the failure at some point tho. } bool Endpoint::SendStatelessReset(const PathDescriptor& options, @@ -1028,11 +1037,28 @@ void Endpoint::SendImmediateConnectionClose(const PathDescriptor& options, "Sending immediate connection close on path %s with reason %s", options, reason); - // While it is possible for a malicious peer to cause us to create a large - // number of these, generating them is fairly trivial. + // A malicious peer can trigger immediate connection close packets by + // sending Initial packets with invalid tokens or when the server is + // busy. Rate-limit per remote host to prevent amplification attacks. + const auto exceeds_limits = [&] { + SocketAddressInfoTraits::Type* counts = + addr_validation_lru_.Peek(options.remote_address); + auto count = counts != nullptr ? counts->immediate_close_count : 0; + return count >= kMaxImmediateCloses; + }; + + if (exceeds_limits()) { + Debug(this, + "Immediate connection close rate limit exceeded for %s", + options.remote_address); + return; + } + auto packet = Packet::CreateImmediateConnectionClosePacket(*this, options, reason); if (packet) { + addr_validation_lru_.Upsert(options.remote_address) + ->immediate_close_count++; STAT_INCREMENT(Stats, immediate_close_count); Send(std::move(packet)); } diff --git a/src/quic/endpoint.h b/src/quic/endpoint.h index 188246546b6906..a9f020e0328eff 100644 --- a/src/quic/endpoint.h +++ b/src/quic/endpoint.h @@ -47,6 +47,20 @@ class Endpoint final : public AsyncWrap, public Packet::Listener { // intentionally triggering generation of a large number of retries. static constexpr uint64_t DEFAULT_MAX_RETRY_LIMIT = 10; + // Maximum number of version negotiation packets that will be sent to a + // given remote host within the LRU tracking window. Version negotiation + // packets are cheap to generate but can be used as an amplification + // vector with spoofed source addresses. + // TODO(@jasnell): Consider making this configurable via Endpoint::Options. + static constexpr uint64_t kMaxVersionNegotiations = 10; + + // Maximum number of immediate connection close packets that will be sent + // to a given remote host within the LRU tracking window. These are sent + // when the server is busy or a token is invalid — a malicious peer could + // trigger a large number of them. + // TODO(@jasnell): Consider making this configurable via Endpoint::Options. + static constexpr uint64_t kMaxImmediateCloses = 10; + // Endpoint configuration options struct Options final : public MemoryRetainer { // The local socket address to which the UDP port will be bound. The port @@ -454,6 +468,8 @@ class Endpoint final : public AsyncWrap, public Packet::Listener { struct Type final { size_t reset_count; size_t retry_count; + size_t version_negotiation_count; + size_t immediate_close_count; uint64_t timestamp; bool validated; }; diff --git a/src/quic/session.cc b/src/quic/session.cc index 4ec2b578519dd8..abcf733d443cdd 100644 --- a/src/quic/session.cc +++ b/src/quic/session.cc @@ -464,7 +464,12 @@ Session::Config::Config(Environment* env, settings.log_printf = ngtcp2_debug_log; } - settings.handshake_timeout = options.handshake_timeout; + // The handshake_timeout option is in milliseconds; ngtcp2 expects + // nanoseconds (ngtcp2_duration). UINT64_MAX means no timeout. + settings.handshake_timeout = + options.handshake_timeout == UINT64_MAX + ? UINT64_MAX + : options.handshake_timeout * NGTCP2_MILLISECONDS; settings.max_stream_window = options.max_stream_window; settings.max_window = options.max_window; settings.ack_thresh = options.unacknowledged_packet_threshold; @@ -3640,6 +3645,10 @@ void Session::InitPerContext(Realm* realm, Local target) { NODE_DEFINE_CONSTANT(target, QUIC_PROTO_MAX); NODE_DEFINE_CONSTANT(target, QUIC_PROTO_MIN); + static constexpr auto DEFAULT_HANDSHAKE_TIMEOUT = + Session::Options::DEFAULT_HANDSHAKE_TIMEOUT; + NODE_DEFINE_CONSTANT(target, DEFAULT_HANDSHAKE_TIMEOUT); + NODE_DEFINE_STRING_CONSTANT( target, "DEFAULT_CIPHERS", TLSContext::DEFAULT_CIPHERS); NODE_DEFINE_STRING_CONSTANT( diff --git a/src/quic/session.h b/src/quic/session.h index 0ac183b3272258..472079984f313a 100644 --- a/src/quic/session.h +++ b/src/quic/session.h @@ -153,8 +153,15 @@ class Session final : public AsyncWrap, private SessionTicket::AppData::Source { bool qlog = false; // The amount of time (in milliseconds) that the endpoint will wait for the - // completion of the tls handshake. - uint64_t handshake_timeout = UINT64_MAX; + // completion of the TLS handshake. If the handshake does not complete + // within this time, the session is closed. This prevents a peer from + // holding a session open indefinitely in the handshake state, consuming + // server resources (ngtcp2 connection, TLS state, JS objects) without + // ever completing the connection. The default of 10 seconds is generous + // enough to accommodate slow networks with retransmissions while still + // bounding resource exposure. Set to UINT64_MAX to disable. + static constexpr uint64_t DEFAULT_HANDSHAKE_TIMEOUT = 10'000; + uint64_t handshake_timeout = DEFAULT_HANDSHAKE_TIMEOUT; // The keep-alive timeout in milliseconds. When set to a non-zero value, // ngtcp2 will automatically send PING frames to keep the connection alive diff --git a/test/parallel/test-quic-connection-limits.mjs b/test/parallel/test-quic-connection-limits.mjs index acb0f8065d4c78..2f41c388805dc4 100644 --- a/test/parallel/test-quic-connection-limits.mjs +++ b/test/parallel/test-quic-connection-limits.mjs @@ -27,9 +27,11 @@ const endpoint = new QuicEndpoint({ maxConnectionsTotal: 1 }); // Verify the limits are readable and mutable. strictEqual(endpoint.maxConnectionsTotal, 1); -strictEqual(endpoint.maxConnectionsPerHost, 0); -endpoint.maxConnectionsPerHost = 100; +// The default maxConnectionsPerHost is 100 — a non-zero default that +// prevents a single host from exhausting server resources. strictEqual(endpoint.maxConnectionsPerHost, 100); +endpoint.maxConnectionsPerHost = 50; +strictEqual(endpoint.maxConnectionsPerHost, 50); endpoint.maxConnectionsPerHost = 0; let sessionCount = 0; diff --git a/test/parallel/test-quic-internal-endpoint-stats-state.mjs b/test/parallel/test-quic-internal-endpoint-stats-state.mjs index 015155344fde42..57044a773eb2d6 100644 --- a/test/parallel/test-quic-internal-endpoint-stats-state.mjs +++ b/test/parallel/test-quic-internal-endpoint-stats-state.mjs @@ -43,8 +43,8 @@ const { isListening: false, isClosing: false, isBusy: false, - maxConnectionsPerHost: 0, - maxConnectionsTotal: 0, + maxConnectionsPerHost: 100, + maxConnectionsTotal: 10_000, pendingCallbacks: '0', });