Skip to content

ngx_mruby v2.7.2

ngx_mruby 2.7.2 is a maintenance release of the 2.x line.

It fixes three security issues, each with its own advisory: a use-after-free in Nginx::Upstream#server= and the stream Nginx::Stream::Connection#upstream_server= (GHSA-22jc-w698-vfqw, High); uninitialised worker memory disclosed through an Nginx::Var value that contains a NUL byte (GHSA-gxqc-3p4h-5p36, Medium); and a HEAD request receiving a response body from a handler that writes output (GHSA-8mxf-gvvp-38fq, Medium). Entries 6, 7 and 8 describe them.

Some of its fixes change what an existing configuration, Ruby script or build can observe. Read the first section before you upgrade. Each entry says what you could observe in 2.7.1 (“Before”), what you observe in 2.7.2 (“Now”), why the behavior had to change (“Why it changed”: “The defect” or “The reason”, “Why the old behavior could not be kept” and “What the documentation promised”), which configurations are affected, what to do, and, where the test suite covers the change, the port of its tests. “Not changed by this release” names a related behavior that stays as it was.

1. Nginx::Var raises for a method name that names no variable and for a setter without its value (#589)

Section titled “1. Nginx::Var raises for a method name that names no variable and for a setter without its value (#589)”
  • Before: Nginx::Var treats a call of a method that it does not define as access to the nginx variable of that name (v.http_host reads $http_host), and a method name that ends with = as a setter of the variable named before the = (v.http_host = "a"). Code that builds the name at run time passes it to send. For three kinds of names the accessor read memory outside the name or outside the arguments, so the result depended on that memory:
    • an empty name (v.send("")) read the byte before the name, and usually returned nil;
    • a name made only of = (v.send("=", "x")) read past the end of the name, and returned nil or ended the worker process;
    • a setter called without its value (v.send("http_host=")) read a value from outside its arguments. It could end the worker process, or set the variable to a value that the code did not pass.
  • Now: an empty name and a name made only of = raise NoMethodError (undefined method '' for Nginx::Var, undefined method '=' for Nginx::Var). A setter called without its value raises ArgumentError (wrong number of arguments (given 0, expected 1) for Nginx::Var#http_host=) and leaves the variable as it was. The accessor checks the name and the arguments before it reads them.
  • Why it changed:
    • The defect: the accessor read the byte before an empty name, ran strtok past the end of its copy of a name made only of =, and read the first element of an empty argument list for a setter without its value, as “Before” says (#589).
    • Why the old behavior could not be kept: the results of these calls came from memory outside the name and the arguments, so the same call could return nil once and end the worker process another time. Such a name names no variable, and a setter has no value to set without its argument, so there is no result to keep. The calls raise what Ruby raises for a method that the receiver does not have (NoMethodError) and for a call with fewer arguments than the method takes (ArgumentError).
    • What the documentation promised: the 2.7.1 documentation says that the accessor is called for Nginx::Var#VARNAME and Nginx::Var#VARNAME= and sets “the 2nd arg” to the variable when the name ends with = (Nginx::Var#method_missing of 2.7.1). It says nothing about a name without a variable name in it, or about a setter without its value.
  • Affected: code that calls Nginx::Var with a name that it builds at run time (send, public_send, method_missing) when the name can be empty or made only of =, and code that calls a setter without a value.
  • What to do: check the name before the call, or rescue NoMethodError where the name can be empty. Pass a value to each setter.
  • Not changed by this release: the accessor reads and sets the same variables as before, also for names with uppercase letters, which name the variable in lowercase. A setter called with more than one value still uses the first one, and a name like =foo= still sets $foo.
  • Tests: port 18153 (test/t/cases/var_input.rb).

2. Nginx::Var#set leaves the String or Symbol of the name unchanged (#589)

Section titled “2. Nginx::Var#set leaves the String or Symbol of the name unchanged (#589)”
  • Before: Nginx::Var#set(name, value) looked the variable up by the lowercased name, and lowercased the bytes of name in place to get it. After the call, the String passed as name was in lowercase, also when it was frozen, and so was a String that shared its bytes, such as a copy that dup made of a long String. For a Symbol with a long name, set lowercased the name that the worker keeps for the Symbol, so to_s of that Symbol returned the name in lowercase in every later request of the worker. Long means more than 27 bytes on a 64-bit build: mruby keeps a shorter String inside the String object, and a copy does not share it.
  • Now: set looks the variable up by a lowercased copy of the name. The String or Symbol passed as name stays as it was. set sets the same variable and returns the same value as before.
  • Why it changed:
    • The defect: set wrote into the String passed as name, and through it into frozen Strings, into the Strings that shared its bytes and into the names of Symbols, as “Before” says (#589).
    • Why the old behavior could not be kept: a frozen String must not change, a String that shares its bytes is another object of the code, and the name of a Symbol belongs to every request of the worker. The lookup needs the name in lowercase, so it takes a copy.
    • What the documentation promised: the 2.7.1 documentation says that set sets the value to the nginx variable (Nginx::Var#set of 2.7.1). It does not say that set changes its arguments.
  • Affected: code that uses the String or Symbol that it passed to set after the call, and expects it in lowercase.
  • What to do: nothing, unless the code relied on set lowercasing the name. Such code calls downcase on the name itself.
  • Tests: port 18153 (test/t/cases/var_input.rb).

3. Redis is in the default build again, and mruby-redis builds hiredis v1.5.0 on every platform without stopping on hiredis warnings (#604, matsumotory/mruby-redis#108)

Section titled “3. Redis is in the default build again, and mruby-redis builds hiredis v1.5.0 on every platform without stopping on hiredis warnings (#604, matsumotory/mruby-redis#108)”
  • Before: the default build_config.rb of 2.7.1 leaves matsumotory/mruby-redis out (entry 1 of the 2.7.1 notes), so a binary built with it has no Redis class, and code that refers to Redis raises NameError (uninitialized constant Redis). A build that enables the gem, by uncommenting conf.gem github: 'matsumotory/mruby-redis' or with its own build_config.rb, gets hiredis from the build script of the gem. When the build directory of the gem had no hiredis directory yet, the script cloned hiredis from the head of its default branch, and on macOS checked out v0.13.3. The script compiled hiredis with the C flags of the mruby build, and the Makefile of hiredis 1.2.0 and later adds -Werror, so a new warning in hiredis stopped the build. With hiredis 1.4.0 and later and the gcc toolchain of mruby, the build stopped at ffc.h:330:5: error: "FFC_DEBUG" is not defined, evaluates to 0 [-Werror=undef].
  • Now: the default build_config.rb includes matsumotory/mruby-redis again (#604), as the default build_config.rb of 2.7.0 and earlier did. A binary built with it has the Redis class, and its build clones the gem and hiredis from GitHub. The build script of the gem clones the hiredis tag v1.5.0 into hiredis-v1.5.0 on every platform, macOS included. It stops, with a message that names both commits, when the clone is not at commit 5bb7f030f6631b243438cf0fdd3be84352b905bb, the commit of that tag. It builds hiredis with USE_WERROR=0, so the build prints the warnings of hiredis and goes on. It builds hiredis with make install only, so hiredis-test is no longer built, and musl systems such as Alpine no longer need linux-headers. A hiredis directory left by an earlier build is not reused. The change of hiredis is in the gem: ngx_mruby 2.x commits no gem lock, so the build clones the gem at the head of its master branch. Every build that clones the gem after the merge of matsumotory/mruby-redis#108 gets the change, a build of 2.7.1 included. mruby does not update a gem that it cloned before, and it checks a gem out at the commit that build_config.rb.lock records, a file that mruby writes at the end of each build and that git ignores on 2.x: a source tree that built mruby-redis before the merge keeps that commit of the gem, and its hiredis, until both its mruby build directory mruby/build and build_config.rb.lock are removed.
  • Why it changed:
    • The defect: the unpinned clone let every new hiredis commit reach every new build that enables the gem, and hiredis 1.4.0 stopped such builds with the FFC_DEBUG error above (#528, matsumotory/mruby-redis#108). 2.7.1 left the gem out of the default build because of this defect (entry 1 of the 2.7.1 notes).
    • Why the old behavior could not be kept: a build that follows the default branch of hiredis can break with any hiredis commit, which is why 2.7.1 left the gem out of the default build. A pinned release with a checked commit gives every build the same hiredis. -Werror had to go as well: with ngx_mruby’s sanitizer flags (-fsanitize=address,undefined -fno-omit-frame-pointer), gcc 13 to 15 stop the build of hiredis v1.5.0 at sds.c:525 with null format string [-Werror=format-truncation=], so a newer compiler would break the pinned build too. USE_WERROR=0 is the switch that hiredis 1.3.0 and later provide for builds that do not want -Werror (redis/hiredis#1210). v1.5.0 builds and passes the tests of the gem on Linux and macOS, so macOS no longer needs v0.13.3. With hiredis pinned, the reason for leaving the gem out of the default build no longer holds: entry 1 of the 2.7.1 notes kept the gem out of the default list “until mruby-redis pins hiredis”. Leaving it out longer would keep Redis from the scripts that used it with the default build of 2.7.0 and earlier, the examples in the README and in docs/use_case and the client of the bundled auto-ssl mrbgem among them.
    • What the documentation promised: entry 1 of the 2.7.1 notes says that the build of the gem “can fail while hiredis has the problem that #528 describes”. The 2.7.1 documentation says to uncomment the line of the gem in build_config.rb (built-in mrbgems of 2.7.1). It says that the build of the gem “clones the hiredis library from an unpinned branch, which broke the build of ngx_mruby” (docs/install of 2.7.1), and does not name a version of hiredis. It also says that the Redis class “is not in the default build since 2.7.1”. The 2.7.0 documentation listed mruby-redis among “the default mrbgems” (docs/install of 2.7.0) and Redis among the classes that can be used “with default build configuration” (built-in mrbgems of 2.7.0).
  • Affected: the default builds of 2.7.2. Their binaries have the Redis class again, so code that refers to Redis no longer raises NameError. Their build clones three more repositories from GitHub: matsumotory/mruby-redis, and matsumotory/mruby-pointer, which mruby-redis depends on and which adds no class, at the heads of their master branches, and the hiredis tag v1.5.0. It links hiredis statically into nginx or into the dynamic module, which grows by about 190 KB: a stripped static nginx built with the release options of the performance comparison of next (test/build_release.sh there), on Linux aarch64 with gcc 11.4, grows from 3.88 MB to 4.08 MB. Only a script that calls Redis needs a Redis server. The change of hiredis affects the default builds of 2.7.2, builds of 2.7.1 that enable mruby-redis, and the default builds of 2.7.0 and earlier, whose default build_config.rb includes the gem. When they clone the gem after the merge, they get hiredis v1.5.0 instead of the head of the default branch of hiredis, and on macOS instead of v0.13.3. mruby-redis#108 tested the gem with mruby 3.3.0, which 2.6.0 to 2.7.2 bundle, and with mruby 4.0.0; 2.5.0 and earlier bundle older mruby releases. The gem links hiredis statically, so a binary built before keeps its hiredis until it is built again after mruby/build and build_config.rb.lock are removed. A build of 2.7.1 with the default build_config.rb is not affected, because it does not include the gem.
  • What to do: nothing for the default build of 2.7.2, unless this source tree built mruby-redis before, in a build that enabled it or in the default build of 2.7.0 or earlier: then remove mruby/build and build_config.rb.lock first. Removing mruby/build alone is not enough: the next build clones the gem again, but checks it out at the commit that the lock recorded, with the unpinned hiredis clone. Then build as before: the warning of entry 1 of the 2.7.1 notes (“The build of that gem can fail …”) no longer applies. To build without the gem, as the default build of 2.7.1 did, comment out conf.gem github: 'matsumotory/mruby-redis' in build_config.rb. To build with another hiredis release, set the environment variable MRUBY_REDIS_HIREDIS_VERSION to its tag when you build, or set ENV['MRUBY_REDIS_HIREDIS_VERSION'] in build_config.rb (see the README of mruby-redis). The build does not check the commit of another tag. mruby-redis#108 also built and tested v1.4.1, on its earlier head 2cc63c1 with gcc and mruby 3.3.0.
  • Not changed by this release: as in 2.7.0 and in builds of 2.7.1 that enable the gem, each call of Redis waits in the worker until the Redis server answers, because the gem uses the blocking API of hiredis, so the worker serves no other request meanwhile. Redis#ttl, whose declaration mruby-redis#108 changed for mruby 4.0.0, behaves as before with the bundled mruby 3.3.0.
  • Tests: the test suite of ngx_mruby has no test that calls Redis, and it starts no Redis server. Its default build builds the gem since #604, so every CI cell builds the gem and links hiredis, the cell with the sanitizers included, and the test nginx has the Redis class. #604 quotes the suite on master with the static and the dynamic module and with the sanitizers, the symbols of the gem and of hiredis in the nginx binary, and a request that calls Redis without and with a running Redis server. mruby-redis#108 quotes the suite of ngx_mruby master (03aad7031) and next (75bfa6ff4), each built with the gem enabled at the head of that pull request, and both pass. The CI of the gem runs its tests on Ubuntu 22.04 and on Ubuntu 24.04, the latter with the sanitizer settings of ngx_mruby’s CI.

4. Redis.connect_set_udptr and Redis.new without arguments keep the connection in the mrb_state, not in mrb->ud, and Redis.new with one argument raises ArgumentError (matsumotory/mruby-redis#109)

Section titled “4. Redis.connect_set_udptr and Redis.new without arguments keep the connection in the mrb_state, not in mrb->ud, and Redis.new with one argument raises ArgumentError (matsumotory/mruby-redis#109)”
  • Before: in a build that includes mruby-redis, Redis.connect_set_udptr host, port[, timeout] (also named Redis.connect_set_raw) connected to the server and stored the connection through matsumotory/mruby-pointer, which keeps its own struct in mrb->ud, the user-data pointer of the mrb_state. Redis.new without arguments read the connection from there. ngx_mruby keeps its own data in mrb->ud: the stream module its context, in all the code of a stream {} block, and the HTTP module its server configuration while mruby_server_context_handler_code, mruby_ssl_handshake_handler and mruby_ssl_handshake_handler_code run. In that code the two methods wrote the connection into ngx_mruby’s data and read it from there. In the other code of an http {} block, mrb->ud is NULL: Redis.connect_set_udptr raised RuntimeError (“can not set, mrb ud pointer was not initialized”) after it had connected, and left that connection open, and Redis.new without arguments raised RuntimeError (“can not get. mrb ud pointer was not initialized”). Where a connection was stored, every Redis.new without arguments returned an object with that same connection, and each of these objects closed it when it was collected. Redis.new with one argument used a value that the call had not set: for a new object, a NULL pointer, which ended the worker.
  • Now: the gem keeps the connection in the mrb_state, in an instance variable of the Redis class that Ruby code cannot reach, and no longer reads or writes mrb->ud. The two methods work the same way in all the code of an http {} or a stream {} block. Redis.connect_set_udptr keeps the connection. When it connects, it closes a connection that it kept before and that no Redis.new has taken. A failed Redis.connect_set_udptr raises Redis::ConnectionError, as before; it now also closes the connection that failed, and a connection kept before stays kept. Redis.new without arguments takes the kept connection, so that one object owns it: a second Redis.new without arguments raises an error until Redis.connect_set_udptr is called again, and so does a call when no connection is kept. The error is Redis::ConnectionError, a subclass of RuntimeError, with the message “no connection kept: call Redis.connect_set_udptr first”. Redis.new with one argument raises ArgumentError with the message “wrong number of arguments (given 1, expected 0, 2 or 3)”. The change is in the gem: ngx_mruby 2.x commits no gem lock, so every build that clones the gem after the merge of matsumotory/mruby-redis#109 gets it, a build of 2.7.1 included. A source tree that built the gem before keeps its checkout of the gem until mruby/build and build_config.rb.lock are removed (see entry 3).
  • Why it changed:
    • The defect: mruby leaves mrb->ud to the application that embeds it, and ngx_mruby uses it. The two methods used ngx_mruby’s data as the struct of mruby-pointer in the code named above, and raised in the rest of an http {} block. Redis.new with one argument used a value that the call had not set (matsumotory/mruby-redis#109).
    • Why the old behavior could not be kept: mrb->ud has no place for the data of a gem. While the gem kept its connection there, the two methods either raised, where ngx_mruby leaves mrb->ud NULL, or used ngx_mruby’s data, where it does not. Handing one connection to several objects made each of them close it, so the connection was closed more than once; with one owner, it is closed once. A call with one argument has no host and port to connect to, and its old result came from a pointer that the call had not set, so there is no result to keep. It raises what Ruby raises for a wrong number of arguments (ArgumentError).
    • What the documentation promised: before this change, neither the README of mruby-redis nor the documentation of ngx_mruby described the two methods. The README of mruby-pointer shows them in a C program that calls mrb_udptr_init and mrb_udptr_copy to share a connection between two mrb_states; ngx_mruby calls neither.
  • Affected: scripts that call Redis.connect_set_udptr, Redis.connect_set_raw, or Redis.new without arguments or with one argument, in the default build of 2.7.2, which includes mruby-redis again (entry 3), and in every other build that includes the gem: builds of 2.7.1 that enable it, and the default builds of 2.7.0 and earlier. Scripts that create every Redis object with Redis.new host, port[, timeout] are not affected.
  • What to do: call Redis.connect_set_udptr again before each further Redis.new without arguments, or keep the object that Redis.new returned and use it again, or call Redis.new host, port instead. Redis::ConnectionError is a subclass of RuntimeError, so code that rescues RuntimeError around Redis.new without arguments still catches the new error. Change only code that compares the class of the error with RuntimeError exactly, or its message. Call Redis.connect_set_udptr in code that runs in the worker (mruby_init_worker_code, mruby_stream_init_worker_code or a handler): a connection kept by mruby_init_code, mruby_server_context_handler_code, mruby_stream_init_code or mruby_stream_server_context_code is copied into every worker. If this source tree built mruby-redis before, remove mruby/build and build_config.rb.lock first.
  • Not changed by this release: Redis.new host, port[, timeout] and the other methods of Redis behave as before. The build of mruby-redis still clones matsumotory/mruby-pointer, which the gem no longer calls.
  • Tests: the test suite of ngx_mruby has no test that uses Redis. mruby-redis#109 adds tests of Redis.connect_set_udptr, Redis.connect_set_raw, Redis.new without arguments and Redis.new with one argument to the gem, among them tests with mrb->ud set the way a host application sets it. It quotes the suite of ngx_mruby master and next, each built with the gem enabled at the head of that pull request, and both pass.

5. A content handler that writes output and then sets a status below 300 answers with that status (#594)

Section titled “5. A content handler that writes output and then sets a status below 300 answers with that status (#594)”
  • Before: an mruby_content_handler* on the main request that wrote output with Nginx.rputs or Nginx.echo and then called Nginx.return, Nginx.send_header or Nginx.status_code= with a status below 300 other than 200, 201 and 204, for example Nginx::HTTP_ACCEPTED, or with 0, sent no response. nginx closed an HTTP/1.0 connection without sending a byte and kept an HTTP/1.1 keep-alive connection open without a response until keepalive_timeout (75 s by default), so the client waited for its own timeout. 2.7.1 lists this under “Known limitations”.
  • Now: the request gets the output with the status that the handler set, with Content-Length of the output, like nginx’s own return 202 "text"; without its Content-Type (ngx_mruby sends none, as for 200 after output). This holds also when the handler ends so after Nginx::Async::HTTP.sub_request resumed it. Nginx.rputs "queued" followed by Nginx.return Nginx::HTTP_ACCEPTED answers 202 Accepted with the body queued. A status below 200 goes out the same way, as the number without a reason phrase (HTTP/1.1 100 ), as nginx sends it for its own return 100 "text";, although HTTP does not allow a final response with such a status. Nginx.return 0 sets no status, and the output gets 200, as output without a status does. A keep-alive connection stays open for the next request, as after a 200 with output.
  • Why it changed:
    • The defect: Nginx.return after the output marked the end of the output, and ngx_mruby then handed the status to nginx without sending the output. nginx sends a response of its own for 201, 204 and the statuses from 300 up, but for any other status below 300 it ends the request without sending anything, so the request had no response. #566 fixed this for handlers without output and left this case open, as its review records; this release fixes it (#594).
    • Why the old behavior could not be kept: keeping it would leave these requests without a response. A content handler that writes output and then sets 200 already answers with its output; the fix answers the other statuses that nginx does not answer by itself in the same way, with the status the handler set. No request that got a response in 2.7.1 gets a different one.
    • What the documentation promised: the 2.7.1 documentation says that Nginx.rputs “Puts response text” and that Nginx.return “Returns nginx status code to send back HTTP status to the client” (Nginx.return of 2.7.1), and the 2.7.1 release notes list this case under “Known limitations” (v2.7.1.md). The documentation of the methods did not say what a content handler answers after output with such a status.
  • Affected: mruby_content_handler* handlers that write output and then set a status below 300 other than 200, 201 and 204, or 0.
  • What to do: nothing for a handler that means to answer its output with that status. A handler that should answer 200 sets Nginx::HTTP_OK.
  • Not changed by this release: after output, Nginx::HTTP_OK answers 200 with the output, and Nginx::HTTP_CREATED, Nginx::HTTP_NO_CONTENT and the statuses from 300 up get nginx’s own response for that status without the output, as in 2.7.1. A status set before the first Nginx.rputs or Nginx.echo still does not apply to the output, which gets 200. Handlers of other phases are not changed: a rewrite handler that writes output and then sets Nginx::HTTP_ACCEPTED still sends no response. Subrequests are not changed either: a subrequest ends without a response body, and its parent answers.
  • Tests: port 18154 (test/conf/conf.d/68-output-then-status.conf, test/t/cases/output_then_status.rb).

6. Nginx::Var copies the whole value, so a value with a NUL byte no longer exposes uninitialized worker memory

Section titled “6. Nginx::Var copies the whole value, so a value with a NUL byte no longer exposes uninitialized worker memory”
  • Before: the Nginx::Var accessor (v.name = value) and Nginx::Var#set(name, value) copied the value into a request-pool buffer with ngx_cpystrn. ngx_cpystrn stops at the first NUL byte in the source, but the length recorded for the variable (vv->len) was the full length of the mruby string. mruby strings are byte strings and can hold a NUL, for example a value taken from client input such as the request body or a decoded argument. The bytes after the NUL were therefore never written, while the variable’s length still spanned the whole buffer. Reading the variable back (Nginx::Var#name, or nginx emitting $name) returned vv->len bytes, so the untouched bytes after the NUL came from uninitialized request-pool memory. Sending that variable to the client (Nginx.rputs/Nginx.echo of the variable, add_header $name, a value placed in a proxied request, or a logged value) then sent those uninitialized bytes out of the worker. On a worker that had already served requests, that memory could hold fragments of earlier data and heap pointers.
  • Now: ngx_mruby copies the whole value with ngx_memcpy for its full length, so a value that holds a NUL is stored byte for byte and the memory after the NUL is not exposed. Both the accessor (ngx_mrb_var_set_vector) and Nginx::Var#set (ngx_mrb_var_set_func) are changed.
  • Why it changed:
    • The defect: ngx_cpystrn stopped the copy at the first NUL while the variable kept the full length, so a variable set from a value with a NUL read uninitialized request-pool memory past the NUL, as “Before” says.
    • Why the old behavior could not be kept: the bytes past the NUL were never written, so the value read back was not the value that was set, and on a reused worker it could carry fragments of earlier data and heap pointers out to the client. A value that holds a NUL has to be stored as it is, and reading memory past it gives no result worth keeping.
    • What the documentation promised: the 2.7.1 documentation says that the accessor and set set the value to the nginx variable (Nginx::Var#method_missing of 2.7.1, Nginx::Var#set of 2.7.1). It does not say that a value is cut off at a NUL byte, or that reading the variable back can return other memory.
  • Affected: a configuration that stores a value that can hold a NUL byte, derived from client input, into an nginx variable with Nginx::Var#set or the v.name = accessor, and then sends that variable back toward a client.
  • What to do: nothing; the value is stored in full. On an earlier release that you cannot upgrade, remove the NUL bytes before you store the value, for example value.delete("\x00").
  • Not changed by this release: a value that holds no NUL byte is stored and read back as before.
  • Tests: port 18153 (test/t/cases/var_input.rb, /set_nul_value and /set_nul_value_via_set).

7. A HEAD request to a content handler that writes output receives only the headers

Section titled “7. A HEAD request to a content handler that writes output receives only the headers”
  • Before: for a HEAD request, nginx sets r->header_only, and the response should carry the headers without a body. But when the content handler of the main request wrote output with Nginx.rputs or Nginx.echo, ngx_mrb_finalize_rputs (src/http/ngx_http_mruby_core.c) called ngx_http_send_header and then passed the output chain to ngx_http_output_filter without checking r->header_only. A HEAD request to such a location therefore received the body after the headers. On a keep-alive connection, the extra body bytes shifted where the client read the next response, so the responses on the connection no longer lined up with the requests. When a reverse proxy in front reused one upstream connection for more than one client, it could pair a response with the wrong request (a response-queue desynchronization, the HTTP request/response smuggling class). A client triggered it by sending a HEAD request to a location whose mruby content handler writes output.
  • Now: a HEAD request receives no body. ngx_mrb_finalize_rputs checks r->header_only before ngx_http_output_filter, as nginx’s own content handlers do. The headers, Content-Length among them, are unchanged, so the HEAD response still reports the length the body would have had. This holds for the “output and then a status” path of #594 as well: a HEAD request to a handler that sets 202 after output receives the headers of 202 with no body.
  • Why it changed:
    • The defect: ngx_mrb_finalize_rputs passed the output chain to ngx_http_output_filter without checking r->header_only, so a content handler that wrote output answered a HEAD request with a body, as “Before” says.
    • Why the old behavior could not be kept: a HEAD response must carry no body, and the body on a keep-alive connection shifted the framing of the responses that followed, which a front proxy that reuses an upstream connection across clients could pair with the wrong request. nginx’s own content handlers send no body for HEAD, and the mruby content handler now does the same.
    • What the documentation promised: the 2.7.1 documentation says that Nginx.rputs “Puts response text” (the methods of 2.7.1). It does not say that a HEAD request receives that output, and HTTP does not allow a body in the response to a HEAD request.
  • Affected: a location whose main-request handler writes output with Nginx.rputs or Nginx.echo and that receives a HEAD request. The fix is in the shared output path (ngx_mrb_finalize_rputs), so a content, rewrite or access handler that writes output sends the headers only for a HEAD request.
  • What to do: nothing.
  • Not changed by this release: a GET request behaves as before.
  • Tests: port 18137 (test/t/cases/head_output.rb) and the HEAD cases added to port 18154 (test/t/cases/output_then_status.rb).

8. Nginx::Upstream#server= and Nginx::Stream::Connection#upstream_server= keep the peer valid for later requests and connections (a use-after-free fix)

Section titled “8. Nginx::Upstream#server= and Nginx::Stream::Connection#upstream_server= keep the peer valid for later requests and connections (a use-after-free fix)”
  • Before: Nginx::Upstream#server= (src/http/ngx_http_mruby_upstream.c) copied the new server name into memory from the request pool (ngx_palloc(r->pool, ...)), resolved it with ngx_parse_url(r->pool, ...), and stored the result into the upstream’s round-robin peer. The peer lives for the worker’s lifetime (the configuration pool), and every request that uses the upstream shares it. Nginx::Stream::Connection#upstream_server= (src/stream/ngx_stream_mruby_connection.c) did the same on the stream side, storing into the peer a Ruby string (which mruby’s garbage collector can free or move) or connection-pool memory. When the request or connection that set the server ended, its pool was freed, but the shared peer still pointed into it. A later request or connection that used the same upstream without calling the setter (a proxy_pass to the upstream from another location, a read of Nginx::Upstream#server, or a read on a later connection) then read freed memory: the address nginx connected to, and the name it logged, came from a freed allocation. This is a use-after-free: it could crash the worker, send the upstream connection to the wrong address, or disclose freed memory in a log.
  • Now: ngx_mruby copies the name and the resolved sockaddr into worker-lifetime storage that belongs to the peer (one per peer, allocated once in the cycle pool and reused), so the peer no longer points at freed memory. A repeated setter call reuses the one store instead of growing it; the store holds 1 to 255 bytes, and the fix also checks that the destination resolved. Both Nginx::Upstream#server= (HTTP) and Nginx::Stream::Connection#upstream_server= (stream) are changed.
  • Why it changed:
    • The defect: the peer, which lives for the worker’s lifetime, pointed at request-pool or connection-pool memory, and on the stream side at an mruby string that the garbage collector can move, so a later use of the upstream read memory that had been freed, as “Before” says.
    • Why the old behavior could not be kept: the peer outlives the request or connection that set it, so it cannot hold a pointer into their pools. The name and the address have to live as long as the peer, so the fix copies them into storage that lives for the worker’s lifetime.
    • What the documentation promised: the documented usage sets the server in mruby_rewrite_handler_code and proxy_passes in the same request. Nothing said that a later request that does not set the server reads a valid peer, or that reading Nginx::Upstream#server after the setting request ended returns valid memory.
  • Affected: a configuration that uses Nginx::Upstream#server= (HTTP) or Nginx::Stream::Connection#upstream_server= (stream).
  • What to do: nothing.
  • Not changed by this release: setting the server on each request or connection that uses the upstream still works, and the value read back is the value that was set.
  • Not changed by this release: an upstream with a shared-memory zone keeps its peer in shared memory that every worker shares, so pointing the peer at any one worker’s memory (the per-worker store of this fix, or the request pool of the old code) is invalid across workers. This release corrects the case without a zone. An upstream with a zone needs the name and the sockaddr in the zone’s slab, which is outside the scope of this release; the old code was worse here, an existing problem.
  • Tests: HTTP on port 18139 (test/t/cases/upstream_server.rb), stream on ports 12364 and 12365 (test/t/cases/stream_upstream_server.rb).

None.

  • When nginx has no value for a variable that Nginx::Var reads, for example $realpath_root in a location whose root does not exist, the line that Nginx::Var writes to error.log (<name> is NULL) ends the name of the variable at its last character. Before, the line could carry the bytes that followed the name in memory (#589).
  • test.sh: the HTTP test servers also listen on 18153, for test/t/cases/var_input.rb (#589).
  • test.sh: instead of sleeping for NGINX_HEATTIME seconds before the first request, test.sh waits until the test nginx accepts connections on every port of the listen directives in test/conf/nginx.conf and test/conf/conf.d/*.conf, and prints how long that took. A CI cell under valgrind had failed with Errno::ECONNREFUSED, because nginx was still reading its configuration when the 10 seconds of the sleep had passed (#564). NGINX_HEATTIME is now the upper bound of the wait, 60 seconds by default; it was the length of the sleep, 2 seconds by default and 10 in CI, and CI no longer sets it. The run fails, and names the ports that still refuse connections, when nginx does not listen within the bound or ends before it listens. Before it starts nginx, test.sh also waits until no process accepts connections on those ports. “Waiting for nginx to listen” in docs/test/README.md describes the wait (#593).
  • CI: three workflows check the supply chain (“Supply chain checks” in docs/test/README.md). zizmor.yml runs the static analyzer zizmor on pull requests and pushes that change .github/workflows/, .github/dependabot.yml or .github/zizmor.yml, and fails on any finding. scorecard.yml runs OpenSSF Scorecard on pushes to master and weekly, and publishes the results to the Scorecard API and to code scanning. gem-heads.yml reports every Monday, for each conf.gem github: line of build_config.rb, the head of the branch that a build clones, and whether it moved since the previous report. mirror-v2x.yml asks for contents: write in its job instead of the whole workflow, and Dependabot proposes a new release of an action only when it is 7 days old (#599).
  • CI: a pull request that changes an input of the measured binary (src/, mrbgems/, mruby/, dependence/, the top-level build files, nginx_version), the comparison or the workflow runs the new perf job. It builds the base and the pull request as release builds and compares the instructions that nginx executes per request with callgrind (test/perf/, test/build_release.sh, and the configurations, scenarios and mock LLM upstream in test/soak/, taken from the next branch). ci-ok does not need the job yet. Locally, sh test/perf/compare.sh BASE_DIR (Linux with valgrind) builds in build_perf/ and listens on 12370-12372. “Performance comparison with callgrind” in docs/test/README.md describes it (#601).
  • CI: the “Check for sanitizer reports” step of the sanitizer cell also reads build/nginx/logs/stderr.log, the other *.log files of build/nginx/logs/ and the logs of the second nginx under build/nginx/second_instance/, which hold the reports of the processes that the tests start. Before, it read only build/nginx/logs/error.log (#601).
  • Tests for directives and Ruby methods that had none. They check that nginx -t rejects mruby_output_filter and mruby_output_filter_code with their message, and that mruby_exit_worker_code and mruby_stream_exit_worker_code run when nginx stops. They check what these methods return or write to error.log: Nginx.module_version, Nginx.nginx_version, Nginx.server_version, Nginx::Request#content_type, #content_type=, #method=, #scheme and #document_root, Nginx::SSL.log, the methods of the Rack logger, Nginx::Utils.escape and .encode_parameters, the methods of Nginx::Stream::Connection that read the addresses of the session, #upstream_server, Nginx::Stream.errlogger and Nginx::Stream.module_name. The HTTP test servers also listen on 18155 to 18157 and the stream test servers on 12363. test/t/cases/config_hooks.rb starts an nginx of its own on 18158 and stops it with nginx -s quit (#603).
  • test.sh: the HTTP test servers also listen on 18154, for test/t/cases/output_then_status.rb (#594).

Measured on 2026-10-29 with the callgrind comparison of master (test/perf/compare.sh at 6b27f08da, see “Performance comparison with callgrind” in docs/test/README.md): release builds of both trees with nginx 1.31.6, Ubuntu 22.04 in a container on aarch64 (gcc 11.4, valgrind 3.18.1), 20000 requests per scenario after 2000 warmup requests. Base: the tag v2.7.1 (tree 0e6185b02799, src/ tree 8b6857bd850e); head: master at 6b27f08da (tree db916ab4a26f, src/ tree 9f6fd46d1a5c). Both builds have the same 21 third-party mrbgems at the same commits; the head also has matsumotory/mruby-redis and matsumotory/mruby-pointer, which the default build includes again (entry 3). Instructions that nginx executes per request, without mruby’s garbage collector:

Scenario 2.7.1 2.7.2 Change
hello 11172 11214 +0.38%
headers 34511 34574 +0.18%
var 19952 19998 +0.23%
filter 13350 13426 +0.57%
sleep 14892 15012 +0.80%
sub_request 47551 47782 +0.49%
file 13215 13319 +0.79%
proxy_plain_2k 22099 22099 +0.00%
proxy_plain_64k 22414 22415 +0.00%
proxy_stream_plain_50 53736 53968 +0.43%
auth 46142 47166 +2.22%
route_json_2k 106227 106944 +0.67%
ruby_call_1 10677 10685 +0.08%
ruby_call_10 35597 35712 +0.32%

Every scenario stays below the 3% at which the comparison warns. The largest change, relative and absolute, is in auth: +2.22%, or 1024 instructions per request. In the comparison of #604 with its base, which the perf job of CI made on its x86_64 runner (ubuntu-22.04, base 68032bb6a), auth was +3.71% and sleep +3.04%. Those numbers come from another machine and another base than the table above, so the two are not compared with each other. #604 traced almost all of that increase to mrb_vm_const_get: with mruby-redis, Object has 119 constants instead of 118 in a table of 128 slots on the bundled mruby 3.3.0, so constant lookups probe longer. The owner accepted that difference on #604. Of the changes since 2.7.1, only #589 changes code on the request path: it checks the name and the arguments of Nginx::Var before it reads them, and Nginx::Var#set takes a lowercased copy of the name from the request pool (ngx_pnalloc) instead of lowercasing the name in place. Neither change adds a blocking wait.

Supported nginx versions: the nginx mainline and stable lines (see README.md); CI builds nginx 1.31.6, 1.30.5, 1.28.3 and 1.26.3.

  • No directive was added, renamed or removed. Rebuild the module with this release the way you built 2.7.1, and replace the binary or the dynamic module as usual.

Check these before upgrading. Each line is the “What to do” of its entry above.

  • Nginx::Var with a name built at run time: check the name before the call, or rescue NoMethodError where the name can be empty, and pass a value to each setter (entry 1).
  • Nginx::Var#set: nothing, unless the code relied on set lowercasing the name; such code calls downcase on the name itself (entry 2).
  • mruby-redis, which the default build includes again: nothing, unless this source tree built it before; then remove mruby/build and build_config.rb.lock first. The build gets hiredis v1.5.0, and the warning of entry 1 of the 2.7.1 notes no longer applies. To build without the gem, comment out its line in build_config.rb. Set the environment variable MRUBY_REDIS_HIREDIS_VERSION to build another hiredis tag (entry 3).
  • Redis.connect_set_udptr and Redis.new without arguments of mruby-redis: call Redis.connect_set_udptr again before each further Redis.new without arguments, keep the object that Redis.new returned, or call Redis.new host, port instead. rescue RuntimeError still catches the new Redis::ConnectionError, a subclass of it. Call Redis.connect_set_udptr in code that runs in the worker. If this source tree built mruby-redis before, remove mruby/build and build_config.rb.lock first (entry 4).
  • A content handler that writes output and then sets a status below 300 other than 200, 201 and 204: nothing for a handler that means to answer its output with that status; a handler that should answer 200 sets Nginx::HTTP_OK (entry 5).
  • A value that can hold a NUL byte, stored into an nginx variable with Nginx::Var#set or the v.name = accessor: nothing; the value is stored in full (entry 6).
  • A HEAD request to a content handler that writes output with Nginx.rputs or Nginx.echo: nothing (entry 7).
  • Nginx::Upstream#server= or Nginx::Stream::Connection#upstream_server=: nothing (entry 8).