diff --git a/lib/httparty.rb b/lib/httparty.rb index 70561c5..e906e99 100644 --- a/lib/httparty.rb +++ b/lib/httparty.rb @@ -201,6 +201,26 @@ module HTTParty default_options[:pem] = pem_contents end + # Allows setting an OpenSSL certificate authority file + # + # class Foo + # include HTTParty + # ssl_ca_file '/etc/ssl/certs/ca-certificates.crt' + # end + def ssl_ca_file(path) + default_options[:ssl_ca_file] = path + end + + # Allows setting an OpenSSL certificate authority path (directory) + # + # class Foo + # include HTTParty + # ssl_ca_path '/etc/ssl/certs/' + # end + def ssl_ca_path(path) + default_options[:ssl_ca_path] = path + end + # Allows setting a custom parser for the response. # # class Foo diff --git a/lib/httparty/request.rb b/lib/httparty/request.rb index a9fd9b6..01d374c 100644 --- a/lib/httparty/request.rb +++ b/lib/httparty/request.rb @@ -68,12 +68,22 @@ module HTTParty http.read_timeout = options[:timeout] end + # By default, don't do any SSL verification (!), but this can be overridden. + http.verify_mode = OpenSSL::SSL::VERIFY_NONE + # Client certificate authentication if options[:pem] && http.use_ssl? http.cert = OpenSSL::X509::Certificate.new(options[:pem]) http.key = OpenSSL::PKey::RSA.new(options[:pem]) http.verify_mode = OpenSSL::SSL::VERIFY_PEER - else - http.verify_mode = OpenSSL::SSL::VERIFY_NONE + end + # SSL certificate authority file and/or directory + if options[:ssl_ca_file] && http.use_ssl? + http.ca_file = options[:ssl_ca_file] + http.verify_mode = OpenSSL::SSL::VERIFY_PEER + end + if options[:ssl_ca_path] && http.use_ssl? + http.ca_path = options[:ssl_ca_path] + http.verify_mode = OpenSSL::SSL::VERIFY_PEER end if options[:debug_output] diff --git a/spec/fixtures/ssl/generate.sh b/spec/fixtures/ssl/generate.sh new file mode 100755 index 0000000..ac11ed7 --- /dev/null +++ b/spec/fixtures/ssl/generate.sh @@ -0,0 +1,29 @@ +#!/bin/sh +set -e + +if [ -d "generated" ] ; then + echo >&2 "error: 'generated' directory already exists. Delete it first." + exit 1 +fi + +mkdir generated + +# Generate the CA private key and certificate +openssl req -batch -subj '/CN=INSECURE Test Certificate Authority' -newkey rsa:1024 -new -x509 -days 999999 -keyout generated/ca.key -nodes -out generated/ca.crt + +# Create symlinks for ssl_ca_path +c_rehash generated + +# Generate the server private key and self-signed certificate +openssl req -batch -subj '/CN=localhost' -newkey rsa:1024 -new -x509 -days 999999 -keyout generated/server.key -nodes -out generated/selfsigned.crt + +# Generate certificate signing request with bogus hostname +openssl req -batch -subj '/CN=bogo' -new -days 999999 -key generated/server.key -nodes -out generated/bogushost.csr + +# Sign the certificate requests +openssl x509 -CA generated/ca.crt -CAkey generated/ca.key -set_serial 1 -in generated/selfsigned.crt -out generated/server.crt -clrext -extfile openssl-exts.cnf -extensions cert +openssl x509 -req -CA generated/ca.crt -CAkey generated/ca.key -set_serial 1 -in generated/bogushost.csr -out generated/bogushost.crt -clrext -extfile openssl-exts.cnf -extensions cert + +# Remove certificate signing requests +rm -f generated/*.csr + diff --git a/spec/fixtures/ssl/generated/1fe462c2.0 b/spec/fixtures/ssl/generated/1fe462c2.0 new file mode 120000 index 0000000..a74ccf5 --- /dev/null +++ b/spec/fixtures/ssl/generated/1fe462c2.0 @@ -0,0 +1 @@ +ca.crt \ No newline at end of file diff --git a/spec/fixtures/ssl/generated/bogushost.crt b/spec/fixtures/ssl/generated/bogushost.crt new file mode 100644 index 0000000..bdb2932 --- /dev/null +++ b/spec/fixtures/ssl/generated/bogushost.crt @@ -0,0 +1,13 @@ +-----BEGIN CERTIFICATE----- +MIICAzCCAWygAwIBAgIBATANBgkqhkiG9w0BAQUFADAuMSwwKgYDVQQDEyNJTlNF +Q1VSRSBUZXN0IENlcnRpZmljYXRlIEF1dGhvcml0eTAeFw0xMDA3MDkwMTU5MTda +Fw0xMDA4MDgwMTU5MTdaMA8xDTALBgNVBAMTBGJvZ28wgZ8wDQYJKoZIhvcNAQEB +BQADgY0AMIGJAoGBAKMU3pAeBZzKYcYF8eDIPvb9qYF8odH9ZK23IGn1T9D9Oxd0 +2+IltkMJ0sOWpknp+kTzbAP0dammMNExt/YFuFqN4MenTMp87FFQAXSK0WhtjNcb +Fl9gv4iThWedFVSq3qZs8c+ZTCrrC/A/ScGAH7g4C57Degci7SCdf4v97m7nAgMB +AAGjUDBOMAwGA1UdEwEB/wQCMAAwHQYDVR0OBBYEFMXuuqokuPcMMS7OFu0jeoGK +oJsDMB8GA1UdIwQYMBaAFJYXkYdQ2afmGvLRN9YGOqrGK/MLMA0GCSqGSIb3DQEB +BQUAA4GBAIPfOu+RadQpZlSaMg3m7t7wn3yPPp2yXtz6s98JqBvoTtZZ0f9JMG6z +muVss0JmHPTyPDlNk54DaySa0wAUArAqTUvq05U+VoxtN7QEqR9bgdsRSByowVax +/01r5CdrMC/xDs4OWz3sv8Kyw0hmd9nnCvMoMUQgEZsjNcEPmN7K +-----END CERTIFICATE----- diff --git a/spec/fixtures/ssl/generated/ca.crt b/spec/fixtures/ssl/generated/ca.crt new file mode 100644 index 0000000..66694c9 --- /dev/null +++ b/spec/fixtures/ssl/generated/ca.crt @@ -0,0 +1,15 @@ +-----BEGIN CERTIFICATE----- +MIICazCCAdSgAwIBAgIJAMBBDJhEZSUlMA0GCSqGSIb3DQEBBQUAMC4xLDAqBgNV +BAMTI0lOU0VDVVJFIFRlc3QgQ2VydGlmaWNhdGUgQXV0aG9yaXR5MB4XDTEwMDcw +OTAxNTkxN1oXDTI2MDUxOTE2MzM1N1owLjEsMCoGA1UEAxMjSU5TRUNVUkUgVGVz +dCBDZXJ0aWZpY2F0ZSBBdXRob3JpdHkwgZ8wDQYJKoZIhvcNAQEBBQADgY0AMIGJ +AoGBAMAfEr8RkcWZhIwj7zz2F+phGpASWiO6EPUNsltXvksPo06DGJJJqWWJ/zUm +/1m9Wjd0yx1dinT+C1jpViWTvJZ0KaSs29PW1EFuwZ8tOlm4TdWti6mGp9QZ5KVU +24QXAAbrvXrEVBUfCmhd1gxrqvXH9gMbtndXtDnmbba6u2ehAgMBAAGjgZAwgY0w +HQYDVR0OBBYEFJYXkYdQ2afmGvLRN9YGOqrGK/MLMF4GA1UdIwRXMFWAFJYXkYdQ +2afmGvLRN9YGOqrGK/MLoTKkMDAuMSwwKgYDVQQDEyNJTlNFQ1VSRSBUZXN0IENl +cnRpZmljYXRlIEF1dGhvcml0eYIJAMBBDJhEZSUlMAwGA1UdEwQFMAMBAf8wDQYJ +KoZIhvcNAQEFBQADgYEAgN+TxBq4sYMnJZ3WHZ/RLcnpIbZdElUuM3lBbwKOmL5E +KZ7uh5ZzyihNMnuw61MqvSMjwZfipyD0xNhX7e4dF47Q1oOUaMSxTS92PgQ22otY +IMVtP7r8h8op7oKsiaSu4Y984abXirhMdVa1nUvyliBSYs94YIyZtwujhQJKN5Y= +-----END CERTIFICATE----- diff --git a/spec/fixtures/ssl/generated/ca.key b/spec/fixtures/ssl/generated/ca.key new file mode 100644 index 0000000..f1af027 --- /dev/null +++ b/spec/fixtures/ssl/generated/ca.key @@ -0,0 +1,15 @@ +-----BEGIN RSA PRIVATE KEY----- +MIICXgIBAAKBgQDAHxK/EZHFmYSMI+889hfqYRqQElojuhD1DbJbV75LD6NOgxiS +Sallif81Jv9ZvVo3dMsdXYp0/gtY6VYlk7yWdCmkrNvT1tRBbsGfLTpZuE3VrYup +hqfUGeSlVNuEFwAG6716xFQVHwpoXdYMa6r1x/YDG7Z3V7Q55m22urtnoQIDAQAB +AoGAMJMqsDiG/Mj15GDpiiZGobHvf2HEfKf8xZiy8blbmarYhW9L9SC+vbeIWS4E +/fGML91NxZzy9uWMhOxqJZIW6hsPbaZaxWcI/Ar78YcwskKRZC8vZd2bZ+jbwp6Y +rI0/FVla42wcFXr6dbdnLKOeYBurB/F/839jeErjoWJ5F2kCQQDsmOl9A+ni+OYI +We8/Vc/BASVnpKbvYYUi4BlzDjhcj5cn44pIuGRS/VDfmiQTGPf/gMOd6GM2jDUm +kvFUZY5fAkEAz+BwsTZAwF9vPOBu9iuVsCzJ+OhyNtl1PrWDqEdMkFRHN++eXkL0 +U4uNMLma3pCDLJ2bv49mTPzDu2AY03bJ/wJBALMSzW5MvwKGhnz9rOJQDa20M15t +tdfrBLyvxzNZKPmNyMdtJiYCQhS6HDMRVIqL1HCzQdvLnwQTPMtUXooVT5sCQQDF +BpFJJYbRzqJ8LKx/HmhOBuWXyZkXa5y4xwn2YT2sPnUSC0crSIKS/N3hpMmo0YfC +rc+FDMGFjr1lx3tAUoK5AkEAiEbs3Kn1donT7bT5+WeTGSIIBnWN2s93Gq7AXpV3 +K96lVDUnXa4qGwGdhmvlhXpwhYrzMNHkzaU/AcSOi3Cqww== +-----END RSA PRIVATE KEY----- diff --git a/spec/fixtures/ssl/generated/selfsigned.crt b/spec/fixtures/ssl/generated/selfsigned.crt new file mode 100644 index 0000000..948bcf5 --- /dev/null +++ b/spec/fixtures/ssl/generated/selfsigned.crt @@ -0,0 +1,14 @@ +-----BEGIN CERTIFICATE----- +MIICGzCCAYSgAwIBAgIJALEY3/cqVrhXMA0GCSqGSIb3DQEBBQUAMBQxEjAQBgNV +BAMTCWxvY2FsaG9zdDAeFw0xMDA3MDkwMTU5MTdaFw0yNjA1MTkxNjMzNTdaMBQx +EjAQBgNVBAMTCWxvY2FsaG9zdDCBnzANBgkqhkiG9w0BAQEFAAOBjQAwgYkCgYEA +oxTekB4FnMphxgXx4Mg+9v2pgXyh0f1krbcgafVP0P07F3Tb4iW2QwnSw5amSen6 +RPNsA/R1qaYw0TG39gW4Wo3gx6dMynzsUVABdIrRaG2M1xsWX2C/iJOFZ50VVKre +pmzxz5lMKusL8D9JwYAfuDgLnsN6ByLtIJ1/i/3ubucCAwEAAaN1MHMwHQYDVR0O +BBYEFMXuuqokuPcMMS7OFu0jeoGKoJsDMEQGA1UdIwQ9MDuAFMXuuqokuPcMMS7O +Fu0jeoGKoJsDoRikFjAUMRIwEAYDVQQDEwlsb2NhbGhvc3SCCQCxGN/3Kla4VzAM +BgNVHRMEBTADAQH/MA0GCSqGSIb3DQEBBQUAA4GBAKC8iJBjfAe78/QzKmMk6QJN +kdLolcGGdINaCyGJRG67givjgkLj9N0JDJImeXWebyykrb/RKSNh7jAcBah+qnvS +SkuXG5E2qKvG66rN/4sjhP68pvD10psvKY/pYmZTPu1VHLZXWwNHtKy/F/ktj/7P +HyuNWpYma8yzHT8RMizZ +-----END CERTIFICATE----- diff --git a/spec/fixtures/ssl/generated/server.crt b/spec/fixtures/ssl/generated/server.crt new file mode 100644 index 0000000..acd0484 --- /dev/null +++ b/spec/fixtures/ssl/generated/server.crt @@ -0,0 +1,13 @@ +-----BEGIN CERTIFICATE----- +MIICCDCCAXGgAwIBAgIBATANBgkqhkiG9w0BAQUFADAuMSwwKgYDVQQDEyNJTlNF +Q1VSRSBUZXN0IENlcnRpZmljYXRlIEF1dGhvcml0eTAeFw0xMDA3MDkwMTU5MTda +Fw0xMDA4MDgwMTU5MTdaMBQxEjAQBgNVBAMTCWxvY2FsaG9zdDCBnzANBgkqhkiG +9w0BAQEFAAOBjQAwgYkCgYEAoxTekB4FnMphxgXx4Mg+9v2pgXyh0f1krbcgafVP +0P07F3Tb4iW2QwnSw5amSen6RPNsA/R1qaYw0TG39gW4Wo3gx6dMynzsUVABdIrR +aG2M1xsWX2C/iJOFZ50VVKrepmzxz5lMKusL8D9JwYAfuDgLnsN6ByLtIJ1/i/3u +bucCAwEAAaNQME4wDAYDVR0TAQH/BAIwADAdBgNVHQ4EFgQUxe66qiS49wwxLs4W +7SN6gYqgmwMwHwYDVR0jBBgwFoAUlheRh1DZp+Ya8tE31gY6qsYr8wswDQYJKoZI +hvcNAQEFBQADgYEAPRrgDgJG0YSDNUl57cghBqHvlb4QOZDGgW1XLiG2GYLl1o5Q +jbHij7BZBdk4CBwr1vzExL6Ef7ktvhEEvgXEJQeiOU9Y+v/w3EG914dpJp39Epv6 +vBvO9yAyAaozk7JvRGlB8nbb36pYuFnv+KsRNn/KFjauCXm+gknQRSP9vpE= +-----END CERTIFICATE----- diff --git a/spec/fixtures/ssl/generated/server.key b/spec/fixtures/ssl/generated/server.key new file mode 100644 index 0000000..65941b5 --- /dev/null +++ b/spec/fixtures/ssl/generated/server.key @@ -0,0 +1,15 @@ +-----BEGIN RSA PRIVATE KEY----- +MIICXQIBAAKBgQCjFN6QHgWcymHGBfHgyD72/amBfKHR/WSttyBp9U/Q/TsXdNvi +JbZDCdLDlqZJ6fpE82wD9HWppjDRMbf2BbhajeDHp0zKfOxRUAF0itFobYzXGxZf +YL+Ik4VnnRVUqt6mbPHPmUwq6wvwP0nBgB+4OAuew3oHIu0gnX+L/e5u5wIDAQAB +AoGBAJpAsie1De/5CbRJiTjpj40F79/3qAQ83o7lqTYv/7gY3lzYfucQbq5IS2AP +TeiZ9MxlRuUSxHycIo6srWl6jZ0u1M4vfRvmQf2aXZbN4MPcY+XnpUCYGKgKBVLC +tXoD9iHgCpvexbxjJgLIeeWotUoY3xAo8YnX2Luf+Vtah6dRAkEA03hLG+w8lov2 +J3AfHQgKjKfRrMU5eC4P9I4LN+nLyHd7i6T+JP5V5NadRkN+xj0zHfo8PxjaPmDv +QQqkhUlV3wJBAMVsGqa6NbtnCvHPc/M0XanBRuGejz2mDJqqaXMCBw8tY318XEMs +gRdlDVWrNg7AcSEuzh48OA7c6lazKLa5N/kCQHhA69VRHZMuvCfpJohHzlf2BtIM +xYWGDCSxscd1+CBjcaoThUJcL1QWhxExyKHKo4rkheYLp+/ZB7Ug7DWvYlkCQQCZ +SO+Ehs5TfJVF3UJ1EjKrLHNhmOA1CKl+qVQIxQlAIoi+FQH58iMlTAPHgZEOcSMl +lZbaaP1JpQOaX677+OHZAkA8vVYbYC+7t0SUqoY0X8z11Iobttpqir9trNq1CK8A +/3To+/BFK2A6Sj7R5u90Vi523FpWq1a74oNAsnB3x7TU +-----END RSA PRIVATE KEY----- diff --git a/spec/fixtures/ssl/openssl-exts.cnf b/spec/fixtures/ssl/openssl-exts.cnf new file mode 100644 index 0000000..a7bf5fd --- /dev/null +++ b/spec/fixtures/ssl/openssl-exts.cnf @@ -0,0 +1,9 @@ +[ca] +basicConstraints=critical,CA:true +subjectKeyIdentifier=hash +authorityKeyIdentifier=keyid:always,issuer:always + +[cert] +basicConstraints=critical,CA:false +subjectKeyIdentifier=hash +authorityKeyIdentifier=keyid,issuer diff --git a/spec/httparty/ssl_spec.rb b/spec/httparty/ssl_spec.rb new file mode 100644 index 0000000..fb881c5 --- /dev/null +++ b/spec/httparty/ssl_spec.rb @@ -0,0 +1,54 @@ +require File.expand_path(File.join(File.dirname(__FILE__), '..', 'spec_helper')) + +describe HTTParty::Request do + context "SSL certificate verification" do + before do + FakeWeb.allow_net_connect = true # enable network connections just for this test + end + + after do + FakeWeb.allow_net_connect = false # Restore allow_net_connect value for testing + end + + it "should work with when no trusted CA list is specified" do + ssl_verify_test(nil, nil, "selfsigned.crt").should == {'success' => true} + end + + it "should work with when no trusted CA list is specified, even with a bogus hostname" do + ssl_verify_test(nil, nil, "bogushost.crt").should == {'success' => true} + end + + it "should work when using ssl_ca_file with a self-signed CA" do + ssl_verify_test(:ssl_ca_file, "selfsigned.crt", "selfsigned.crt").should == {'success' => true} + end + + it "should work when using ssl_ca_file with a certificate authority" do + ssl_verify_test(:ssl_ca_file, "ca.crt", "server.crt").should == {'success' => true} + end + it "should work when using ssl_ca_path with a certificate authority" do + ssl_verify_test(:ssl_ca_path, ".", "server.crt").should == {'success' => true} + end + + it "should fail when using ssl_ca_file and the server uses an unrecognized certificate authority" do + lambda do + ssl_verify_test(:ssl_ca_file, "ca.crt", "selfsigned.crt") + end.should raise_error(OpenSSL::SSL::SSLError) + end + it "should fail when using ssl_ca_path and the server uses an unrecognized certificate authority" do + lambda do + ssl_verify_test(:ssl_ca_path, ".", "selfsigned.crt") + end.should raise_error(OpenSSL::SSL::SSLError) + end + + it "should fail when using ssl_ca_file and the server uses a bogus hostname" do + lambda do + ssl_verify_test(:ssl_ca_file, "ca.crt", "bogushost.crt") + end.should raise_error(OpenSSL::SSL::SSLError) + end + it "should fail when using ssl_ca_path and the server uses a bogus hostname" do + lambda do + ssl_verify_test(:ssl_ca_path, ".", "bogushost.crt") + end.should raise_error(OpenSSL::SSL::SSLError) + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index deb1d7c..e836ed3 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -10,6 +10,7 @@ Dir[File.expand_path(File.join(File.dirname(__FILE__),'support','**','*.rb'))].e Spec::Runner.configure do |config| config.include HTTParty::StubResponse + config.include HTTParty::SSLTestHelper config.before(:suite) do FakeWeb.allow_net_connect = false end diff --git a/spec/support/ssl_test_helper.rb b/spec/support/ssl_test_helper.rb new file mode 100644 index 0000000..cec611e --- /dev/null +++ b/spec/support/ssl_test_helper.rb @@ -0,0 +1,25 @@ +module HTTParty + module SSLTestHelper + def ssl_verify_test(mode, ca_basename, server_cert_filename) + test_server = nil + begin + # Start an HTTPS server + test_server = SSLTestServer.new( + :rsa_key => File.read(File.expand_path("../../fixtures/ssl/generated/server.key", __FILE__)), + :cert => File.read(File.expand_path("../../fixtures/ssl/generated/#{server_cert_filename}", __FILE__))) + test_server.start + + # Build a request + if mode + ca_path = File.expand_path("../../fixtures/ssl/generated/#{ca_basename}", __FILE__) + raise ArgumentError.new("#{ca_path} does not exist") unless File.exist?(ca_path) + return HTTParty.get("https://localhost:#{test_server.port}/", :format => :json, :timeout=>30, mode => ca_path) + else + return HTTParty.get("https://localhost:#{test_server.port}/", :format => :json, :timeout=>30) + end + ensure + test_server.stop if test_server + end + end + end +end diff --git a/spec/support/ssl_test_server.rb b/spec/support/ssl_test_server.rb new file mode 100644 index 0000000..dbe9870 --- /dev/null +++ b/spec/support/ssl_test_server.rb @@ -0,0 +1,69 @@ +require 'openssl' +require 'socket' +require 'thread' + +# NOTE: This code is garbage. It probably has deadlocks, it might leak +# threads, and otherwise cause problems in a real system. It's really only +# intended for testing HTTParty. +class SSLTestServer + attr_accessor :ctx # SSLContext object + attr_reader :port + + def initialize(options={}) + @ctx = OpenSSL::SSL::SSLContext.new + @ctx.cert = OpenSSL::X509::Certificate.new(options[:cert]) + @ctx.key = OpenSSL::PKey::RSA.new(options[:rsa_key]) + @ctx.verify_mode = OpenSSL::SSL::VERIFY_NONE # Don't verify client certificate + @port = options[:port] || 0 + @thread = nil + @stopping_mutex = Mutex.new + @stopping = false + end + + def start + @raw_server = TCPServer.new(@port) + if @port == 0 + @port = Socket::getnameinfo(@raw_server.getsockname, Socket::NI_NUMERICHOST|Socket::NI_NUMERICSERV)[1].to_i + end + @ssl_server = OpenSSL::SSL::SSLServer.new(@raw_server, @ctx) + @stopping_mutex.synchronize{ + return if @stopping + @thread = Thread.new{ thread_main } + } + nil + end + + def stop + @stopping_mutex.synchronize{ + return if @stopping + @stopping = true + } + @thread.join + end + + private + + def thread_main + until @stopping_mutex.synchronize{ @stopping } + (rr,ww,ee) = select([@ssl_server.to_io], nil, nil, 0.1) + next unless rr && rr.include?(@ssl_server.to_io) + socket = @ssl_server.accept + Thread.new{ + header = [] + until (line = socket.readline).rstrip.empty? + header << line + end + + socket.write <<'EOF'.gsub(/\r\n/n, "\n").gsub(/\n/n, "\r\n") +HTTP/1.1 200 OK +Connection: close +Content-Type: application/json; charset=UTF-8 + +{"success":true} +EOF + socket.close + } + end + @ssl_server.close + end +end