diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2ff4570 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,12 @@ +FROM openresty/openresty:1.31.1.1-alpine-fat + +RUN apk add --no-cache curl perl bash wget git perl-dev libarchive-tools nodejs; \ + ln -s /usr/bin/bsdtar /usr/bin/tar + +RUN curl -s -L https://cpanmin.us | perl - App::cpanminus > /bin/cpanm && chmod +x /bin/cpanm + +RUN cpanm -q -n Test::Nginx + +RUN /usr/local/openresty/luajit/bin/luarocks install luacov && \ + /usr/local/openresty/luajit/bin/luarocks install lua-resty-openssl && \ + /usr/local/openresty/luajit/bin/luarocks install luacheck diff --git a/README.md b/README.md index 59c02f5..4ee5985 100644 --- a/README.md +++ b/README.md @@ -179,6 +179,7 @@ The options table has the following fields: * `ssl_send_status_req`: option as per [OpenResty docs](https://github.com/openresty/lua-nginx-module#tcpsocksslhandshake) * `ssl_client_cert`: will be passed to `tcpsock:setclientcert`. Requires `ngx_lua_http_module` >= v0.10.23. * `ssl_client_priv_key`: as above. +* `ssl_trusted_store`: a custom trusted CA store (cdata `X509_STORE*`) to verify the server certificate against, passed to `tcpsock:settrustedstore`. Requires `ngx_lua_http_module` >= v0.10.31rc1 ## set\_timeout diff --git a/lib/resty/http_connect.lua b/lib/resty/http_connect.lua index 83b6d2f..37f0402 100644 --- a/lib/resty/http_connect.lua +++ b/lib/resty/http_connect.lua @@ -3,6 +3,7 @@ local ngx_re_gmatch = ngx.re.gmatch local ngx_re_sub = ngx.re.sub local ngx_re_find = ngx.re.find local ngx_log = ngx.log +local ngx_md5 = ngx.md5 local ngx_WARN = ngx.WARN local ngx_DEBUG = ngx.DEBUG local to_hex = require("resty.string").to_hex @@ -53,6 +54,11 @@ client:connect { ssl_client_cert = nil, ssl_client_priv_key = nil, + -- Custom trusted CA store (cdata `X509_STORE*`), passed to + -- `tcpsock:settrustedstore`. Requires a cosocket build with + -- `settrustedstore` support. + ssl_trusted_store = nil, + proxy_opts, -- proxy opts, defaults to global proxy options } ]] @@ -81,6 +87,7 @@ local function connect(self, options) -- ssl settings local ssl, ssl_reused_session, ssl_server_name local ssl_verify, ssl_send_status_req, ssl_client_cert, ssl_client_priv_key + local ssl_trusted_store if request_scheme == "https" then ssl = true ssl_reused_session = options.ssl_reused_session @@ -92,6 +99,7 @@ local function connect(self, options) end ssl_client_cert = options.ssl_client_cert ssl_client_priv_key = options.ssl_client_priv_key + ssl_trusted_store = options.ssl_trusted_store end -- proxy related settings @@ -233,6 +241,22 @@ local function connect(self, options) cert_hash = to_hex(cert_hash) -- convert to hex so that it's printable end + -- Validate ssl_trusted_store + if ssl and ssl_trusted_store then + if type(ssl_trusted_store) ~= "cdata" then + return nil, "bad ssl_trusted_store: cdata expected, got " .. type(ssl_trusted_store) + end + + if type(sock.setclientcert) ~= "function" then + return nil, "cannot use ssl_trusted_store without settrustedstore support" + end + end + + local ssl_trusted_store_id + if ssl_trusted_store then + ssl_trusted_store_id = ngx_md5(tostring(ssl_trusted_store)) + end + -- construct a poolname unique within proxy and ssl info if not poolname then poolname = (request_scheme or "") @@ -244,6 +268,7 @@ local function connect(self, options) .. ":" .. (proxy_uri or "") .. ":" .. (request_scheme == "https" and proxy_authorization or "") .. ":" .. (cert_hash or "") + .. ":" .. tostring(ssl_trusted_store_id or "") -- in the above we only add the 'proxy_authorization' as part of the poolname -- when the request is https. Because in that case the CONNECT request (which -- carries the authorization header) is part of the connect procedure, whereas @@ -315,11 +340,21 @@ local function connect(self, options) else ok, err = sock:setclientcert(ssl_client_cert, ssl_client_priv_key) if not ok then + self:close() return nil, "could not set client certificate: " .. err end end end + -- Custom CA trusted store support + if ssl_trusted_store then + ok, err = sock:settrustedstore(ssl_trusted_store) + if not ok then + self:close() + return nil, "could not set trusted store: " .. err + end + end + ssl_session, err = sock:sslhandshake(ssl_reused_session, ssl_server_name, ssl_verify, ssl_send_status_req) if not ssl_session then self:close() diff --git a/lua-resty-http-0.17.2-0.rockspec b/lua-resty-http-0.17.2-0.rockspec index c1d8113..885ad08 100644 --- a/lua-resty-http-0.17.2-0.rockspec +++ b/lua-resty-http-0.17.2-0.rockspec @@ -1,14 +1,14 @@ -package = "lua-resty-http" +package = "3scale-lua-resty-http" version = "0.17.2-0" source = { - url = "git://github.com/ledgetech/lua-resty-http", + url = "git@github.com/3scale/lua-resty-http.git", tag = "v0.17.2" } description = { summary = "Lua HTTP client cosocket driver for OpenResty / ngx_lua.", - homepage = "https://github.com/ledgetech/lua-resty-http", + homepage = "https://github.com/3scale/lua-resty-http", license = "2-clause BSD", - maintainer = "James Hurst " + maintainer = "3scale" } dependencies = { "lua >= 5.1" diff --git a/t/21-ssl-trusted-store.t b/t/21-ssl-trusted-store.t new file mode 100644 index 0000000..7341fdb --- /dev/null +++ b/t/21-ssl-trusted-store.t @@ -0,0 +1,232 @@ +use Test::Nginx::Socket::Lua 'no_plan'; + + +log_level 'debug'; + +no_long_string(); + +sub read_file { + my $infile = shift; + open my $in, $infile + or die "cannot open $infile for reading: $!"; + my $cert = do { local $/; <$in> }; + close $in; + $cert; +} + +our $TestCert = read_file("t/cert/test.crt"); +our $TestKey = read_file("t/cert/test.key"); + +our $HtmlDir = html_dir; + +use Cwd qw(cwd); +my $pwd = cwd(); + +our $http_config = <<"_EOC_"; + lua_package_path "$pwd/lib/?.lua;/usr/local/share/lua/5.1/?.lua;;"; +server { + listen unix:$::HtmlDir/ssl.sock ssl; + + ssl_certificate $::HtmlDir/test.crt; + ssl_certificate_key $::HtmlDir/test.key; + server_tokens off; + server_name example.com; + + location / { + echo -n "hello"; + } +} +_EOC_ + +our $user_files = <<"_EOC_"; +>>> test.crt +$::TestCert +>>> test.key +$::TestKey +_EOC_ + +run_tests(); + +__DATA__ + +=== TEST 1: ssl_trusted_store rejects non-cdata values +--- http_config eval: $::http_config +--- config eval +" +lua_ssl_trusted_certificate $::HtmlDir/test.crt; +location /t { + content_by_lua_block { + local httpc = assert(require('resty.http').new()) + + local ok, err = httpc:connect { + scheme = 'https', + host = 'unix:$::HtmlDir/ssl.sock', + ssl_trusted_store = 'not_a_cdata', + } + + if not ok then + ngx.status = 400 + ngx.say(err) + else + ngx.say('unexpected success') + end + } +} +" +--- user_files eval: $::user_files +--- request +GET /t +--- error_code: 400 +--- response_body +bad ssl_trusted_store: cdata expected, got string +--- no_error_log +[error] +[warn] + + +=== TEST 2: ssl_trusted_store rejects table values +--- http_config eval: $::http_config +--- config eval +" +lua_ssl_trusted_certificate $::HtmlDir/test.crt; +location /t { + content_by_lua_block { + local httpc = assert(require('resty.http').new()) + + local ok, err = httpc:connect { + scheme = 'https', + host = 'unix:$::HtmlDir/ssl.sock', + ssl_trusted_store = {}, + } + + if not ok then + ngx.status = 400 + ngx.say(err) + else + ngx.say('unexpected success') + end + } +} +" +--- user_files eval: $::user_files +--- request +GET /t +--- error_code: 400 +--- response_body +bad ssl_trusted_store: cdata expected, got table +--- no_error_log +[error] +[warn] + + +=== TEST 3: ssl_trusted_store is ignored for non-https schemes +--- http_config eval: $::http_config +--- config eval +" +location /backend { + echo -n 'ok'; +} + +location /t { + content_by_lua_block { + local httpc = assert(require('resty.http').new()) + + local ok, err = httpc:connect { + scheme = 'http', + host = '127.0.0.1', + port = \$TEST_NGINX_SERVER_PORT, + ssl_trusted_store = 'should_be_ignored', + } + + if not ok then + ngx.say('connect failed: ' .. err) + return + end + + local res, err = httpc:request { + method = 'GET', + path = '/backend', + } + + if not res then + ngx.say('request failed: ' .. err) + return + end + + ngx.say(res:read_body()) + httpc:close() + } +} +" +--- user_files eval: $::user_files +--- request +GET /t +--- error_code: 200 +--- response_body +ok +--- no_error_log +[error] +[warn] + + +=== TEST 4: same ssl_trusted_store reuses pool, different store gets a new pool +--- http_config eval: $::http_config +--- config eval +" +lua_ssl_trusted_certificate $::HtmlDir/test.crt; +location /t { + content_by_lua_block { + local x509_store = require('resty.openssl.x509.store') + + local store1 = assert(x509_store.new()) + local store2 = assert(x509_store.new()) + + -- First connection with store1 + local httpc1 = assert(require('resty.http').new()) + local ok, err = httpc1:connect { + scheme = 'https', + host = 'unix:$::HtmlDir/ssl.sock', + ssl_trusted_store = store1.ctx, + ssl_verify = false, + } + assert(ok and not err, 'connect 1 failed: ' .. (err or '')) + httpc1:set_keepalive() + + -- Second connection with the same store1 — should reuse the pool + local httpc2 = assert(require('resty.http').new()) + ok, err = httpc2:connect { + scheme = 'https', + host = 'unix:$::HtmlDir/ssl.sock', + ssl_trusted_store = store1.ctx, + ssl_verify = false, + } + assert(ok and not err, 'connect 2 failed: ' .. (err or '')) + ngx.say('same store reused: ', httpc2:get_reused_times()) + httpc2:set_keepalive() + + -- Third connection with a different store2 — should NOT reuse the pool + local httpc3 = assert(require('resty.http').new()) + ok, err = httpc3:connect { + scheme = 'https', + host = 'unix:$::HtmlDir/ssl.sock', + ssl_trusted_store = store2.ctx, + ssl_verify = false, + } + assert(ok and not err, 'connect 3 failed: ' .. (err or '')) + ngx.say('different store reused: ', httpc3:get_reused_times()) + httpc3:close() + } +} +" +--- user_files eval: $::user_files +--- request +GET /t +--- error_code: 200 +--- response_body +same store reused: 1 +different store reused: 0 +--- no_error_log +[error] +[warn] +--- skip_nginx +4: < 1.21.4