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.
Behavior changes: read before upgrading
Section titled “Behavior changes: read before upgrading”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::Vartreats a call of a method that it does not define as access to the nginx variable of that name (v.http_hostreads$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 tosend. 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 returnednil; - a name made only of
=(v.send("=", "x")) read past the end of the name, and returnednilor 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.
- an empty name (
- Now: an empty name and a name made only of
=raiseNoMethodError(undefined method '' for Nginx::Var,undefined method '=' for Nginx::Var). A setter called without its value raisesArgumentError(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
strtokpast 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
nilonce 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#VARNAMEandNginx::Var#VARNAME=and sets “the 2nd arg” to the variable when the name ends with=(Nginx::Var ). It says nothing about a name without a variable name in it, or about a setter without its value.#method_ missing of 2.7.1
- The defect: the accessor read the byte before an empty name, ran
- Affected: code that calls
Nginx::Varwith 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
NoMethodErrorwhere 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 ofnamein place to get it. After the call, the String passed asnamewas in lowercase, also when it was frozen, and so was a String that shared its bytes, such as a copy thatdupmade of a long String. For a Symbol with a long name,setlowercased the name that the worker keeps for the Symbol, soto_sof 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:
setlooks the variable up by a lowercased copy of the name. The String or Symbol passed asnamestays as it was.setsets the same variable and returns the same value as before. - Why it changed:
- The defect:
setwrote into the String passed asname, 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
setsets the value to the nginx variable (Nginx::Var ). It does not say that#set of 2.7.1 setchanges its arguments.
- The defect:
- Affected: code that uses the String or Symbol that it passed to
setafter the call, and expects it in lowercase. - What to do: nothing, unless the code relied on
setlowercasing the name. Such code callsdowncaseon 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.rbof 2.7.1 leavesmatsumotory/mruby-redisout (entry 1 of the 2.7.1 notes), so a binary built with it has noRedisclass, and code that refers toRedisraisesNameError(uninitialized constant Redis). A build that enables the gem, by uncommentingconf.gem github: 'matsumotory/mruby-redis'or with its ownbuild_config.rb, gets hiredis from the build script of the gem. When the build directory of the gem had nohiredisdirectory 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 atffc.h:330:5: error: "FFC_DEBUG" is not defined, evaluates to 0 [-Werror=undef]. - Now: the default
build_config.rbincludesmatsumotory/mruby-redisagain (#604), as the defaultbuild_config.rbof 2.7.0 and earlier did. A binary built with it has theRedisclass, and its build clones the gem and hiredis from GitHub. The build script of the gem clones the hiredis tagv1.5.0intohiredis-v1.5.0on every platform, macOS included. It stops, with a message that names both commits, when the clone is not at commit5bb7f030f6631b243438cf0fdd3be84352b905bb, the commit of that tag. It builds hiredis withUSE_WERROR=0, so the build prints the warnings of hiredis and goes on. It builds hiredis withmake installonly, sohiredis-testis no longer built, and musl systems such as Alpine no longer needlinux-headers. Ahiredisdirectory 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 itsmasterbranch. 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 thatbuild_config.rb.lockrecords, 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 directorymruby/buildandbuild_config.rb.lockare 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_DEBUGerror 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.
-Werrorhad 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 atsds.c:525withnull format string [-Werror=format-truncation=], so a newer compiler would break the pinned build too.USE_WERROR=0is 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 keepRedisfrom the scripts that used it with the default build of 2.7.0 and earlier, the examples in the README and indocs/use_caseand the client of the bundledauto-sslmrbgem 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 theRedisclass “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) andRedisamong the classes that can be used “with default build configuration” (built-in mrbgems of 2.7.0).
- 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
- Affected: the default builds of 2.7.2. Their binaries have the
Redisclass again, so code that refers toRedisno longer raisesNameError. 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 theirmasterbranches, and the hiredis tagv1.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 ofnext(test/build_release.shthere), on Linux aarch64 with gcc 11.4, grows from 3.88 MB to 4.08 MB. Only a script that callsRedisneeds 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 defaultbuild_config.rbincludes 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 aftermruby/buildandbuild_config.rb.lockare removed. A build of 2.7.1 with the defaultbuild_config.rbis 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/buildandbuild_config.rb.lockfirst. Removingmruby/buildalone 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 outconf.gem github: 'matsumotory/mruby-redis'inbuild_config.rb. To build with another hiredis release, set the environment variableMRUBY_REDIS_HIREDIS_VERSIONto its tag when you build, or setENV['MRUBY_REDIS_HIREDIS_VERSION']inbuild_config.rb(see the README of mruby-redis). The build does not check the commit of another tag. mruby-redis#108 also built and testedv1.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
Rediswaits 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
Redisclass. #604 quotes the suite onmasterwith 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_mrubymaster(03aad7031) andnext(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 namedRedis.connect_set_raw) connected to the server and stored the connection through matsumotory/mruby-pointer, which keeps its own struct inmrb->ud, the user-data pointer of themrb_state.Redis.newwithout arguments read the connection from there. ngx_mruby keeps its own data inmrb->ud: the stream module its context, in all the code of astream {}block, and the HTTP module its server configuration whilemruby_server_context_handler_code,mruby_ssl_handshake_handlerandmruby_ssl_handshake_handler_coderun. In that code the two methods wrote the connection into ngx_mruby’s data and read it from there. In the other code of anhttp {}block,mrb->udis NULL:Redis.connect_set_udptrraisedRuntimeError(“can not set, mrb ud pointer was not initialized”) after it had connected, and left that connection open, andRedis.newwithout arguments raisedRuntimeError(“can not get. mrb ud pointer was not initialized”). Where a connection was stored, everyRedis.newwithout arguments returned an object with that same connection, and each of these objects closed it when it was collected.Redis.newwith 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 theRedisclass that Ruby code cannot reach, and no longer reads or writesmrb->ud. The two methods work the same way in all the code of anhttp {}or astream {}block.Redis.connect_set_udptrkeeps the connection. When it connects, it closes a connection that it kept before and that noRedis.newhas taken. A failedRedis.connect_set_udptrraisesRedis::ConnectionError, as before; it now also closes the connection that failed, and a connection kept before stays kept.Redis.newwithout arguments takes the kept connection, so that one object owns it: a secondRedis.newwithout arguments raises an error untilRedis.connect_set_udptris called again, and so does a call when no connection is kept. The error isRedis::ConnectionError, a subclass ofRuntimeError, with the message “no connection kept: call Redis.connect_set_udptr first”.Redis.newwith one argument raisesArgumentErrorwith 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 untilmruby/buildandbuild_config.rb.lockare removed (see entry 3). - Why it changed:
- The defect: mruby leaves
mrb->udto 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 anhttp {}block.Redis.newwith one argument used a value that the call had not set (matsumotory/mruby-redis#109). - Why the old behavior could not be kept:
mrb->udhas no place for the data of a gem. While the gem kept its connection there, the two methods either raised, where ngx_mruby leavesmrb->udNULL, 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_initandmrb_udptr_copyto share a connection between twomrb_states; ngx_mruby calls neither.
- The defect: mruby leaves
- Affected: scripts that call
Redis.connect_set_udptr,Redis.connect_set_raw, orRedis.newwithout 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 everyRedisobject withRedis.new host, port[, timeout]are not affected. - What to do: call
Redis.connect_set_udptragain before each furtherRedis.newwithout arguments, or keep the object thatRedis.newreturned and use it again, or callRedis.new host, portinstead.Redis::ConnectionErroris a subclass ofRuntimeError, so code that rescuesRuntimeErroraroundRedis.newwithout arguments still catches the new error. Change only code that compares the class of the error withRuntimeErrorexactly, or its message. CallRedis.connect_set_udptrin code that runs in the worker (mruby_init_worker_code,mruby_stream_init_worker_codeor a handler): a connection kept bymruby_init_code,mruby_server_context_handler_code,mruby_stream_init_codeormruby_stream_server_context_codeis copied into every worker. If this source tree built mruby-redis before, removemruby/buildandbuild_config.rb.lockfirst. - Not changed by this release:
Redis.new host, port[, timeout]and the other methods ofRedisbehave 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.newwithout arguments andRedis.newwith one argument to the gem, among them tests withmrb->udset the way a host application sets it. It quotes the suite of ngx_mrubymasterandnext, 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 withNginx.rputsorNginx.echoand then calledNginx.return,Nginx.send_headerorNginx.status_code=with a status below 300 other than 200, 201 and 204, for exampleNginx::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 untilkeepalive_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-Lengthof the output, like nginx’s ownreturn 202 "text";without itsContent-Type(ngx_mruby sends none, as for 200 after output). This holds also when the handler ends so afterNginx::Async::HTTP.sub_requestresumed it.Nginx.rputs "queued"followed byNginx.return Nginx::HTTP_ACCEPTEDanswers202 Acceptedwith the bodyqueued. 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 ownreturn 100 "text";, although HTTP does not allow a final response with such a status.Nginx.return 0sets 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.returnafter 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 thatNginx.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.
- The defect:
- 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_OKanswers 200 with the output, andNginx::HTTP_CREATED,Nginx::HTTP_NO_CONTENTand 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 firstNginx.rputsorNginx.echostill does not apply to the output, which gets 200. Handlers of other phases are not changed: a rewrite handler that writes output and then setsNginx::HTTP_ACCEPTEDstill 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::Varaccessor (v.name = value) andNginx::Var#set(name, value)copied the value into a request-pool buffer withngx_cpystrn.ngx_cpystrnstops 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) returnedvv->lenbytes, so the untouched bytes after the NUL came from uninitialized request-pool memory. Sending that variable to the client (Nginx.rputs/Nginx.echoof 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_memcpyfor 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) andNginx::Var#set(ngx_mrb_var_set_func) are changed. - Why it changed:
- The defect:
ngx_cpystrnstopped 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
setset the value to the nginx variable (Nginx::Var , Nginx::#method_ missing of 2.7.1 Var ). It does not say that a value is cut off at a NUL byte, or that reading the variable back can return other memory.#set of 2.7.1
- The defect:
- Affected: a configuration that stores a value that can hold a NUL byte,
derived from client input, into an nginx variable with
Nginx::Var#setor thev.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_valueand/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 withNginx.rputsorNginx.echo,ngx_mrb_finalize_rputs(src/http/ngx_http_mruby_core.c) calledngx_http_send_headerand then passed the output chain tongx_http_output_filterwithout checkingr->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_rputschecksr->header_onlybeforengx_http_output_filter, as nginx’s own content handlers do. The headers,Content-Lengthamong 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_rputspassed the output chain tongx_http_output_filterwithout checkingr->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.
- The defect:
- Affected: a location whose main-request handler writes output with
Nginx.rputsorNginx.echoand 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 withngx_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 (aproxy_passto the upstream from another location, a read ofNginx::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) andNginx::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_codeand proxy_passes in the same request. Nothing said that a later request that does not set the server reads a valid peer, or that readingNginx::Upstream#serverafter the setting request ended returns valid memory.
- Affected: a configuration that uses
Nginx::Upstream#server=(HTTP) orNginx::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
zonekeeps 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 azone. An upstream with azoneneeds 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).
New features
Section titled “New features”None.
- When nginx has no value for a variable that
Nginx::Varreads, for example$realpath_rootin a location whose root does not exist, the line thatNginx::Varwrites toerror.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).
Build and test changes
Section titled “Build and test changes”test.sh: the HTTP test servers also listen on 18153, fortest/t/cases/var_input.rb(#589).test.sh: instead of sleeping forNGINX_HEATTIMEseconds before the first request,test.shwaits until the test nginx accepts connections on every port of thelistendirectives intest/conf/nginx.confandtest/conf/conf.d/*.conf, and prints how long that took. A CI cell under valgrind had failed withErrno::ECONNREFUSED, because nginx was still reading its configuration when the 10 seconds of the sleep had passed (#564).NGINX_HEATTIMEis 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.shalso waits until no process accepts connections on those ports. “Waiting for nginx to listen” indocs/test/README.mddescribes the wait (#593).- CI: three workflows check the supply chain (“Supply chain checks” in
docs/test/README.md).zizmor.ymlruns the static analyzer zizmor on pull requests and pushes that change.github/workflows/,.github/dependabot.ymlor.github/zizmor.yml, and fails on any finding.scorecard.ymlruns OpenSSF Scorecard on pushes tomasterand weekly, and publishes the results to the Scorecard API and to code scanning.gem-heads.ymlreports every Monday, for eachconf.gem github:line ofbuild_config.rb, the head of the branch that a build clones, and whether it moved since the previous report.mirror-v2x.ymlasks forcontents: writein 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 newperfjob. 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 intest/soak/, taken from thenextbranch).ci-okdoes not need the job yet. Locally,sh test/perf/compare.sh BASE_DIR(Linux with valgrind) builds inbuild_perf/and listens on 12370-12372. “Performance comparison with callgrind” indocs/test/README.mddescribes it (#601). - CI: the “Check for sanitizer reports” step of the sanitizer cell also
reads
build/nginx/logs/stderr.log, the other*.logfiles ofbuild/nginx/logs/and the logs of the second nginx underbuild/nginx/second_instance/, which hold the reports of the processes that the tests start. Before, it read onlybuild/nginx/logs/error.log(#601). - Tests for directives and Ruby methods that had none. They check that
nginx -trejectsmruby_output_filterandmruby_output_filter_codewith their message, and thatmruby_exit_worker_codeandmruby_stream_exit_worker_coderun when nginx stops. They check what these methods return or write toerror.log:Nginx.module_version,Nginx.nginx_version,Nginx.server_version,Nginx::Request#content_type,#content_type=,#method=,#schemeand#document_root,Nginx::SSL.log, the methods of the Rack logger,Nginx::Utils.escapeand.encode_parameters, the methods ofNginx::Stream::Connectionthat read the addresses of the session,#upstream_server,Nginx::Stream.errloggerandNginx::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.rbstarts an nginx of its own on 18158 and stops it withnginx -s quit(#603). test.sh: the HTTP test servers also listen on 18154, fortest/t/cases/output_then_status.rb(#594).
Performance
Section titled “Performance”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.
Upgrading
Section titled “Upgrading”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::Varwith a name built at run time: check the name before the call, or rescueNoMethodErrorwhere the name can be empty, and pass a value to each setter (entry 1).Nginx::Var#set: nothing, unless the code relied onsetlowercasing the name; such code callsdowncaseon the name itself (entry 2).- mruby-redis, which the default build includes again: nothing, unless
this source tree built it before; then remove
mruby/buildandbuild_config.rb.lockfirst. 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 inbuild_config.rb. Set the environment variableMRUBY_REDIS_HIREDIS_VERSIONto build another hiredis tag (entry 3). Redis.connect_set_udptrandRedis.newwithout arguments of mruby-redis: callRedis.connect_set_udptragain before each furtherRedis.newwithout arguments, keep the object thatRedis.newreturned, or callRedis.new host, portinstead.rescue RuntimeErrorstill catches the newRedis::ConnectionError, a subclass of it. CallRedis.connect_set_udptrin code that runs in the worker. If this source tree built mruby-redis before, removemruby/buildandbuild_config.rb.lockfirst (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#setor thev.name =accessor: nothing; the value is stored in full (entry 6). - A HEAD request to a content handler that writes output with
Nginx.rputsorNginx.echo: nothing (entry 7). Nginx::Upstream#server=orNginx::Stream::Connection#upstream_server=: nothing (entry 8).