From ad5ac4eff92a2d31979e34cc15a6729a58be5b07 Mon Sep 17 00:00:00 2001 From: Walker Zhao Date: Wed, 29 Apr 2026 17:50:17 +0800 Subject: [PATCH 1/2] feature: add tcpsock:settrustedstore() for per-handshake trusted CAs Adds a Lua wrapper around the new ngx_http_lua_ffi_socket_tcp_settrustedstore FFI in lua-nginx-module, exposed as tcpsock:settrustedstore(store). The store is an X509_STORE * cdata (e.g. from lua-resty-openssl) that overrides lua_ssl_trusted_certificate for the next sslhandshake() on this cosocket. The C side consumes the slot during the handshake, so the override does not leak across handshakes; passing nil clears it on both the lua and C sides so a previously-set store cannot dangle past a GC of the user's last reference. This is needed for per-request mTLS upstreams where the trusted CA set is determined dynamically (per-tenant routing, dynamic CA discovery) and cannot be expressed via the static lua_ssl_trusted_certificate directive. The FFI symbol is looked up softly so loading lua-resty-core against an older lua-nginx-module that lacks the new symbol still works; the method is simply not attached to the cosocket metatable in that case. Only the http subsystem is wired up, matching the FFI surface in ngx_http_lua_module. Stream cosockets are unchanged. Requires lua-nginx-module change "feature: support custom trusted CA store for cosocket TLS handshake". Signed-off-by: Walker Zhao --- README.markdown | 1 + lib/resty/core/socket.lua | 50 ++++++ t/socket-tcp-settrustedstore.t | 311 +++++++++++++++++++++++++++++++++ 3 files changed, 362 insertions(+) create mode 100644 t/socket-tcp-settrustedstore.t diff --git a/README.markdown b/README.markdown index eed0c068a..997706d0e 100644 --- a/README.markdown +++ b/README.markdown @@ -309,6 +309,7 @@ in the current request before you reusing the `ctx` table in some other place. * [socket.setoption](https://github.com/openresty/lua-nginx-module#tcpsocksetoption) * [socket.setclientcert](https://github.com/openresty/lua-nginx-module#tcpsocksetclientcert) +* [socket.settrustedstore](https://github.com/openresty/lua-nginx-module#tcpsocksettrustedstore) * [socket.sslhandshake](https://github.com/openresty/lua-nginx-module#tcpsocksslhandshake) [Back to TOC](#table-of-contents) diff --git a/lib/resty/core/socket.lua b/lib/resty/core/socket.lua index 37a7b69c7..edfea9f91 100644 --- a/lib/resty/core/socket.lua +++ b/lib/resty/core/socket.lua @@ -85,6 +85,10 @@ int ngx_http_lua_ffi_socket_tcp_get_ssl_ctx(ngx_http_request_t *r, ngx_http_lua_socket_tcp_upstream_t *u, void **pctx, char **errmsg); + +int +ngx_http_lua_ffi_socket_tcp_settrustedstore(ngx_http_request_t *r, + ngx_http_lua_socket_tcp_upstream_t *u, void *store, char **errmsg); ]] ngx_lua_ffi_socket_tcp_getoption = C.ngx_http_lua_ffi_socket_tcp_getoption @@ -155,6 +159,7 @@ local SOCKET_CTX_INDEX = 1 local SOCKET_CLIENT_CERT_INDEX = 6 local SOCKET_CLIENT_PKEY_INDEX = 7 local SOCKET_IP_TRANSPARENT_INDEX = 9 +local SOCKET_TRUSTED_STORE_INDEX = 10 local function get_tcp_socket(cosocket) @@ -327,6 +332,48 @@ local function setclientcert(cosocket, cert, pkey) end +local ngx_lua_ffi_socket_tcp_settrustedstore +if pcall(function() + return C.ngx_http_lua_ffi_socket_tcp_settrustedstore +end) then + ngx_lua_ffi_socket_tcp_settrustedstore = + C.ngx_http_lua_ffi_socket_tcp_settrustedstore +end + + +local NULL_STORE = ffi_new("void *", nil) + + +local function settrustedstore(cosocket, store) + if not ngx_lua_ffi_socket_tcp_settrustedstore then + return nil, "tcpsock:settrustedstore is not supported by " .. + "the current lua-nginx-module" + end + + if store ~= nil and type(store) ~= "cdata" then + return nil, "bad store arg: cdata expected, got " .. type(store) + end + + local r = get_request() + if not r then + error("no request found", 2) + end + + local u = get_tcp_socket(cosocket) + + local rc = ngx_lua_ffi_socket_tcp_settrustedstore(r, u, + store or NULL_STORE, + errmsg) + if rc ~= FFI_OK then + return nil, ffi_str(errmsg[0]) + end + + cosocket[SOCKET_TRUSTED_STORE_INDEX] = store + + return true +end + + local function sslhandshake(cosocket, reused_session, server_name, ssl_verify, send_status_req, ...) @@ -443,6 +490,9 @@ do method_table.getoption = getoption method_table.setoption = setoption method_table.setclientcert = setclientcert + if ngx_lua_ffi_socket_tcp_settrustedstore then + method_table.settrustedstore = settrustedstore + end method_table.sslhandshake = sslhandshake method_table.getfd = getfd method_table.getoption = getoption diff --git a/t/socket-tcp-settrustedstore.t b/t/socket-tcp-settrustedstore.t new file mode 100644 index 000000000..007a7bfc2 --- /dev/null +++ b/t/socket-tcp-settrustedstore.t @@ -0,0 +1,311 @@ +# vim:set ft= ts=4 sw=4 et fdm=marker: + +use lib '.'; +use t::TestCore; + +repeat_each(2); + +my $NginxBinary = $ENV{'TEST_NGINX_BINARY'} || 'nginx'; +my $openssl_version = eval { `$NginxBinary -V 2>&1` }; + +if ($openssl_version =~ m/built with OpenSSL (0\S*|1\.0\S*|1\.1\.0\S*)/) { + plan(skip_all => "too old OpenSSL, need 1.1.1, was $1"); +} else { + plan tests => repeat_each() * (blocks() * 5); +} + +no_long_string(); +#no_diff(); + +env_to_nginx("PATH=" . $ENV{'PATH'}); +$ENV{TEST_NGINX_LUA_PACKAGE_PATH} = "$t::TestCore::lua_package_path"; +$ENV{TEST_NGINX_HTML_DIR} ||= html_dir(); + +# An http_config that: +# 1. boots resty.core in init_by_lua_block (same as t::TestCore::HttpConfig); +# 2. cdef's just enough OpenSSL to build an X509_STORE from a PEM blob and +# exposes load_store_from_pem() as a global; +# 3. stands up a TLS server on a unix socket presenting mtls_server.crt +# (signed by mtls_ca) so the test cosocket has something to handshake +# against. +our $TLSHttpConfig = <<_EOC_; + lua_package_path '$t::TestCore::lua_package_path'; + + init_by_lua_block { + $t::TestCore::init_by_lua_block + + local ffi = require "ffi" + ffi.cdef[[ + typedef struct x509_store_st X509_STORE; + typedef struct x509_st X509; + typedef struct bio_st BIO; + typedef struct bio_method_st BIO_METHOD; + + X509_STORE *X509_STORE_new(void); + int X509_STORE_add_cert(X509_STORE *ctx, X509 *x); + void X509_STORE_free(X509_STORE *v); + + BIO_METHOD *BIO_s_mem(void); + BIO *BIO_new(BIO_METHOD *type); + int BIO_write(BIO *b, const void *buf, int len); + void BIO_free(BIO *a); + X509 *PEM_read_bio_X509(BIO *bp, X509 **x, void *cb, void *u); + void X509_free(X509 *a); + ]] + + function _G.load_store_from_pem(pem) + local C = ffi.C + local bio = C.BIO_new(C.BIO_s_mem()) + if bio == nil then return nil, "BIO_new failed" end + if C.BIO_write(bio, pem, #pem) <= 0 then + C.BIO_free(bio) + return nil, "BIO_write failed" + end + local x509 = C.PEM_read_bio_X509(bio, nil, nil, nil) + C.BIO_free(bio) + if x509 == nil then return nil, "PEM_read_bio_X509 failed" end + local store = C.X509_STORE_new() + if store == nil then + C.X509_free(x509) + return nil, "X509_STORE_new failed" + end + if C.X509_STORE_add_cert(store, x509) ~= 1 then + C.X509_free(x509) + C.X509_STORE_free(store) + return nil, "X509_STORE_add_cert failed" + end + C.X509_free(x509) + return ffi.gc(store, C.X509_STORE_free) + end + } + + server { + listen unix:\$TEST_NGINX_HTML_DIR/tls.sock ssl; + ssl_certificate ../../cert/mtls_server.crt; + ssl_certificate_key ../../cert/mtls_server.key; + server_tokens off; + + location / { + content_by_lua_block { + ngx.say("hello, ", ngx.var.ssl_protocol) + } + } + } +_EOC_ + +run_tests(); + +__DATA__ + +=== TEST 1: handshake succeeds with a custom X509 trusted store +--- http_config eval: $::TLSHttpConfig +--- config + lua_ssl_verify_depth 2; + + location /t { + content_by_lua_block { + local f = assert(io.open("t/cert/mtls_ca.crt", "r")) + local pem = f:read("*a") + f:close() + + local store, err = load_store_from_pem(pem) + if not store then + ngx.say("failed to load store: ", err) + return + end + + local sock = ngx.socket.tcp() + sock:settimeout(3000) + + local ok, err = sock:connect("unix:$TEST_NGINX_HTML_DIR/tls.sock") + if not ok then + ngx.say("failed to connect: ", err) + return + end + + local ok, err = sock:settrustedstore(store) + if not ok then + ngx.say("failed to settrustedstore: ", err) + return + end + + local sess, err = sock:sslhandshake(nil, "example.com", true) + if not sess then + ngx.say("failed to do SSL handshake: ", err) + return + end + + local req = "GET / HTTP/1.0\r\nHost: example.com\r\nConnection: close\r\n\r\n" + local bytes, err = sock:send(req) + if not bytes then + ngx.say("failed to send: ", err) + return + end + + local line, err = sock:receive("*l") + if not line then + ngx.say("failed to receive: ", err) + return + end + + ngx.say("received: ", line) + sock:close() + } + } +--- request +GET /t +--- response_body_like +^received: HTTP/1\.0 200 OK +--- no_error_log +[error] +[alert] +[crit] + + + +=== TEST 2: handshake fails when the trusted store has the wrong CA +--- http_config eval: $::TLSHttpConfig +--- config + location /t { + content_by_lua_block { + local f = assert(io.open("t/cert/test.crt", "r")) + local pem = f:read("*a") + f:close() + + local store, err = load_store_from_pem(pem) + if not store then + ngx.say("failed to load store: ", err) + return + end + + local sock = ngx.socket.tcp() + sock:settimeout(3000) + assert(sock:connect("unix:$TEST_NGINX_HTML_DIR/tls.sock")) + + local ok, err = sock:settrustedstore(store) + if not ok then + ngx.say("failed to settrustedstore: ", err) + return + end + + local sess, err = sock:sslhandshake(nil, "example.com", true) + if sess then + ngx.say("unexpected success") + else + ngx.say("handshake failed: ", err) + end + + sock:close() + } + } +--- request +GET /t +--- response_body_like +^handshake failed: .*certificate verify +--- error_log +lua ssl certificate verify error +--- no_error_log +[alert] +[crit] + + + +=== TEST 3: bad arg type is rejected before any FFI / network work +--- http_config eval: $::TLSHttpConfig +--- config + location /t { + content_by_lua_block { + local sock = ngx.socket.tcp() + sock:settimeout(3000) + assert(sock:connect("unix:$TEST_NGINX_HTML_DIR/tls.sock")) + + local ok, err = sock:settrustedstore("not cdata") + ngx.say("settrustedstore: ", ok, " ", err) + + sock:close() + } + } +--- request +GET /t +--- response_body +settrustedstore: nil bad store arg: cdata expected, got string +--- no_error_log +[error] +[alert] +[crit] + + + +=== TEST 4: settrustedstore on a closed socket returns "closed" +--- http_config eval: $::TLSHttpConfig +--- config + location /t { + content_by_lua_block { + local f = assert(io.open("t/cert/mtls_ca.crt", "r")) + local pem = f:read("*a") + f:close() + + local store = assert(load_store_from_pem(pem)) + + local sock = ngx.socket.tcp() + sock:settimeout(3000) + assert(sock:connect("unix:$TEST_NGINX_HTML_DIR/tls.sock")) + assert(sock:close()) + + local ok, err = sock:settrustedstore(store) + ngx.say("settrustedstore: ", ok, " ", err) + } + } +--- request +GET /t +--- response_body +settrustedstore: nil closed +--- no_error_log +[error] +[alert] +[crit] + + + +=== TEST 5: passing nil clears the trusted store on both sides +--- http_config eval: $::TLSHttpConfig +--- config + lua_ssl_trusted_certificate ../../cert/mtls_ca.crt; + lua_ssl_verify_depth 2; + + location /t { + content_by_lua_block { + -- First set a wrong CA, then clear it. The handshake should + -- then succeed via lua_ssl_trusted_certificate, proving the + -- C-side slot was cleared (not just the lua-side ref). + local f = assert(io.open("t/cert/test.crt", "r")) + local wrong_pem = f:read("*a") + f:close() + + local wrong_store = assert(load_store_from_pem(wrong_pem)) + + local sock = ngx.socket.tcp() + sock:settimeout(3000) + assert(sock:connect("unix:$TEST_NGINX_HTML_DIR/tls.sock")) + + assert(sock:settrustedstore(wrong_store)) + assert(sock:settrustedstore(nil)) + + local sess, err = sock:sslhandshake(nil, "example.com", true) + if not sess then + ngx.say("handshake failed: ", err) + return + end + + ngx.say("handshake ok") + sock:close() + } + } +--- request +GET /t +--- response_body +handshake ok +--- no_error_log +[error] +[alert] +[crit] From 1a92487249ea5d1b8b26e6fedf3ec833e84d3d69 Mon Sep 17 00:00:00 2001 From: lijunlong Date: Sat, 2 May 2026 22:21:28 +0800 Subject: [PATCH 2/2] approved. --- t/socket-tcp-settrustedstore.t | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/t/socket-tcp-settrustedstore.t b/t/socket-tcp-settrustedstore.t index 007a7bfc2..804a27d92 100644 --- a/t/socket-tcp-settrustedstore.t +++ b/t/socket-tcp-settrustedstore.t @@ -155,7 +155,7 @@ __DATA__ --- request GET /t --- response_body_like -^received: HTTP/1\.0 200 OK +^received: HTTP/1\.[01] 200 OK --- no_error_log [error] [alert] @@ -201,7 +201,7 @@ GET /t --- request GET /t --- response_body_like -^handshake failed: .*certificate verify +handshake failed: .*: unable to get local issuer certificate --- error_log lua ssl certificate verify error --- no_error_log