From 64617e29d609ae809459b409e8f03f9b695eeb29 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). A partially invalid string is not an error: OpenSSL's parsers drop unknown tokens and succeed if any token matches, so the surviving subset is applied with no fallback to the default list (covered by TEST 15). Requires ngx_http_lua_ffi_ssl_set_ciphers() from lua-nginx-module. --- lib/ngx/ssl/clienthello.lua | 35 ++++ lib/ngx/ssl/clienthello.md | 47 +++++ t/ssl-client-hello.t | 407 ++++++++++++++++++++++++++++++++++++ 3 files changed, 489 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..c19b86730 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,52 @@ 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. + +Note that a partially invalid string is not an error: OpenSSL's parsers are lenient, unknown tokens in either string are silently dropped and the call succeeds as long as at least one token matches, in which case the surviving subset is applied with no fallback to the default list. The failure path above only covers strings OpenSSL rejects outright. + +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..a7b6d606f 100644 --- a/t/ssl-client-hello.t +++ b/t/ssl-client-hello.t @@ -1141,3 +1141,410 @@ 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] + + + +=== TEST 15: set_ciphers with a partially invalid cipher list applies the valid subset +OpenSSL's cipher list parser is lenient: unknown tokens are silently dropped +and the call succeeds as long as at least one token matches, so the surviving +subset is applied with no fallback to the default list. +--- 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("TRASH:ECDHE-RSA-AES128-GCM-SHA256:GARBAGE") + 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]