From 0cfd7d35d4cb7a7c7eab0f47087e85fda1fb9ca8 Mon Sep 17 00:00:00 2001 From: Gabriel Clima Date: Thu, 9 Jul 2026 10:59:36 +0000 Subject: [PATCH] feature: clienthello.set_ciphers(). Sets the connection's TLS <= 1.2 cipher list and/or TLS 1.3 ciphersuites from ssl_client_hello_by_lua*, e.g. keyed on the client hello server name. On failure the connection's SSL object is left exactly as it was -- cipher list, ciphersuites, and error queue all untouched -- so the handshake proceeds with the default list inherited from the server block (ssl_ciphers/ssl_conf_command). Requires ngx_http_lua_ffi_ssl_set_ciphers() from lua-nginx-module. --- lib/ngx/ssl/clienthello.lua | 35 +++++ lib/ngx/ssl/clienthello.md | 45 ++++++ t/ssl-client-hello.t | 292 ++++++++++++++++++++++++++++++++++++ 3 files changed, 372 insertions(+) diff --git a/lib/ngx/ssl/clienthello.lua b/lib/ngx/ssl/clienthello.lua index e8f27d935..31a8ff341 100644 --- a/lib/ngx/ssl/clienthello.lua +++ b/lib/ngx/ssl/clienthello.lua @@ -32,6 +32,7 @@ local ngx_lua_ffi_ssl_get_client_hello_ext local ngx_lua_ffi_ssl_set_protocols local ngx_lua_ffi_ssl_get_client_hello_ext_present local ngx_lua_ffi_ssl_get_client_hello_ciphers +local ngx_lua_ffi_ssl_set_ciphers if subsystem == 'http' then @@ -52,6 +53,9 @@ if subsystem == 'http' then int ngx_http_lua_ffi_ssl_get_client_hello_ciphers(ngx_http_request_t *r, unsigned short *ciphers, size_t ciphers_len, char **err); /* Undefined for the stream subsystem */ + int ngx_http_lua_ffi_ssl_set_ciphers(ngx_http_request_t *r, + const char *ciphers, const char *ciphersuites, char **err); + /* Undefined for the stream subsystem */ ]] ngx_lua_ffi_ssl_get_client_hello_server_name = @@ -63,6 +67,7 @@ if subsystem == 'http' then C.ngx_http_lua_ffi_ssl_get_client_hello_ext_present ngx_lua_ffi_ssl_get_client_hello_ciphers = C.ngx_http_lua_ffi_ssl_get_client_hello_ciphers + ngx_lua_ffi_ssl_set_ciphers = C.ngx_http_lua_ffi_ssl_set_ciphers @@ -338,4 +343,34 @@ function _M.set_protocols(protocols) return nil, ffi_str(errmsg[0]) end + +-- return ok, err +-- on failure the connection's cipher list, ciphersuites and error queue +-- are left untouched, so the handshake proceeds on the server defaults +function _M.set_ciphers(ciphers, ciphersuites) + local r = get_request() + if not r then + error("no request found") + end + + if ngx_phase() ~= "ssl_client_hello" then + error("API disabled in the current context") + end + + if ciphers ~= nil and type(ciphers) ~= "string" then + error("ciphers must be a string") + end + + if ciphersuites ~= nil and type(ciphersuites) ~= "string" then + error("ciphersuites must be a string") + end + + local rc = ngx_lua_ffi_ssl_set_ciphers(r, ciphers, ciphersuites, errmsg) + if rc == FFI_OK then + return true + end + + return nil, ffi_str(errmsg[0]) +end + return _M diff --git a/lib/ngx/ssl/clienthello.md b/lib/ngx/ssl/clienthello.md index 18a023a03..cb5827087 100644 --- a/lib/ngx/ssl/clienthello.md +++ b/lib/ngx/ssl/clienthello.md @@ -17,6 +17,7 @@ Table of Contents * [get_client_hello_ext_present](#get_client_hello_ext_present) * [get_client_hello_ext](#get_client_hello_ext) * [set_protocols](#set_protocols) + * [set_ciphers](#set_ciphers) * [Community](#community) * [English Mailing List](#english-mailing-list) * [Chinese Mailing List](#chinese-mailing-list) @@ -294,6 +295,50 @@ Example: `ssl_clt.set_protocols({"TLSv1.1", "TLSv1.2", "TLSv1.3"})` [Back to TOC](#table-of-contents) +set_ciphers +---------------------- +**syntax:** *ok, err = ssl_clt.set_ciphers(ciphers, ciphersuites?)* + +**context:** *ssl_client_hello_by_lua** + +Sets the cipher list used by the current downstream SSL connection, e.g. keyed on the client hello server name. + +The `ciphers` string configures the TLS <= 1.2 cipher list in the OpenSSL cipher list format (see [ciphers(1)](https://docs.openssl.org/master/man1/openssl-ciphers/)), like the `ssl_ciphers` nginx directive. The optional `ciphersuites` string configures the TLS 1.3 ciphersuites, a colon-separated list like `TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256`, like the `ssl_conf_command Ciphersuites` nginx directive. Either argument may be `nil` to leave that part of the configuration unchanged. + +Returns `true` on success, or a `nil` value and a string describing the error otherwise. + +On failure the connection's SSL object is left exactly as it was -- cipher list, ciphersuites, and error queue all untouched -- so the handshake proceeds with whatever the connection already had, which is the default list inherited from the server block (`ssl_ciphers`/`ssl_conf_command`). Both strings are validated before either is applied, because a failed `SSL_set_cipher_list()` would otherwise still install the parsed list on the connection and leave the error queue dirty, aborting the handshake. + +Considering it is meaningless to set the cipher list after the cipher is negotiated, +so this function may only be called in the context of [ssl_client_hello_by_lua*](https://github.com/openresty/lua-nginx-module/#ssl_client_hello_by_lua_block). + +This function requires OpenSSL 1.1.1 or later. + +Example: + +```nginx +# nginx.conf +server { + listen 443 ssl; + server_name test.com; + ssl_client_hello_by_lua_block { + local ssl_clt = require "ngx.ssl.clienthello" + local name = ssl_clt.get_client_hello_server_name() + if name == "legacy.test.com" then + local ok, err = ssl_clt.set_ciphers("HIGH:!aNULL:!MD5") + if not ok then + ngx.log(ngx.ERR, "failed to set ciphers: ", err) + -- the handshake continues on the default ssl_ciphers + end + end + } + ssl_certificate test.crt; + ssl_certificate_key test.key; +} +``` + +[Back to TOC](#table-of-contents) + Community ========= diff --git a/t/ssl-client-hello.t b/t/ssl-client-hello.t index 8c6266e85..cee40ad5d 100644 --- a/t/ssl-client-hello.t +++ b/t/ssl-client-hello.t @@ -1141,3 +1141,295 @@ qr/1: CIPHER \d+, context: ssl_client_hello_by_lua/ [alert] [crit] [error] + + + +=== TEST 12: set_ciphers sets the cipher used by the connection +--- http_config + lua_package_path "$TEST_NGINX_LUA_PACKAGE_PATH"; + + server { + listen 127.0.0.2:$TEST_NGINX_RAND_PORT_1 ssl; + server_name test.com; + ssl_client_hello_by_lua_block { + local ssl_clt = require "ngx.ssl.clienthello" + local ok, err = ssl_clt.set_ciphers("ECDHE-RSA-AES128-GCM-SHA256") + if ok then + ngx.log(ngx.INFO, "set_ciphers: ok") + else + ngx.log(ngx.WARN, "failed to set ciphers: ", err) + end + } + + ssl_protocols TLSv1.2; + ssl_certificate ../../cert/test.crt; + ssl_certificate_key ../../cert/test.key; + + server_tokens off; + location /foo { + default_type 'text/plain'; + content_by_lua_block { + ngx.status = 201 + ngx.say(ngx.var.ssl_cipher) + ngx.exit(201) + } + more_clear_headers Date; + } + } +--- config + server_tokens off; + lua_ssl_trusted_certificate ../../cert/test.crt; + lua_ssl_protocols TLSv1.2; + + location /t { + content_by_lua_block { + do + local sock = ngx.socket.tcp() + + sock:settimeout(3000) + + local ok, err = sock:connect("127.0.0.2", $TEST_NGINX_RAND_PORT_1) + if not ok then + ngx.say("failed to connect: ", err) + return + end + + ngx.say("connected: ", ok) + + local sess, err = sock:sslhandshake(nil, nil, true) + if not sess then + ngx.say("failed to do SSL handshake: ", err) + return + end + + ngx.say("ssl handshake: ", type(sess)) + + local req = "GET /foo HTTP/1.0\r\nHost: test.com\r\nConnection: close\r\n\r\n" + local bytes, err = sock:send(req) + if not bytes then + ngx.say("failed to send http request: ", err) + return + end + + local line, err = sock:receive() + if not line then + ngx.say("failed to receive response status line: ", err) + return + end + + ngx.say("received: ", line) + + while true do + local line, err = sock:receive() + if not line then + break + end + + if line == "" then + local body, err = sock:receive() + if body then + ngx.say("cipher: ", body) + end + break + end + end + + sock:close() + end -- do + -- collectgarbage() + } + } + +--- request +GET /t +--- response_body +connected: 1 +ssl handshake: cdata +received: HTTP/1.1 201 Created +cipher: ECDHE-RSA-AES128-GCM-SHA256 +--- error_log +set_ciphers: ok +--- no_error_log +[alert] +[crit] +[error] + + + +=== TEST 13: set_ciphers with an invalid cipher list fails without breaking the handshake +--- http_config + lua_package_path "$TEST_NGINX_LUA_PACKAGE_PATH"; + + server { + listen 127.0.0.2:$TEST_NGINX_RAND_PORT_1 ssl; + server_name test.com; + ssl_client_hello_by_lua_block { + local ssl_clt = require "ngx.ssl.clienthello" + local ok, err = ssl_clt.set_ciphers("NOT-A-CIPHER") + if not ok then + ngx.log(ngx.WARN, "failed to set ciphers: ", err) + end + } + + ssl_protocols TLSv1.2; + ssl_certificate ../../cert/test.crt; + ssl_certificate_key ../../cert/test.key; + + server_tokens off; + location /foo { + default_type 'text/plain'; + content_by_lua_block {ngx.status = 201 ngx.say("foo") ngx.exit(201)} + more_clear_headers Date; + } + } +--- config + server_tokens off; + lua_ssl_trusted_certificate ../../cert/test.crt; + lua_ssl_protocols TLSv1.2; + + location /t { + content_by_lua_block { + do + local sock = ngx.socket.tcp() + + sock:settimeout(3000) + + local ok, err = sock:connect("127.0.0.2", $TEST_NGINX_RAND_PORT_1) + if not ok then + ngx.say("failed to connect: ", err) + return + end + + ngx.say("connected: ", ok) + + local sess, err = sock:sslhandshake(nil, nil, true) + if not sess then + ngx.say("failed to do SSL handshake: ", err) + return + end + + ngx.say("ssl handshake: ", type(sess)) + + local req = "GET /foo HTTP/1.0\r\nHost: test.com\r\nConnection: close\r\n\r\n" + local bytes, err = sock:send(req) + if not bytes then + ngx.say("failed to send http request: ", err) + return + end + + local line, err = sock:receive() + if not line then + ngx.say("failed to receive response status line: ", err) + return + end + + ngx.say("received: ", line) + + sock:close() + end -- do + -- collectgarbage() + } + } + +--- request +GET /t +--- response_body +connected: 1 +ssl handshake: cdata +received: HTTP/1.1 201 Created +--- error_log eval +qr/failed to set ciphers: SSL_set_cipher_list\(\) failed/ +--- no_error_log +[alert] +[crit] +[error] + + + +=== TEST 14: set_ciphers with invalid TLSv1.3 ciphersuites fails without breaking the handshake +--- http_config + lua_package_path "$TEST_NGINX_LUA_PACKAGE_PATH"; + + server { + listen 127.0.0.2:$TEST_NGINX_RAND_PORT_1 ssl; + server_name test.com; + ssl_client_hello_by_lua_block { + local ssl_clt = require "ngx.ssl.clienthello" + local ok, err = ssl_clt.set_ciphers(nil, "TLS_NOT_A_SUITE") + if not ok then + ngx.log(ngx.WARN, "failed to set ciphers: ", err) + end + } + + ssl_protocols TLSv1.3; + ssl_certificate ../../cert/test.crt; + ssl_certificate_key ../../cert/test.key; + + server_tokens off; + location /foo { + default_type 'text/plain'; + content_by_lua_block {ngx.status = 201 ngx.say("foo") ngx.exit(201)} + more_clear_headers Date; + } + } +--- config + server_tokens off; + lua_ssl_trusted_certificate ../../cert/test.crt; + lua_ssl_protocols TLSv1.3; + + location /t { + content_by_lua_block { + do + local sock = ngx.socket.tcp() + + sock:settimeout(3000) + + local ok, err = sock:connect("127.0.0.2", $TEST_NGINX_RAND_PORT_1) + if not ok then + ngx.say("failed to connect: ", err) + return + end + + ngx.say("connected: ", ok) + + local sess, err = sock:sslhandshake(nil, nil, true) + if not sess then + ngx.say("failed to do SSL handshake: ", err) + return + end + + ngx.say("ssl handshake: ", type(sess)) + + local req = "GET /foo HTTP/1.0\r\nHost: test.com\r\nConnection: close\r\n\r\n" + local bytes, err = sock:send(req) + if not bytes then + ngx.say("failed to send http request: ", err) + return + end + + local line, err = sock:receive() + if not line then + ngx.say("failed to receive response status line: ", err) + return + end + + ngx.say("received: ", line) + + sock:close() + end -- do + -- collectgarbage() + } + } + +--- request +GET /t +--- response_body +connected: 1 +ssl handshake: cdata +received: HTTP/1.1 201 Created +--- error_log eval +qr/failed to set ciphers: SSL_set_ciphersuites\(\) failed/ +--- no_error_log +[alert] +[crit] +[error]