ruby-changes:44761
From: shugo <ko1@a...>
Date: Sat, 19 Nov 2016 11:29:27 +0900 (JST)
Subject: [ruby-changes:44761] shugo:r56834 (trunk): Support TLS and hash styles options for Net::FTP.new.
shugo 2016-11-19 11:29:23 +0900 (Sat, 19 Nov 2016) New Revision: 56834 https://svn.ruby-lang.org/cgi-bin/viewvc.cgi?view=revision&revision=56834 Log: Support TLS and hash styles options for Net::FTP.new. If the :ssl options is specified, the control connection is protected with TLS in the manner described in RFC 4217. Data connections are also protected with TLS unless the :private_data_connection is set to false. Modified files: trunk/NEWS trunk/lib/net/ftp.rb trunk/test/net/ftp/test_ftp.rb Index: test/net/ftp/test_ftp.rb =================================================================== --- test/net/ftp/test_ftp.rb (revision 56833) +++ test/net/ftp/test_ftp.rb (revision 56834) @@ -8,6 +8,9 @@ require "tempfile" https://github.com/ruby/ruby/blob/trunk/test/net/ftp/test_ftp.rb#L8 class FTPTest < Test::Unit::TestCase SERVER_ADDR = "127.0.0.1" + CA_FILE = File.expand_path("../imap/cacert.pem", __dir__) + SERVER_KEY = File.expand_path("../imap/server.key", __dir__) + SERVER_CERT = File.expand_path("../imap/server.crt", __dir__) def setup @thread = nil @@ -219,6 +222,62 @@ class FTPTest < Test::Unit::TestCase https://github.com/ruby/ruby/blob/trunk/test/net/ftp/test_ftp.rb#L222 end end + def test_implicit_login + commands = [] + server = create_ftp_server { |sock| + sock.print("220 (test_ftp).\r\n") + commands.push(sock.gets) + sock.print("331 Please specify the password.\r\n") + commands.push(sock.gets) + sock.print("332 Need account for login.\r\n") + commands.push(sock.gets) + sock.print("230 Login successful.\r\n") + commands.push(sock.gets) + sock.print("200 Switching to Binary mode.\r\n") + } + begin + begin + ftp = Net::FTP.new(SERVER_ADDR, + port: server.port, + user: "foo", + passwd: "bar", + acct: "baz") + assert_equal("USER foo\r\n", commands.shift) + assert_equal("PASS bar\r\n", commands.shift) + assert_equal("ACCT baz\r\n", commands.shift) + assert_equal("TYPE I\r\n", commands.shift) + assert_equal(nil, commands.shift) + ensure + ftp.close if ftp + end + ensure + server.close + end + end + + def test_s_open + commands = [] + server = create_ftp_server { |sock| + sock.print("220 (test_ftp).\r\n") + commands.push(sock.gets) + sock.print("331 Please specify the password.\r\n") + commands.push(sock.gets) + sock.print("230 Login successful.\r\n") + commands.push(sock.gets) + sock.print("200 Switching to Binary mode.\r\n") + } + begin + Net::FTP.open(SERVER_ADDR, port: server.port, user: "anonymous") do + end + assert_equal("USER anonymous\r\n", commands.shift) + assert_equal("PASS anonymous@\r\n", commands.shift) + assert_equal("TYPE I\r\n", commands.shift) + assert_equal(nil, commands.shift) + ensure + server.close + end + end + # TODO: How can we test open_timeout? sleep before accept cannot delay # connections. def _test_open_timeout_exceeded @@ -1644,6 +1703,362 @@ EOF https://github.com/ruby/ruby/blob/trunk/test/net/ftp/test_ftp.rb#L1703 end end + if defined?(OpenSSL::SSL) + def test_tls_unknown_ca + assert_raise(OpenSSL::SSL::SSLError) do + tls_test do |port| + begin + Net::FTP.new("localhost", + :port => port, + :ssl => true) + rescue SystemCallError + skip $! + end + end + end + end + + def test_tls_with_ca_file + assert_nothing_raised do + tls_test do |port| + begin + Net::FTP.new("localhost", + :port => port, + :ssl => { :ca_file => CA_FILE }) + rescue SystemCallError + skip $! + end + end + end + end + + def test_tls_verify_none + assert_nothing_raised do + tls_test do |port| + Net::FTP.new(SERVER_ADDR, + :port => port, + :ssl => { :verify_mode => OpenSSL::SSL::VERIFY_NONE }) + end + end + end + + def test_tls_post_connection_check + assert_raise(OpenSSL::SSL::SSLError) do + tls_test do |port| + # SERVER_ADDR is different from the hostname in the certificate, + # so the following code should raise a SSLError. + Net::FTP.new(SERVER_ADDR, + :port => port, + :ssl => { :ca_file => CA_FILE }) + end + end + end + + def test_active_private_data_connection + server = TCPServer.new(SERVER_ADDR, 0) + port = server.addr[1] + commands = [] + binary_data = (0..0xff).map {|i| i.chr}.join * 4 * 3 + @thread = Thread.start do + sock = server.accept + begin + sock.print("220 (test_ftp).\r\n") + commands.push(sock.gets) + sock.print("234 AUTH success.\r\n") + ctx = OpenSSL::SSL::SSLContext.new + ctx.ca_file = CA_FILE + ctx.key = File.open(SERVER_KEY) { |f| + OpenSSL::PKey::RSA.new(f) + } + ctx.cert = File.open(SERVER_CERT) { |f| + OpenSSL::X509::Certificate.new(f) + } + sock = OpenSSL::SSL::SSLSocket.new(sock, ctx) + sock.sync_close = true + begin + sock.accept + commands.push(sock.gets) + sock.print("200 PSBZ success.\r\n") + commands.push(sock.gets) + sock.print("200 PROT success.\r\n") + commands.push(sock.gets) + sock.print("331 Please specify the password.\r\n") + commands.push(sock.gets) + sock.print("230 Login successful.\r\n") + commands.push(sock.gets) + sock.print("200 Switching to Binary mode.\r\n") + line = sock.gets + commands.push(line) + port_args = line.slice(/\APORT (.*)/, 1).split(/,/) + host = port_args[0, 4].join(".") + port = port_args[4, 2].map(&:to_i).inject {|x, y| (x << 8) + y} + sock.print("200 PORT command successful.\r\n") + commands.push(sock.gets) + sock.print("150 Opening BINARY mode data connection for foo (#{binary_data.size} bytes)\r\n") + conn = TCPSocket.new(host, port) + conn = OpenSSL::SSL::SSLSocket.new(conn, ctx) + conn.sync_close = true + conn.accept + binary_data.scan(/.{1,1024}/nm) do |s| + conn.print(s) + end + conn.close + sock.print("226 Transfer complete.\r\n") + rescue OpenSSL::SSL::SSLError + end + ensure + sock.close + server.close + end + end + ftp = Net::FTP.new("localhost", + port: port, + ssl: { ca_file: CA_FILE }, + passive: false) + begin + assert_equal("AUTH TLS\r\n", commands.shift) + assert_equal("PBSZ 0\r\n", commands.shift) + assert_equal("PROT P\r\n", commands.shift) + ftp.login + assert_match(/\AUSER /, commands.shift) + assert_match(/\APASS /, commands.shift) + assert_equal("TYPE I\r\n", commands.shift) + buf = ftp.getbinaryfile("foo", nil) + assert_equal(binary_data, buf) + assert_equal(Encoding::ASCII_8BIT, buf.encoding) + assert_match(/\APORT /, commands.shift) + assert_equal("RETR foo\r\n", commands.shift) + assert_equal(nil, commands.shift) + ensure + ftp.close + end + end + + def test_passive_private_data_connection + server = TCPServer.new(SERVER_ADDR, 0) + port = server.addr[1] + commands = [] + binary_data = (0..0xff).map {|i| i.chr}.join * 4 * 3 + @thread = Thread.start do + sock = server.accept + begin + sock.print("220 (test_ftp).\r\n") + commands.push(sock.gets) + sock.print("234 AUTH success.\r\n") + ctx = OpenSSL::SSL::SSLContext.new + ctx.ca_file = CA_FILE + ctx.key = File.open(SERVER_KEY) { |f| + OpenSSL::PKey::RSA.new(f) + } + ctx.cert = File.open(SERVER_CERT) { |f| + OpenSSL::X509::Certificate.new(f) + } + sock = OpenSSL::SSL::SSLSocket.new(sock, ctx) + sock.sync_close = true + begin + sock.accept + commands.push(sock.gets) + sock.print("200 PSBZ success.\r\n") + commands.push(sock.gets) + sock.print("200 PROT success.\r\n") + commands.push(sock.gets) + sock.print("331 Please specify the password.\r\n") + commands.push(sock.gets) + sock.print("230 Login successful.\r\n") + commands.push(sock.gets) + sock.print("200 Switching to Binary mode.\r\n") + commands.push(sock.gets) + data_server = TCPServer.new(SERVER_ADDR, 0) + port = data_server.local_address.ip_port + sock.printf("227 Entering Passive Mode (127,0,0,1,%s).\r\n", + port.divmod(256).join(",")) + commands.push(sock.gets) + sock.print("150 Opening BINARY mode data connection for foo (#{binary_data.size} bytes)\r\n") + conn = data_server.accept + conn = OpenSSL::SSL::SSLSocket.new(conn, ctx) + conn.sync_close = true + conn.accept + binary_data.scan(/.{1,1024}/nm) do |s| + conn.print(s) + end + conn.close + data_server.close + sock.print("226 Transfer complete.\r\n") + rescue OpenSSL::SSL::SSLError + end + ensure + sock.close + server.close + end + end + ftp = Net::FTP.new("localhost", + port: port, + ssl: { ca_file: CA_FILE }, + passive: true) + begin + assert_equal("AUTH TLS\r\n", commands.shift) + assert_equal("PBSZ 0\r\n", commands.shift) + assert_equal("PROT P\r\n", commands.shift) + ftp.login + assert_match(/\AUSER /, commands.shift) + assert_match(/\APASS /, commands.shift) + assert_equal("TYPE I\r\n", commands.shift) + buf = ftp.getbinaryfile("foo", nil) + assert_equal(binary_data, buf) + assert_equal(Encoding::ASCII_8BIT, buf.encoding) + assert_equal("PASV\r\n", commands.shift) + assert_equal("RETR foo\r\n", commands.shift) + assert_equal(nil, commands.shift) + ensure + ftp.close + end + end + + def test_active_clear_data_connection + server = TCPServer.new(SERVER_ADDR, 0) + port = server.addr[1] + commands = [] + binary_data = (0..0xff).map {|i| i.chr}.join * 4 * 3 + @thread = Thread.start do + sock = server.accept + begin + sock.print("220 (test_ftp).\r\n") + commands.push(sock.gets) + sock.print("234 AUTH success.\r\n") + ctx = OpenSSL::SSL::SSLContext.new + ctx.ca_file = CA_FILE + ctx.key = File.open(SERVER_KEY) { |f| + OpenSSL::PKey::RSA.new(f) + } + ctx.cert = File.open(SERVER_CERT) { |f| + OpenSSL::X509::Certificate.new(f) + } + sock = OpenSSL::SSL::SSLSocket.new(sock, ctx) + sock.sync_close = true + begin + sock.accept + commands.push(sock.gets) + sock.print("331 Please specify the password.\r\n") + commands.push(sock.gets) + sock.print("230 Login successful.\r\n") + commands.push(sock.gets) + sock.print("200 Switching to Binary mode.\r\n") + line = sock.gets + commands.push(line) + port_args = line.slice(/\APORT (.*)/, 1).split(/,/) + host = port_args[0, 4].join(".") + port = port_args[4, 2].map(&:to_i).inject {|x, y| (x << 8) + y} + sock.print("200 PORT command successful.\r\n") + commands.push(sock.gets) + sock.print("150 Opening BINARY mode data connection for foo (#{binary_data.size} bytes)\r\n") + conn = TCPSocket.new(host, port) + binary_data.scan(/.{1,1024}/nm) do |s| + conn.print(s) + end + conn.close + sock.print("226 Transfer complete.\r\n") + rescue OpenSSL::SSL::SSLError + end + ensure + sock.close + server.close + end + end + ftp = Net::FTP.new("localhost", + port: port, + ssl: { ca_file: CA_FILE }, + private_data_connection: false, + passive: false) + begin + assert_equal("AUTH TLS\r\n", commands.shift) + ftp.login + assert_match(/\AUSER /, commands.shift) + assert_match(/\APASS /, commands.shift) + assert_equal("TYPE I\r\n", commands.shift) + buf = ftp.getbinaryfile("foo", nil) + assert_equal(binary_data, buf) + assert_equal(Encoding::ASCII_8BIT, buf.encoding) + assert_match(/\APORT /, commands.shift) + assert_equal("RETR foo\r\n", commands.shift) + assert_equal(nil, commands.shift) + ensure + ftp.close + end + end + + def test_passive_clear_data_connection + server = TCPServer.new(SERVER_ADDR, 0) + port = server.addr[1] + commands = [] + binary_data = (0..0xff).map {|i| i.chr}.join * 4 * 3 + @thread = Thread.start do + sock = server.accept + begin + sock.print("220 (test_ftp).\r\n") + commands.push(sock.gets) + sock.print("234 AUTH success.\r\n") + ctx = OpenSSL::SSL::SSLContext.new + ctx.ca_file = CA_FILE + ctx.key = File.open(SERVER_KEY) { |f| + OpenSSL::PKey::RSA.new(f) + } + ctx.cert = File.open(SERVER_CERT) { |f| + OpenSSL::X509::Certificate.new(f) + } + sock = OpenSSL::SSL::SSLSocket.new(sock, ctx) + sock.sync_close = true + begin + sock.accept + commands.push(sock.gets) + sock.print("331 Please specify the password.\r\n") + commands.push(sock.gets) + sock.print("230 Login successful.\r\n") + commands.push(sock.gets) + sock.print("200 Switching to Binary mode.\r\n") + commands.push(sock.gets) + data_server = TCPServer.new(SERVER_ADDR, 0) + port = data_server.local_address.ip_port + sock.printf("227 Entering Passive Mode (127,0,0,1,%s).\r\n", + port.divmod(256).join(",")) + commands.push(sock.gets) + sock.print("150 Opening BINARY mode data connection for foo (#{binary_data.size} bytes)\r\n") + conn = data_server.accept + binary_data.scan(/.{1,1024}/nm) do |s| + conn.print(s) + end + conn.close + data_server.close + sock.print("226 Transfer complete.\r\n") + rescue OpenSSL::SSL::SSLError + end + ensure + sock.close + server.close + end + end + ftp = Net::FTP.new("localhost", + port: port, + ssl: { ca_file: CA_FILE }, + private_data_connection: false, + passive: true) + begin + assert_equal("AUTH TLS\r\n", commands.shift) + ftp.login + assert_match(/\AUSER /, commands.shift) + assert_match(/\APASS /, commands.shift) + assert_equal("TYPE I\r\n", commands.shift) + buf = ftp.getbinaryfile("foo", nil) + assert_equal(binary_data, buf) + assert_equal(Encoding::ASCII_8BIT, buf.encoding) + assert_equal("PASV\r\n", commands.shift) + assert_equal("RETR foo\r\n", commands.shift) + assert_equal(nil, commands.shift) + ensure + ftp.close + end + end + end + private def create_ftp_server(sleep_time = nil) @@ -1667,4 +2082,45 @@ EOF https://github.com/ruby/ruby/blob/trunk/test/net/ftp/test_ftp.rb#L2082 end return server end + + def tls_test + server = TCPServer.new(SERVER_ADDR, 0) + port = server.addr[1] + commands = [] + @thread = Thread.start do + sock = server.accept + begin + sock.print("220 (test_ftp).\r\n") + commands.push(sock.gets) + sock.print("234 AUTH success.\r\n") + ctx = OpenSSL::SSL::SSLContext.new + ctx.ca_file = CA_FILE + ctx.key = File.open(SERVER_KEY) { |f| + OpenSSL::PKey::RSA.new(f) + } + ctx.cert = File.open(SERVER_CERT) { |f| + OpenSSL::X509::Certificate.new(f) + } + sock = OpenSSL::SSL::SSLSocket.new(sock, ctx) + sock.sync_close = true + begin + sock.accept + commands.push(sock.gets) + sock.print("200 PSBZ success.\r\n") + commands.push(sock.gets) + sock.print("200 PROT success.\r\n") + rescue OpenSSL::SSL::SSLError + end + ensure + sock.close + server.close + end + end + ftp = yield(port) + ftp.close + + assert_equal("AUTH TLS\r\n", commands.shift) + assert_equal("PBSZ 0\r\n", commands.shift) + assert_equal("PROT P\r\n", commands.shift) + end end Index: lib/net/ftp.rb =================================================================== --- lib/net/ftp.rb (revision 56833) +++ lib/net/ftp.rb (revision 56834) @@ -19,6 +19,10 @@ require "socket" https://github.com/ruby/ruby/blob/trunk/lib/net/ftp.rb#L19 require "monitor" require "net/protocol" require "time" +begin + require "openssl" +rescue LoadError +end module Net @@ -75,6 +79,10 @@ module Net https://github.com/ruby/ruby/blob/trunk/lib/net/ftp.rb#L79 # class FTP include MonitorMixin + if defined?(OpenSSL::SSL) + include OpenSSL + include SSL + end # :stopdoc: FTP_PORT = 21 @@ -143,38 +151,108 @@ module Net https://github.com/ruby/ruby/blob/trunk/lib/net/ftp.rb#L151 # If a block is given, it is passed the +FTP+ object, which will be closed # when the block finishes, or when an exception is raised. # - def FTP.open(host, user = nil, passwd = nil, acct = nil) + def FTP.open(host, *args) if block_given? - ftp = new(host, user, passwd, acct) + ftp = new(host, *args) begin yield ftp ensure ftp.close end else - new(host, user, passwd, acct) + new(host, *args) end end + # :call-seq: + # Net::FTP.new(host = nil, options = {}) # # Creates and returns a new +FTP+ object. If a +host+ is given, a connection - # is made. Additionally, if the +user+ is given, the given user name, - # password, and (optionally) account are used to log in. See #login. + # is made. + # + # +options+ is an option hash, each key of which is a symbol. + # + # The available options are: + # + # port:: Port number (default value is 21) + # ssl:: If options[:ssl] is true, then an attempt will be made + # to use SSL (now TLS) to connect to the server. For this to + # work OpenSSL [OSSL] and the Ruby OpenSSL [RSSL] extensions + # need to be installed. If options[:ssl] is a hash, it's + # passed to OpenSSL::SSL::SSLContext#set_params as parameters. + # private_data_connection:: If true, TLS is used for data connections. + # Default: +true+ when options[:ssl] is true. + # user:: Username for login. If options[:user] is the string + # "anonymous" and the options[:password] is +nil+, + # "anonymous@" is used as a password. If options[:user] is + # +nil+, + # passwd:: Password for login. + # acct:: Account information for ACCT. + # passive:: When +true+, the connection is in passive mode. Default: +true+. + # debug_mode:: When +true+, all tra (... truncated) -- ML: ruby-changes@q... Info: http://www.atdot.net/~ko1/quickml/