net-ssh 8.0.0.beta3 → 8.0.0.rc2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: ee72c6673cb55fe69cdb90bc49d0b7266b29cb80f7a14794f873e048075cde2b
4
- data.tar.gz: b9416f51d2b80d5d0bbbcac1b33a4272d5b5add59d81421ca8eaa84db2fd0823
3
+ metadata.gz: cd6a3f2cc54c2b16873c1bcd92038d6c70e09581fd29c5a6674b708a76c9fec9
4
+ data.tar.gz: 80fd4270605a25552b4cf7c0ca0c73b358f3806bd3a6654d15b94682fce0ff81
5
5
  SHA512:
6
- metadata.gz: 4f1d529b4af06145b892c6ece94d2fcc6a9d2b65b93db633189b71988b8bc03aa64feffdfce3509ce64809378ff542b04f01381419d5a9e11246c7303d258583
7
- data.tar.gz: 2170524d2a24e1ec8d0c07c3237d140ad00c412c59875f6f178b6f4a2c47ce9d16f8418dee56e0425d8ec8a214f50e0cc96e434d1b6e37998125e1cda9ebb41e
6
+ metadata.gz: 0cd7d51126432199154b9aeeaf28dd6f0df05c6673c4ccf580b27a84c54d6865500b6ff4e2814e9242cc7dc882d3eec7d95000856b5b732dab97d493d1eabe83
7
+ data.tar.gz: abce0bd3fcd5b47f74cc9fae9e684282e927b3af17c424e265f87aaf41fb85afbda631a588f080687804102a75692fa2086b8e82bda4af2d99ae824f2251b33d
checksums.yaml.gz.sig CHANGED
Binary file
@@ -7,7 +7,7 @@ jobs:
7
7
  runs-on: ubuntu-22.04
8
8
  strategy:
9
9
  matrix:
10
- ruby-version: [2.7.8, 3.0.6, 3.1.3, 3.2.1, 3.3.0, 3.4]
10
+ ruby-version: [2.7.8, 3.0.6, 3.1.3, 3.2.1, 3.3, 3.4, '4.0']
11
11
  steps:
12
12
  - uses: actions/checkout@v3
13
13
 
