diff --git a/lib/playwright/utils.rb b/lib/playwright/utils.rb index 763ba628..1ec7118e 100644 --- a/lib/playwright/utils.rb +++ b/lib/playwright/utils.rb @@ -1,3 +1,5 @@ +require 'base64' + module Playwright module Utils module PrepareBrowserContextOptions @@ -65,6 +67,28 @@ module PrepareBrowserContextOptions params[:acceptDownloads] = params[:acceptDownloads] ? 'accept' : 'deny' end + if params[:clientCertificates].is_a?(Array) + params[:clientCertificates] = params[:clientCertificates].filter_map do |item| + out_record = { + origin: item[:origin], + passphrase: item[:passphrase], + } + + { pfxPath: 'pfx', certPath: 'cert', keyPath: 'key' }.each do |key, out_key| + if (filepath = item[key]) + out_record[out_key] = Base64.encode64(File.read(filepath)) rescue '' + elsif (value = item[out_key.to_sym]) + out_record[out_key] = value + end + end + + out_record.compact! + next nil if out_record.empty? + + out_record + end + end + params end end diff --git a/spec/integration/client_certificates_spec.rb b/spec/integration/client_certificates_spec.rb new file mode 100644 index 00000000..631676d7 --- /dev/null +++ b/spec/integration/client_certificates_spec.rb @@ -0,0 +1,182 @@ +require 'spec_helper' + +RSpec.describe 'client certificates', sinatra: true, tls: true do + before { skip unless chromium? } + + it 'should validate input 1' do + options = { + ignoreHTTPSErrors: true, + clientCertificates: [{ origin: 'test' }] + } + expect { + with_context(**options) do |context| + context.new_page + end + }.to raise_error(/None of cert, key, passphrase or pfx is specified/) + end + + it 'should validate input 2' do + kDummyFileName = '__filename' + options = { + ignoreHTTPSErrors: true, + clientCertificates: [{ + origin: 'test', + certPath: kDummyFileName, + keyPath: kDummyFileName, + pfxPath: kDummyFileName, + passphrase: kDummyFileName, + }] + } + expect { + with_context(**options) do |context| + context.new_page + end + }.to raise_error(/pfx is specified together with cert, key or passphrase/) + end + + it 'should validate input 3' do + kDummyFileName = '__filename' + options = { + ignoreHTTPSErrors: true, + proxy: { server: 'http://localhost:8080' }, + clientCertificates: [{ + origin: 'test', + certPath: kDummyFileName, + keyPath: kDummyFileName, + }] + } + expect { + with_context(**options) do |context| + context.new_page + end + }.to raise_error(/Cannot specify both proxy and clientCertificates/) + end + + it 'should fail with no client certificates provided' do + with_context(ignoreHTTPSErrors: true) do |context| + page = context.new_page + expect { + page.goto(server_empty_page) + }.to raise_error(/net::ERR_CONNECTION_RESET|net::ERR_SOCKET_NOT_CONNECTED|The network connection was lost|Connection terminated unexpectedly/) + end + end + + def asset(path) + File.join(__dir__, '../', 'assets', path) + end + + it 'should throw with untrusted client certs' do + options = { + ignoreHTTPSErrors: true, + clientCertificates: [{ + origin: server_prefix, + certPath: asset('client-certificates/client/self-signed/cert.pem'), + keyPath: asset('client-certificates/client/self-signed/key.pem'), + }] + } + with_context(**options) do |context| + page = context.new_page + expect { + page.goto(server_empty_page) + }.to raise_error(/net::ERR_EMPTY_RESPONSE|The network connection was lost|Connection terminated unexpectedly/) + end + end + + it 'should pass with trusted client certificates' do + options = { + ignoreHTTPSErrors: true, + clientCertificates: [{ + origin: server_prefix, + certPath: asset('client-certificates/client/trusted/cert.pem'), + keyPath: asset('client-certificates/client/trusted/key.pem'), + }] + } + with_context(**options) do |context| + page = context.new_page + page.goto("#{server_prefix}/one-style.html") + expect(page.content).to include('hello, world!') + end + end + + it 'should pass with trusted client certificates in base64 format' do + options = { + ignoreHTTPSErrors: true, + clientCertificates: [{ + origin: server_prefix, + cert: Base64.encode64(File.read(asset('client-certificates/client/trusted/cert.pem'))), + key: Base64.encode64(File.read(asset('client-certificates/client/trusted/key.pem'))), + }] + } + with_context(**options) do |context| + page = context.new_page + page.goto("#{server_prefix}/one-style.html") + expect(page.content).to include('hello, world!') + end + end + + it 'should pass with trusted client certificates in pfx format' do + options = { + ignoreHTTPSErrors: true, + clientCertificates: [{ + origin: server_prefix, + pfxPath: asset('client-certificates/client/trusted/cert.pfx'), + passphrase: 'secure' + }] + } + with_context(**options) do |context| + page = context.new_page + page.goto("#{server_prefix}/one-style.html") + expect(page.content).to include('hello, world!') + end + end + + it 'should pass with trusted client certificates in pfx format with base64 encoded' do + options = { + ignoreHTTPSErrors: true, + clientCertificates: [{ + origin: server_prefix, + pfx: Base64.encode64(File.read(asset('client-certificates/client/trusted/cert.pfx'))), + passphrase: 'secure' + }] + } + with_context(**options) do |context| + page = context.new_page + page.goto("#{server_prefix}/one-style.html") + expect(page.content).to include('hello, world!') + end + end + + it 'should throw a http error if the pfx passphrase is incorect' do + options = { + ignoreHTTPSErrors: true, + clientCertificates: [{ + origin: server_prefix, + pfxPath: asset('client-certificates/client/trusted/cert.pfx'), + passphrase: 'this-password-is-incorrect' + }] + } + with_context(**options) do |context| + page = context.new_page + page.goto(server_empty_page) + expect(page.content).to include('mac verify failure') + end + end + + it 'should fail with matching certificates in legacy pfx format' do + skip unless Gem::Version.new(OpenSSL::VERSION) >= Gem::Version.new('3.0.0') + + options = { + ignoreHTTPSErrors: true, + clientCertificates: [{ + origin: server_prefix, + pfxPath: asset('client-certificates/client/trusted/cert-legacy.pfx'), + passphrase: 'secure' + }] + } + with_context(**options) do |context| + page = context.new_page + page.goto(server_empty_page) + expect(page.content).to include('Unsupported TLS certificate') + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 4f93bb89..247064d3 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -163,7 +163,7 @@ def call(env) if ENV['CI'] module PumaEventsLogSuppressing ACCEPT= [ - /^\* Listening on http/, + /^\* Listening on (http|ssl)/, ] def log(str) @@ -239,13 +239,42 @@ def route_missing sinatra_app.get('/_ping') { '_pong' } + if example.metadata[:tls] + @server_prefix = "https://localhost:#{@server_port}" + @server_empty_page = "#{@server_prefix}/empty.html" + + base_path = File.join(__dir__, 'assets/client-certificates/server') + key_path = File.join(base_path, 'server_key.pem') + cert_path = File.join(base_path, 'server_cert.pem') + ca_path = File.join(base_path, 'server_cert.pem') + uri = URI('ssl://localhost') + uri.query = URI.encode_www_form( + key: key_path, + cert: cert_path, + ca: ca_path, + verify_mode: 'force_peer', + ) + bind = uri.to_s + else + bind = '127.0.0.1' + end + # Start server and wait for server ready. # FIXME should change port when Errno::EADDRINUSE - Thread.new(@server_port) { |port| sinatra_app.run!(port: port) } + Thread.new(@server_port) { |port| sinatra_app.run!(port: port, bind: bind) } Timeout.timeout(3) do loop do begin - Net::HTTP.get(URI("#{server_prefix}/_ping")) + if example.metadata[:tls] + Net::HTTP.start('localhost', @server_port, use_ssl: true, verify_mode: OpenSSL::SSL::VERIFY_NONE) do |http| + http.get('/_ping') + end + else + Net::HTTP.get(URI("#{server_prefix}/_ping")) + end + break + rescue Errno::ECONNRESET, OpenSSL::SSL::SSLError, EOFError + # In this case socket is connected but just SSL client cert is not provided. break rescue Errno::EADDRNOTAVAIL sleep 1