Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions lib/internal/quic/quic.js
Original file line number Diff line number Diff line change
Expand Up @@ -3942,8 +3942,8 @@ class QuicEndpoint {
const {
retryTokenExpiration,
tokenExpiration,
maxConnectionsPerHost = 0,
maxConnectionsTotal = 0,
maxConnectionsPerHost = 100,
maxConnectionsTotal = 10_000,
maxStatelessResetsPerHost,
disableStatelessReset,
addressLRUSize,
Expand Down
52 changes: 39 additions & 13 deletions src/quic/endpoint.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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));
}
Expand Down
16 changes: 16 additions & 0 deletions src/quic/endpoint.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
};
Expand Down
11 changes: 10 additions & 1 deletion src/quic/session.cc
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -3640,6 +3645,10 @@ void Session::InitPerContext(Realm* realm, Local<Object> 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(
Expand Down
11 changes: 9 additions & 2 deletions src/quic/session.h
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 4 additions & 2 deletions test/parallel/test-quic-connection-limits.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
4 changes: 2 additions & 2 deletions test/parallel/test-quic-internal-endpoint-stats-state.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,8 @@ const {
isListening: false,
isClosing: false,
isBusy: false,
maxConnectionsPerHost: 0,
maxConnectionsTotal: 0,
maxConnectionsPerHost: 100,
maxConnectionsTotal: 10_000,
pendingCallbacks: '0',
});

Expand Down
Loading