data/CHANGES.txt CHANGED
@@ -1,7 +1,34 @@
1
+ === 8.0.0 rc2
2
+
3
+ * Security: refuse agent channels opened by the server unless agent forwarding was requested (`forward_agent`, ForwardAgent, or `Forward#agent`); any use of port forwarding let a malicious server use the local ssh-agent [GHSA-qpg5-fx6p-8cq3]
4
+ * Security: reject host and user names containing shell metacharacters, and non-numeric ports, before substituting them into ProxyCommand/ProxyJump commands, as OpenSSH does since 9.6 [GHSA-3x7f-q7wf-fp73]
5
+ * Security: reject OpenSSH private keys claiming more than 2**20 bcrypt rounds, and only run the KDF once a passphrase is given [GHSA-rpq3-v334-f5pm]
6
+ * Security: bound the server's identification banner like OpenSSH (8192-byte lines, 1024 lines), and apply `:timeout` to the whole version exchange instead of only its first byte [GHSA-r75w-7fhp-84w8]
7
+ * Security: reject packet lengths below 5 or above 256 KB, as OpenSSH does [GHSA-pfqh-p9vh-wx4p]
8
+ * Security: treat unexpected packets during the initial key exchange as a protocol error, and bound packets queued during a re-key [GHSA-ggxh-cfwq-5xqc]
9
+
10
+ === 8.0.0 rc1
11
+
12
+ * BREAKING: TCP_NODELAY is now set on the connection socket by default, as OpenSSH does; pass `no_delay: false` to keep Nagle's algorithm on [#1017]
13
+ * Ruby 4.0 support: add `logger` as a runtime dependency; Windows Pageant users on Ruby 4.0 need `fiddle` in their Gemfile [#984, #1012]
14
+ * Close the socket when connection setup fails, e.g. on a server version timeout [#1013]
15
+ * Ignore SSH_MSG_EXT_INFO from servers that send it unrequested [#1020]
16
+ * Fix `undefined method 'to_pem'` for ECDSA key_data when an agent is running [#982]
17
+ * Agent forwarding connects to the agent set by `identity_agent`/IdentityAgent [#974]
18
+ * Keyboard-interactive prompts show input when the server asks for it to be echoed [#1014]
19
+ * Raise Net::SSH::Exception on a truncated KEXINIT [#1016]
20
+ * Remote forwarding no longer prints diagnostics to stdout [#953]
21
+
1
22
  === 8.0.0 beta3
2
23
 
3
24
  * Verify @cert-authority host certificate constraints — principals (shell-glob, matching OpenSSH), validity window, host type, and critical options — instead of trusting any CA-signed certificate for any host [#1009]
4
25
 
26
+ === 8.0.0 beta1
27
+
28
+ * BREAKING: require Ruby 2.7 and the openssl gem >= 3.2 [#1001]
29
+ * Use OpenSSL for Ed25519, X25519 and ChaCha20-Poly1305; the ed25519, x25519 and rbnacl gems are no longer needed [#1001]
30
+ * Support AEAD-encrypted OpenSSH private keys [#1001]
31
+
5
32
  === 7.3.3
6
33
 
7
34
  * Support .pub file as IdentityFile to filter/prioritize agent keys (1Password SSH agent use case) [#942]
data/Dockerfile CHANGED
@@ -3,6 +3,9 @@ FROM ruby:${RUBY_VERSION}
3
3
 
4
4
  ARG BUNDLERV=
5
5
 
6
+ # Ruby 2.7 and 3.0 images are built on Debian bullseye, which only archive.debian.org still serves.
7
+ RUN if grep -q bullseye /etc/os-release; then sed -i 's|deb.debian.org|archive.debian.org|' /etc/apt/sources.list; fi
8
+
6
9
  RUN apt update && apt install -y openssh-server sudo netcat-openbsd \
7
10
  && useradd --create-home --shell '/bin/bash' --comment 'NetSSH' 'net_ssh_1' \
8
11
  && useradd --create-home --shell '/bin/bash' --comment 'NetSSH' 'net_ssh_2' \
data/Dockerfile.zlib_ng CHANGED
@@ -11,7 +11,7 @@ ENV INSTALL_PATH="/netssh"
11
11
  # Ruby/OpenSSL initialises — setting it in the Rakefile is too late.
12
12
  ENV OPENSSL_CONF="/netssh/test/openssl3.conf"
13
13
 
14
- RUN dnf install -y openssl openssl-devel ruby ruby-devel git make gcc zlib-ng crypto-policies-scripts && \
14
+ RUN dnf install -y openssl openssl-devel ruby ruby-devel git make gcc redhat-rpm-config zlib-ng crypto-policies-scripts && \
15
15
  update-crypto-policies --set LEGACY
16
16
 
17
17
  WORKDIR $INSTALL_PATH
data/Rakefile CHANGED
@@ -9,7 +9,11 @@ require "rake"
9
9
  require "rake/clean"
10
10
  require "bundler/gem_tasks"
11
11
 
12
- require "rdoc/task"
12
+ begin
13
+ require "rdoc/task"
14
+ rescue LoadError
15
+ # rdoc is no longer a default gem as of Ruby 4.0; the rest of the Rakefile works without it.
16
+ end
13
17
 
14
18
  desc "When releasing make sure NET_SSH_BUILDGEM_SIGNED is set"
15
19
  task :check_NET_SSH_BUILDGEM_SIGNED do
@@ -27,17 +31,19 @@ require_relative "lib/net/ssh/version"
27
31
  version = Net::SSH::Version::CURRENT
28
32
 
29
33
  extra_files = %w[LICENSE.txt THANKS.txt CHANGES.txt]
30
- RDoc::Task.new do |rdoc|
31
- rdoc.rdoc_dir = "rdoc"
32
- rdoc.title = "#{name} #{version}"
33
- rdoc.generator = 'hanna' # gem install hanna-nouveau
34
- rdoc.main = 'README.md'
35
- rdoc.rdoc_files.include("README*")
36
- rdoc.rdoc_files.include("bin/*.rb")
37
- rdoc.rdoc_files.include("lib/**/*.rb")
38
- extra_files.each { |file|
39
- rdoc.rdoc_files.include(file) if File.exist?(file)
40
- }
34
+ if defined?(RDoc::Task)
35
+ RDoc::Task.new do |rdoc|
36
+ rdoc.rdoc_dir = "rdoc"
37
+ rdoc.title = "#{name} #{version}"
38
+ rdoc.generator = 'hanna' # gem install hanna-nouveau
39
+ rdoc.main = 'README.md'
40
+ rdoc.rdoc_files.include("README*")
41
+ rdoc.rdoc_files.include("bin/*.rb")
42
+ rdoc.rdoc_files.include("lib/**/*.rb")
43
+ extra_files.each { |file|
44
+ rdoc.rdoc_files.include(file) if File.exist?(file)
45
+ }
46
+ end
41
47
  end
42
48
 
43
49
  namespace :cert do
@@ -59,6 +59,9 @@ module Net
59
59
  MEND = "-----END OPENSSH PRIVATE KEY-----"
60
60
  MAGIC = "openssh-key-v1"
61
61
 
62
+ # Same limit as OpenSSH; bcrypt_pbkdf with 2**32 rounds would run for months (GHSA-rpq3-v334-f5pm).
63
+ MAX_KDF_ROUNDS = 1 << 20
64
+
62
65
  class DecryptError < ArgumentError
63
66
  def initialize(message, encrypted_key: false)
64
67
  super(message)
@@ -109,6 +112,7 @@ module Net
109
112
  if kdfname == 'bcrypt'
110
113
  salt = kdfopts.read_string
111
114
  rounds = kdfopts.read_long
115
+ raise ArgumentError.new("Private key bcrypt rounds #{rounds} exceeds #{MAX_KDF_ROUNDS}") if rounds > MAX_KDF_ROUNDS
112
116
 
113
117
  key = bcrypt_pbkdf_key(password, salt, keylen + ivlen, rounds)
114
118
  else
@@ -145,6 +149,7 @@ module Net
145
149
  rescue LoadError
146
150
  raise DecryptError.new("bcrypt_pbkdf is required to decrypt bcrypt-encrypted OpenSSH private keys")
147
151
  end
152
+ raise DecryptError.new("Passphrase required for encrypted private key", encrypted_key: true) if password.nil? || password.empty?
148
153
 
149
154
  key = BCryptPbkdf::key(password, salt, length, rounds)
150
155
  raise DecryptError.new("BCryptPbkdf failed", encrypted_key: true) unless key
@@ -127,9 +127,10 @@ module Net
127
127
  if agent
128
128
  agent.identities.each do |key|
129
129
  corresponding_user_identity = user_identities.detect { |identity|
130
- identity[:public_key] && identity[:public_key].to_pem == key.to_pem
130
+ identity[:public_key] && identity[:public_key].to_blob == key.to_blob
131
131
  }
132
- user_identities.delete(corresponding_user_identity) if corresponding_user_identity
132
+ # By identity: EC::Point#== raises TypeError when compared with other key types.
133
+ user_identities.reject! { |identity| identity.equal?(corresponding_user_identity) }
133
134
 
134
135
  if !options[:keys_only] || corresponding_user_identity
135
136
  known_identities[key] = { from: :agent, identity: key }
@@ -55,7 +55,7 @@ module Net
55
55
  prompter = nil
56
56
  result =
57
57
  begin
58
- key_type.read(data, passphrase || 'invalid')
58
+ key_type.read(data, passphrase)
59
59
  rescue *key_type.error_classes => e
60
60
  encrypted_key = !!key_type.encrypted_key?(data, e) if encrypted_key.nil?
61
61
  if encrypted_key && ask_passphrase
@@ -138,8 +138,9 @@ module Net
138
138
  raise Exception, "TODO: subclasses should implement"
139
139
  end
140
140
 
141
+ # A nil passphrase would make OpenSSL prompt on the terminal itself.
141
142
  def self.read(key_data, passphrase)
142
- open_ssl_class.new(key_data, passphrase)
143
+ open_ssl_class.new(key_data, passphrase || 'invalid')
143
144
  end
144
145
 
145
146
  def self.encrypted_key?(key_data, error)
@@ -149,7 +150,7 @@ module Net
149
150
 
150
151
  class OpenSSLPKeyType < OpenSSLKeyTypeBase
151
152
  def self.read(key_data, passphrase)
152
- open_ssl_class.read(key_data, passphrase)
153
+ open_ssl_class.read(key_data, passphrase || 'invalid')
153
154
  end
154
155
 
155
156
  def self.open_ssl_class
@@ -41,9 +41,9 @@ module Net
41
41
  def ask(prompt, echo = true)
42
42
  $stdout.print(prompt)
43
43
  $stdout.flush
44
- ret = $stdin.noecho(&:gets).chomp
45
- $stdout.print("\n")
46
- ret
44
+ ret = echo ? $stdin.gets : $stdin.noecho(&:gets)
45
+ $stdout.print("\n") unless echo
46
+ ret.chomp
47
47
  end
48
48
 
49
49
  # success method will be called when the password was accepted
@@ -16,6 +16,11 @@ module Net
16
16
  # ...
17
17
  # end
18
18
  class Command
19
+ # The command line is run by the shell, so like OpenSSH 9.6 (CVE-2023-51385) we refuse
20
+ # host and user names containing shell metacharacters rather than substituting them.
21
+ INVALID_HOST_NAME = /\A-|[[:space:][:cntrl:]'`"$\\;&<>|(){},]/.freeze
22
+ INVALID_USER_NAME = /\A-|[[:cntrl:]'`";&<>|(){}]|[[:space:]]-|\\\z/.freeze
23
+
19
24
  # The command line template
20
25
  attr_reader :command_line_template
21
26
 
@@ -41,13 +46,13 @@ module Net
41
46
  command_line = @command_line_template.gsub(/%(.)/) {
42
47
  case $1
43
48
  when 'h'
44
- host
49
+ validate_host_name!(host)
45
50
  when 'p'
46
- port.to_s
51
+ validate_port!(port)
47
52
  when 'r'
48
53
  remote_user = connection_options && connection_options[:remote_user]
49
54
  if remote_user
50
- remote_user
55
+ validate_user_name!(remote_user)
51
56
  else
52
57
  raise ArgumentError, "remote user name not available"
53
58
  end
@@ -117,6 +122,26 @@ module Net
117
122
  Process.kill('TERM', io.pid)
118
123
  Thread.new { io.close }
119
124
  end
125
+
126
+ private
127
+
128
+ def validate_host_name!(host)
129
+ raise ArgumentError, "host name contains invalid characters: #{host.inspect}" if host.to_s.match?(INVALID_HOST_NAME)
130
+
131
+ host
132
+ end
133
+
134
+ def validate_user_name!(user)
135
+ raise ArgumentError, "user name contains invalid characters: #{user.inspect}" if user.to_s.match?(INVALID_USER_NAME)
136
+
137
+ user
138
+ end
139
+
140
+ def validate_port!(port)
141
+ raise ArgumentError, "port is not a number: #{port.inspect}" unless port.to_s.match?(/\A\d+\z/)
142
+
143
+ port.to_s
144
+ end
120
145
  end
121
146
  end
122
147
  end
@@ -1,3 +1,4 @@
1
+ require 'shellwords'
1
2
  require 'uri'
2
3
  require 'net/ssh/proxy/command'
3
4
 
@@ -34,20 +35,29 @@ module Net
34
35
  # We cannot build the ProxyCommand template until we know if the :config
35
36
  # option was specified during `Net::SSH.start`.
36
37
  def build_proxy_command_equivalent(connection_options = nil)
37
- first_jump, extra_jumps = jump_proxies.split(",", 2)
38
+ extra_jumps = jump_proxies.split(",", 2)[1]
38
39
  config = connection_options && connection_options[:config]
39
- uri = URI.parse("ssh://#{first_jump}")
40
+ uri, = jump_proxies.split(",").map { |jump| validate_jump!(jump) }
40
41
 
41
42
  template = "ssh".dup
42
43
  template << " -l #{uri.user}" if uri.user
43
44
  template << " -p #{uri.port}" if uri.port
44
45
  template << " -J #{extra_jumps}" if extra_jumps
45
- template << " -F #{config}" if config != true && config
46
+ template << " -F #{Shellwords.escape(config)}" if config != true && config
46
47
  template << " -W %h:%p "
47
48
  template << uri.host
48
49
 
49
50
  @command_line_template = template
50
51
  end
52
+
53
+ private
54
+
55
+ def validate_jump!(jump)
56
+ uri = URI.parse("ssh://#{jump}")
57
+ validate_user_name!(uri.user) if uri.user
58
+ validate_host_name!(uri.host)
59
+ uri
60
+ end
51
61
  end
52
62
  end
53
63
  end
@@ -388,13 +388,13 @@ module Net
388
388
  originator_address = packet.read_string
389
389
  originator_port = packet.read_long
390
390
 
391
- puts "REMOTE 0: #{connected_port} #{connected_address} #{originator_address} #{originator_port}"
391
+ debug { "REMOTE 0: #{connected_port} #{connected_address} #{originator_address} #{originator_port}" }
392
392
  remote = @remote_forwarded_ports[[connected_port, connected_address]]
393
393
  if remote.nil?
394
394
  raise Net::SSH::ChannelOpenFailed.new(1, "unknown request from remote forwarded connection on #{connected_address}:#{connected_port}")
395
395
  end
396
396
 
397
- puts "REMOTE: #{remote.host} #{remote.port}"
397
+ debug { "REMOTE: #{remote.host} #{remote.port}" }
398
398
  client = TCPSocket.new(remote.host, remote.port)
399
399
  info { "connected #{connected_address}:#{connected_port} originator #{originator_address}:#{originator_port}" }
400
400
 
@@ -405,11 +405,17 @@ module Net
405
405
 
406
406
  # The callback used when an auth-agent channel is requested by the server.
407
407
  def auth_agent_channel(session, channel, packet)
408
+ # Like OpenSSH, only relay the local agent when forwarding was requested (GHSA-qpg5-fx6p-8cq3).
409
+ unless session.options[:forward_agent] || @agent_forwarded
410
+ error { "server tried agent forwarding, which was not requested; refusing" }
411
+ raise Net::SSH::ChannelOpenFailed.new(1, "agent forwarding was not requested")
412
+ end
413
+
408
414
  info { "opening auth-agent channel" }
409
415
  channel[:invisible] = true
410
416
 
411
417
  begin
412
- agent = Authentication::Agent.connect(logger, session.options[:agent_socket_factory])
418
+ agent = Authentication::Agent.connect(logger, session.options[:agent_socket_factory], session.options[:identity_agent])
413
419
  if (agent.socket.is_a? ::IO)
414
420
  prepare_client(agent.socket, channel, :agent)
415
421
  else
@@ -347,16 +347,16 @@ module Net
347
347
 
348
348
  packet.read(16) # skip the cookie value
349
349
 
350
- data[:kex] = packet.read_string.split(/,/)
351
- data[:host_key] = packet.read_string.split(/,/)
352
- data[:encryption_client] = packet.read_string.split(/,/)
353
- data[:encryption_server] = packet.read_string.split(/,/)
354
- data[:hmac_client] = packet.read_string.split(/,/)
355
- data[:hmac_server] = packet.read_string.split(/,/)
356
- data[:compression_client] = packet.read_string.split(/,/)
357
- data[:compression_server] = packet.read_string.split(/,/)
358
- data[:language_client] = packet.read_string.split(/,/)
359
- data[:language_server] = packet.read_string.split(/,/)
350
+ data[:kex] = read_name_list(packet)
351
+ data[:host_key] = read_name_list(packet)
352
+ data[:encryption_client] = read_name_list(packet)
353
+ data[:encryption_server] = read_name_list(packet)
354
+ data[:hmac_client] = read_name_list(packet)
355
+ data[:hmac_server] = read_name_list(packet)
356
+ data[:compression_client] = read_name_list(packet)
357
+ data[:compression_server] = read_name_list(packet)
358
+ data[:language_client] = read_name_list(packet)
359
+ data[:language_server] = read_name_list(packet)
360
360
 
361
361
  # TODO: if first_kex_packet_follows, we need to try to skip the
362
362
  # actual kexinit stuff and try to guess what the server is doing...
@@ -366,6 +366,13 @@ module Net
366
366
  return data
367
367
  end
368
368
 
369
+ # Reads one comma-separated algorithm name-list from a KEXINIT packet.
370
+ def read_name_list(packet)
371
+ list = packet.read_string or raise Net::SSH::Exception, "malformed KEXINIT packet from server"
372
+
373
+ list.split(/,/)
374
+ end
375
+
369
376
  # Given the #algorithms map of preferred algorithm types, this constructs
370
377
  # a KEXINIT packet to send to the server. It does not actually send it,
371
378
  # it simply builds the packet and returns it.
@@ -12,6 +12,7 @@ module Net
12
12
  DEBUG = 4
13
13
  SERVICE_REQUEST = 5
14
14
  SERVICE_ACCEPT = 6
15
+ EXT_INFO = 7
15
16
 
16
17
  #--
17
18
  # Algorithm negotiation messages
@@ -15,6 +15,9 @@ module Net
15
15
  module PacketStream # rubocop:disable Metrics/ModuleLength
16
16
  PROXY_COMMAND_HOST_IP = '<no hostip for proxy command>'.freeze
17
17
 
18
+ # Same limit as OpenSSH; a larger length would make us buffer whatever the server streams (GHSA-pfqh-p9vh-wx4p).
19
+ PACKET_MAX_SIZE = 256 * 1024
20
+
18
21
  include BufferedIo
19
22
 
20
23
  def self.extended(object)
@@ -240,6 +243,7 @@ module Net
240
243
  @packet = Net::SSH::Buffer.new(server.update_cipher(data))
241
244
  @packet_length = @packet.read_long
242
245
  end
246
+ raise Net::SSH::Exception, "bad packet length #{@packet_length}" if @packet_length < 5 || @packet_length > PACKET_MAX_SIZE
243
247
  end
244
248
 
245
249
  need = @packet_length + 4 - aad_length - server.block_size
@@ -18,6 +18,10 @@ module Net
18
18
  # The SSH version string as reported by Net::SSH
19
19
  PROTO_VERSION = "SSH-2.0-Ruby/Net::SSH_#{Net::SSH::Version::CURRENT} #{RUBY_PLATFORM}"
20
20
 
21
+ # Same limits as OpenSSH, so a server can't make us buffer its banner without bound (GHSA-r75w-7fhp-84w8).
22
+ MAX_LINE_LENGTH = 8192
23
+ MAX_HEADER_LINES = 1024
24
+
21
25
  # Any header text sent by the server prior to sending the version.
22
26
  attr_reader :header
23
27
 
@@ -45,24 +49,15 @@ module Net
45
49
  socket.write "#{PROTO_VERSION}\r\n"
46
50
  socket.flush
47
51
 
48
- raise Net::SSH::ConnectionTimeout, "timeout during server version negotiating" if timeout && !IO.select([socket], nil, nil, timeout)
52
+ deadline = timeout && (Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout)
49
53
 
50
- loop do
51
- @version = String.new
52
- loop do
53
- begin
54
- b = socket.readpartial(1)
55
- raise Net::SSH::Disconnect, "connection closed by remote host" if b.nil?
56
- rescue EOFError
57
- raise Net::SSH::Disconnect, "connection closed by remote host"
58
- end
59
- @version << b
60
- break if b == "\n"
61
- end
54
+ MAX_HEADER_LINES.times do
55
+ @version = read_line(socket, deadline)
62
56
  break if @version.match(/^SSH-/)
63
57
 
64
58
  @header << @version
65
59
  end
60
+ raise Net::SSH::Exception, "no SSH version received in the first #{MAX_HEADER_LINES} lines" unless @version.match(/^SSH-/)
66
61
 
67
62
  @version.chomp!
68
63
  debug { "remote is `#{@version}'" }
@@ -71,6 +66,31 @@ module Net
71
66
 
72
67
  raise Net::SSH::ConnectionTimeout, "timeout during client version negotiating" if timeout && !IO.select(nil, [socket], nil, timeout)
73
68
  end
69
+
70
+ def read_line(socket, deadline)
71
+ line = String.new
72
+ loop do
73
+ wait_readable(socket, deadline)
74
+ begin
75
+ b = socket.readpartial(1)
76
+ raise Net::SSH::Disconnect, "connection closed by remote host" if b.nil?
77
+ rescue EOFError
78
+ raise Net::SSH::Disconnect, "connection closed by remote host"
79
+ end
80
+ line << b
81
+ return line if b == "\n"
82
+ raise Net::SSH::Exception, "server version line exceeds #{MAX_LINE_LENGTH} bytes" if line.bytesize > MAX_LINE_LENGTH
83
+ end
84
+ end
85
+
86
+ def wait_readable(socket, deadline)
87
+ return unless deadline
88
+
89
+ remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
90
+ return if remaining > 0 && IO.select([socket], nil, nil, remaining)
91
+
92
+ raise Net::SSH::ConnectionTimeout, "timeout during server version negotiating"
93
+ end
74
94
  end
75
95
  end
76
96
  end
@@ -27,6 +27,10 @@ module Net
27
27
  # The standard port for the SSH protocol.
28
28
  DEFAULT_PORT = 22
29
29
 
30
+ # Packets held back during a re-key are bounded so a server can't exhaust memory (GHSA-ggxh-cfwq-5xqc).
31
+ MAX_QUEUED_PACKETS = 16_384
32
+ MAX_QUEUED_BYTES = 32 * 1024 * 1024
33
+
30
34
  # The host to connect to, as given to the constructor.
31
35
  attr_reader :host
32
36
 
@@ -62,6 +66,7 @@ module Net
62
66
  @port = options[:port] || DEFAULT_PORT
63
67
  @bind_address = options[:bind_address] || nil
64
68
  @options = options
69
+ @host_key_verifier = select_host_key_verifier(options[:verify_host_key])
65
70
 
66
71
  @socket =
67
72
  if (factory = options[:proxy])
@@ -75,12 +80,12 @@ module Net
75
80
 
76
81
  @socket.extend(PacketStream)
77
82
  @socket.logger = @logger
83
+ set_no_delay unless options[:no_delay] == false
78
84
 
79
85
  debug { "connection established" }
80
86
 
81
87
  @queue = []
82
-
83
- @host_key_verifier = select_host_key_verifier(options[:verify_host_key])
88
+ @queued_bytes = 0
84
89
 
85
90
  @server_version = ServerVersion.new(socket, logger, options[:timeout])
86
91
 
@@ -88,7 +93,11 @@ module Net
88
93
  @algorithms.start
89
94
  wait { algorithms.initialized? }
90
95
  rescue Errno::ETIMEDOUT
96
+ @socket&.close
91
97
  raise Net::SSH::ConnectionTimeout
98
+ rescue StandardError
99
+ @socket&.close
100
+ raise
92
101
  end
93
102
 
94
103
  def host_keys
@@ -188,7 +197,7 @@ module Net
188
197
  # read from the queue before the socket is queried.
189
198
  def poll_message(mode = :nonblock, consume_queue = true)
190
199
  loop do
191
- return @queue.shift if consume_queue && @queue.any? && algorithms.allow?(@queue.first)
200
+ return shift_queue if consume_queue && @queue.any? && algorithms.allow?(@queue.first)
192
201
 
193
202
  packet = socket.next_packet(mode, options[:timeout])
194
203
  return nil if packet.nil?
@@ -206,11 +215,16 @@ module Net
206
215
  when DEBUG
207
216
  send(packet[:always_display] ? :fatal : :debug) { packet[:message] }
208
217
 
218
+ # We don't advertise ext-info-c, but some servers send EXT_INFO anyway (#955).
219
+ when EXT_INFO
220
+ debug { "ignoring EXT_INFO packet" }
221
+
209
222
  when KEXINIT
210
223
  algorithms.accept_kexinit(packet)
211
224
 
212
225
  else
213
226
  return packet if algorithms.allow?(packet)
227
+ raise Net::SSH::Exception, "unexpected packet type #{packet.type} during initial key exchange" unless algorithms.initialized?
214
228
 
215
229
  push(packet)
216
230
  end
@@ -234,6 +248,11 @@ module Net
234
248
  # #poll_message will return packets from the queue in the order they
235
249
  # were received.
236
250
  def push(packet)
251
+ @queued_bytes += packet.length
252
+ if @queue.size >= MAX_QUEUED_PACKETS || @queued_bytes > MAX_QUEUED_BYTES
253
+ raise Net::SSH::Exception, "too many packets queued during key exchange"
254
+ end
255
+
237
256
  @queue.push(packet)
238
257
  end
239
258
 
@@ -276,6 +295,24 @@ module Net
276
295
 
277
296
  private
278
297
 
298
+ def shift_queue
299
+ packet = @queue.shift
300
+ @queued_bytes -= packet.length
301
+ packet
302
+ end
303
+
304
+ # Turns Nagle's algorithm off on the socket, so that a packet written
305
+ # while the previous one is still unacknowledged goes out right away
306
+ # instead of waiting for the server's ACK. A ProxyCommand hands the
307
+ # transport a pipe rather than a socket, and the command on its other
308
+ # end owns the TCP connection, so there is nothing to set in that case.
309
+ def set_no_delay
310
+ return unless socket.respond_to?(:setsockopt)
311
+
312
+ debug { "setting TCP_NODELAY" }
313
+ socket.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
314
+ end
315
+
279
316
  # Compatibility verifier which allows users to keep using
280
317
  # custom verifier code without adding new :verify_signature
281
318
  # method.
@@ -56,7 +56,7 @@ module Net
56
56
 
57
57
  # The prerelease component of this version of the Net::SSH library
58
58
  # nil allowed
59
- PRE = "beta3"
59
+ PRE = "rc2"
60
60
 
61
61
  # The current version of the Net::SSH library as a Version instance
62
62
  CURRENT = new(*[MAJOR, MINOR, TINY, PRE].compact)
data/lib/net/ssh.rb CHANGED
@@ -69,7 +69,7 @@ module Net
69
69
  max_win_size send_env set_env use_agent number_of_password_prompts
70
70
  append_all_supported_algorithms non_interactive password_prompt
71
71
  agent_socket_factory minimum_dh_bits verify_host_key
72
- fingerprint_hash check_host_ip pubkey_algorithms
72
+ fingerprint_hash check_host_ip pubkey_algorithms no_delay
73
73
  ]
74
74
 
75
75
  # The standard means of starting a new SSH connection. When used with a
@@ -156,6 +156,12 @@ module Net
156
156
  # for better performance if your SSH server supports it (most do).
157
157
  # * :max_win_size => maximum size we tell the other side that is supported for
158
158
  # the window.
159
+ # * :no_delay => turns Nagle's algorithm off on the connection's socket
160
+ # (TCP_NODELAY), so a packet written while the previous one is still
161
+ # unacknowledged is sent right away instead of waiting for the server's
162
+ # ACK. OpenSSH does the same for every connection. Set to +false+ to keep
163
+ # Nagle's algorithm on. Has no effect when the socket comes from a
164
+ # ProxyCommand, whose command owns the TCP connection. Defaults to +true+.
159
165
  # * :non_interactive => set to true if your app is non interactive and prefers
160
166
  # authentication failure vs password prompt. Non-interactive applications
161
167
  # should set it to true to prefer failing a password/etc auth methods vs.
data/net-ssh.gemspec CHANGED
@@ -30,6 +30,7 @@ Gem::Specification.new do |spec|
30
30
  spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
31
31
  spec.require_paths = ["lib"]
32
32
 
33
+ spec.add_dependency("logger")
33
34
  spec.add_dependency("openssl", ">= 3.2.0")
34
35
 
35
36
  spec.add_development_dependency("bcrypt_pbkdf", "~> 1.0") unless RUBY_PLATFORM == "java"
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: net-ssh
3
3
  version: !ruby/object:Gem::Version
4
- version: 8.0.0.beta3
4
+ version: 8.0.0.rc2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jamis Buck
@@ -32,8 +32,22 @@ cert_chain:
32
32
  ufHf4AX2UIkJbh7zCPkiNCqIr7MSWLNFG/9lOlHYsEJM8XujT1ofPobYx6YSFx/C
33
33
  7HBrI7UX7awt6UvBZebhcHzyMHxg/B5PVQllPA==
34
34
  -----END CERTIFICATE-----
35
- date: 2026-08-09 00:00:00.000000000 Z
35
+ date: 2026-10-05 00:00:00.000000000 Z
36
36
  dependencies:
37
+ - !ruby/object:Gem::Dependency
38
+ name: logger
39
+ requirement: !ruby/object:Gem::Requirement
40
+ requirements:
41
+ - - ">="
42
+ - !ruby/object:Gem::Version
43
+ version: '0'
44
+ type: :runtime
45
+ prerelease: false
46
+ version_requirements: !ruby/object:Gem::Requirement
47
+ requirements:
48
+ - - ">="
49
+ - !ruby/object:Gem::Version
50
+ version: '0'
37
51
  - !ruby/object:Gem::Dependency
38
52
  name: openssl
39
53
  requirement: !ruby/object:Gem::Requirement
metadata.gz.sig CHANGED
Binary file