This error message is grammatically incorrect and unhelpful, so we replace it with a message that explains more correctly what went wrong and what was expected. This message happens when making an authenticated connection to a server where the certificate doesn't match its hostname. This happens in the REST terminuses, so we wrap their HTTP methods with a helper that will catch the appropriate SSLError and re-raise it with the better message stating the hostname used, and the list of hostnames that we were expecting it to be a part of.
Unfortunately, because the certificate in question isn't available at error time, we have to use the Net::HTTP#verify_callback to capture it. Paired-With: Jacob Helwig <ja...@puppetlabs.com> Reviewed-By: Dominic Maraglia <domi...@puppetlabs.com> Signed-off-by: Nick Lewis <n...@puppetlabs.com> --- Local-branch: ticket/2.7.x/7224 ...e_when_hostname_not_match_server_certificate.rb | 12 +++++ ...ket_3360_allow_duplicate_csr_with_option_set.rb | 4 +- lib/puppet/indirector/rest.rb | 47 +++++++++++++++++--- spec/unit/indirector/certificate/rest_spec.rb | 1 + spec/unit/indirector/rest_spec.rb | 47 +++++++++++++++++-- 5 files changed, 97 insertions(+), 14 deletions(-) create mode 100644 acceptance/tests/helpful_error_message_when_hostname_not_match_server_certificate.rb diff --git a/acceptance/tests/helpful_error_message_when_hostname_not_match_server_certificate.rb b/acceptance/tests/helpful_error_message_when_hostname_not_match_server_certificate.rb new file mode 100644 index 0000000..c3b5b67 --- /dev/null +++ b/acceptance/tests/helpful_error_message_when_hostname_not_match_server_certificate.rb @@ -0,0 +1,12 @@ +test_name "generate a helpful error message when hostname doesn't match server certificate" + +step "Clear any existing SSL directories" +on(hosts, "rm -r #{config['puppetpath']}/ssl") + +# Start the master with a certname not matching its hostname +with_master_running_on(master, "--certname foobar_not_my_hostname --certdnsnames one_cert:two_cert:red_cert:blue_cert --autosign true") do + run_agent_on(agents, "--no-daemonize --verbose --onetime --server #{master}", :acceptable_exit_codes => (1..255)) do + msg = "Server hostname '#{master}' did not match server certificate; expected one of foobar_not_my_hostname, one_cert, two_cert, red_cert, blue_cert" + assert_match(msg, stdout) + end +end diff --git a/acceptance/tests/ticket_3360_allow_duplicate_csr_with_option_set.rb b/acceptance/tests/ticket_3360_allow_duplicate_csr_with_option_set.rb index 9eaf4c2..a34a3e7 100644 --- a/acceptance/tests/ticket_3360_allow_duplicate_csr_with_option_set.rb +++ b/acceptance/tests/ticket_3360_allow_duplicate_csr_with_option_set.rb @@ -2,8 +2,8 @@ test_name "#3360: Allow duplicate CSR when allow_duplicate_certs is on" agent_hostnames = agents.map {|a| a.to_s} -step "Remove existing SSL directory for agents" -on agents, "rm -r #{config['puppetpath']}/ssl" +step "Remove existing SSL directory for hosts" +on hosts, "rm -r #{config['puppetpath']}/ssl" with_master_running_on master, "--allow_duplicate_certs --certdnsnames=\"puppet:$(hostname -s):$(hostname -f)\" --verbose --noop" do step "Generate a certificate request for the agent" diff --git a/lib/puppet/indirector/rest.rb b/lib/puppet/indirector/rest.rb index 0d39972..8018fe8 100644 --- a/lib/puppet/indirector/rest.rb +++ b/lib/puppet/indirector/rest.rb @@ -71,16 +71,49 @@ class Puppet::Indirector::REST < Puppet::Indirector::Terminus Puppet::Network::HttpPool.http_instance(request.server || self.class.server, request.port || self.class.port) end + [:get, :post, :head, :delete, :put].each do |method| + define_method "http_#{method}" do |request, *args| + http_request(method, request, *args) + end + end + + def http_request(method, request, *args) + http_connection = network(request) + peer_certs = [] + + # We add the callback to collect the certificates for use in constructing + # the error message if the verification failed. This is necessary since we + # don't have direct access to the cert that we expected the connection to + # use otherwise. + # + http_connection.verify_callback = proc do |preverify_ok, ssl_context| + peer_certs << Puppet::SSL::Certificate.from_s(ssl_context.current_cert.to_pem) + preverify_ok + end + + http_connection.send(method, *args) + rescue OpenSSL::SSL::SSLError => error + if error.message.include? "hostname was not match" + raise unless cert = peer_certs.find { |c| c.name !~ /^puppet ca/i } + + valid_certnames = [cert.name, *cert.alternate_names].uniq + msg = valid_certnames.length > 1 ? "one of #{valid_certnames.join(', ')}" : valid_certnames.first + + raise Puppet::Error, "Server hostname '#{http_connection.address}' did not match server certificate; expected #{msg}" + else + raise + end + end + def find(request) uri, body = request_to_uri_and_body(request) uri_with_query_string = "#{uri}?#{body}" - http_connection = network(request) # WEBrick in Ruby 1.9.1 only supports up to 1024 character lines in an HTTP request # http://redmine.ruby-lang.org/issues/show/3991 response = if "GET #{uri_with_query_string} HTTP/1.1\r\n".length > 1024 - http_connection.post(uri, body, headers) + http_post(request, uri, body, headers) else - http_connection.get(uri_with_query_string, headers) + http_get(request, uri_with_query_string, headers) end result = deserialize response result.name = request.key if result.respond_to?(:name=) @@ -88,7 +121,7 @@ class Puppet::Indirector::REST < Puppet::Indirector::Terminus end def head(request) - response = network(request).head(indirection2uri(request), headers) + response = http_head(request, indirection2uri(request), headers) case response.code when "404" return false @@ -101,7 +134,7 @@ class Puppet::Indirector::REST < Puppet::Indirector::Terminus end def search(request) - unless result = deserialize(network(request).get(indirection2uri(request), headers), true) + unless result = deserialize(http_get(request, indirection2uri(request), headers), true) return [] end result @@ -109,12 +142,12 @@ class Puppet::Indirector::REST < Puppet::Indirector::Terminus def destroy(request) raise ArgumentError, "DELETE does not accept options" unless request.options.empty? - deserialize network(request).delete(indirection2uri(request), headers) + deserialize http_delete(request, indirection2uri(request), headers) end def save(request) raise ArgumentError, "PUT does not accept options" unless request.options.empty? - deserialize network(request).put(indirection2uri(request), request.instance.render, headers.merge({ "Content-Type" => request.instance.mime })) + deserialize http_put(request, indirection2uri(request), request.instance.render, headers.merge({ "Content-Type" => request.instance.mime })) end private diff --git a/spec/unit/indirector/certificate/rest_spec.rb b/spec/unit/indirector/certificate/rest_spec.rb index 21e10e3..870d9e4 100755 --- a/spec/unit/indirector/certificate/rest_spec.rb +++ b/spec/unit/indirector/certificate/rest_spec.rb @@ -47,6 +47,7 @@ rn/G response = stub 'response', :code => "200", :body => cert_string response.stubs(:[]).with('content-type').returns "text/plain" response.stubs(:[]).with('content-encoding') + network.stubs(:verify_callback=) network.expects(:get).returns response request = Puppet::Indirector::Request.new(:certificate, :find, "foo.com") diff --git a/spec/unit/indirector/rest_spec.rb b/spec/unit/indirector/rest_spec.rb index 5637080..ee0111a 100755 --- a/spec/unit/indirector/rest_spec.rb +++ b/spec/unit/indirector/rest_spec.rb @@ -90,6 +90,43 @@ describe Puppet::Indirector::REST do @rest_class.port.should == 543 end + describe "when making http requests" do + it "should provide a helpful error message when hostname was not match with server certificate" do + Puppet[:certdnsnames] = 'foo:bar:baz' + csr = OpenSSL::X509::Request.new + csr.subject = OpenSSL::X509::Name.new([['CN', 'not_my_server']]) + csr.public_key = OpenSSL::PKey::RSA.generate(Puppet[:keylength]).public_key + cert = Puppet::SSL::CertificateFactory.new('server', csr, csr, 14).result + + connection = Net::HTTP.new('my_server', 8140) + @searcher.stubs(:network).returns(connection) + ssl_context = OpenSSL::SSL::SSLContext.new + ssl_context.stubs(:current_cert).returns(cert) + connection.stubs(:get).with do + connection.verify_callback.call(true, ssl_context) + end.raises(OpenSSL::SSL::SSLError.new('hostname was not match with server certificate')) + + msg = /Server hostname 'my_server' did not match server certificate; expected one of (.+)/ + expect { @searcher.http_request(:get, stub('request')) }.to( + raise_error(Puppet::Error, msg) do |error| + error.message =~ msg + $1.split(', ').should =~ ['foo', 'bar', 'baz', 'not_my_server'] + end + ) + end + + it "should pass along the error message otherwise" do + connection = Net::HTTP.new('my_server', 8140) + @searcher.stubs(:network).returns(connection) + + connection.stubs(:get).raises(OpenSSL::SSL::SSLError.new('certificate verify failed')) + + expect do + @searcher.http_request(:get, stub('request')) + end.to raise_error(/certificate verify failed/) + end + end + describe "when deserializing responses" do it "should return nil if the response code is 404" do response = mock 'response' @@ -219,7 +256,7 @@ describe Puppet::Indirector::REST do describe "when doing a find" do before :each do - @connection = stub('mock http connection', :get => @response) + @connection = stub('mock http connection', :get => @response, :verify_callback= => nil) @searcher.stubs(:network).returns(@connection) # neuter the network connection # Use a key with spaces, so we can test escaping @@ -313,7 +350,7 @@ describe Puppet::Indirector::REST do describe "when doing a head" do before :each do - @connection = stub('mock http connection', :head => @response) + @connection = stub('mock http connection', :head => @response, :verify_callback= => nil) @searcher.stubs(:network).returns(@connection) # Use a key with spaces, so we can test escaping @@ -349,7 +386,7 @@ describe Puppet::Indirector::REST do describe "when doing a search" do before :each do - @connection = stub('mock http connection', :get => @response) + @connection = stub('mock http connection', :get => @response, :verify_callback= => nil) @searcher.stubs(:network).returns(@connection) # neuter the network connection @model.stubs(:convert_from_multiple) @@ -397,7 +434,7 @@ describe Puppet::Indirector::REST do describe "when doing a destroy" do before :each do - @connection = stub('mock http connection', :delete => @response) + @connection = stub('mock http connection', :delete => @response, :verify_callback= => nil) @searcher.stubs(:network).returns(@connection) # neuter the network connection @request = Puppet::Indirector::Request.new(:foo, :destroy, "foo bar") @@ -453,7 +490,7 @@ describe Puppet::Indirector::REST do describe "when doing a save" do before :each do - @connection = stub('mock http connection', :put => @response) + @connection = stub('mock http connection', :put => @response, :verify_callback= => nil) @searcher.stubs(:network).returns(@connection) # neuter the network connection @instance = stub 'instance', :render => "mydata", :mime => "mime" -- 1.7.5.1 -- You received this message because you are subscribed to the Google Groups "Puppet Developers" group. To post to this group, send email to puppet-dev@googlegroups.com. To unsubscribe from this group, send email to puppet-dev+unsubscr...@googlegroups.com. For more options, visit this group at http://groups.google.com/group/puppet-dev?hl=en.