From 8fff6be1d60869094f3f062e4c8dcf954c17b3da Mon Sep 17 00:00:00 2001 From: Joe Orton Date: Thu, 12 Nov 2026 17:36:43 +0000 Subject: [PATCH 01/18] New test suite. Co-Authored-By: Claude Opus 5.5 --- test/modules/lua/__init__.py | 0 test/modules/lua/conftest.py | 35 ++++ test/modules/lua/env.py | 27 +++ test/modules/lua/htdocs/test1/lua/docroot.lua | 38 ++++ test/modules/lua/htdocs/test1/lua/empty | 0 test/modules/lua/htdocs/test1/lua/mem.lua | 70 +++++++ test/modules/lua/htdocs/test1/lua/regex.lua | 9 + test/modules/lua/htdocs/test1/lua/wsread.lua | 24 +++ test/modules/lua/test_001_websocket.py | 180 ++++++++++++++++++ test/modules/lua/test_002_memory.py | 87 +++++++++ test/modules/lua/test_003_request.py | 44 +++++ 11 files changed, 514 insertions(+) create mode 100644 test/modules/lua/__init__.py create mode 100644 test/modules/lua/conftest.py create mode 100644 test/modules/lua/env.py create mode 100644 test/modules/lua/htdocs/test1/lua/docroot.lua create mode 100644 test/modules/lua/htdocs/test1/lua/empty create mode 100644 test/modules/lua/htdocs/test1/lua/mem.lua create mode 100644 test/modules/lua/htdocs/test1/lua/regex.lua create mode 100644 test/modules/lua/htdocs/test1/lua/wsread.lua create mode 100644 test/modules/lua/test_001_websocket.py create mode 100644 test/modules/lua/test_002_memory.py create mode 100644 test/modules/lua/test_003_request.py diff --git a/test/modules/lua/__init__.py b/test/modules/lua/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/test/modules/lua/conftest.py b/test/modules/lua/conftest.py new file mode 100644 index 00000000000..e038529e5d7 --- /dev/null +++ b/test/modules/lua/conftest.py @@ -0,0 +1,35 @@ +import logging +import os + +import pytest +import sys + +from .env import LuaTestEnv + +sys.path.append(os.path.join(os.path.dirname(__file__), '../..')) + + +def pytest_report_header(config, start_path): + env = LuaTestEnv() + return f"lua [apache: {env.get_httpd_version()}, mpm: {env.mpm_module}, {env.prefix}]" + + +@pytest.fixture(scope="package") +def env(pytestconfig) -> LuaTestEnv: + level = logging.INFO + console = logging.StreamHandler() + console.setLevel(level) + console.setFormatter(logging.Formatter('%(levelname)s: %(message)s')) + logging.getLogger('').addHandler(console) + logging.getLogger('').setLevel(level=level) + env = LuaTestEnv(pytestconfig=pytestconfig) + env.setup_httpd() + env.apache_access_log_clear() + env.httpd_error_log.clear_log() + return env + + +@pytest.fixture(autouse=True, scope="package") +def _stop_package_scope(env): + yield + assert env.apache_stop() == 0 diff --git a/test/modules/lua/env.py b/test/modules/lua/env.py new file mode 100644 index 00000000000..725083907e5 --- /dev/null +++ b/test/modules/lua/env.py @@ -0,0 +1,27 @@ +import inspect +import logging +import os + +from pyhttpd.env import HttpdTestEnv, HttpdTestSetup + +log = logging.getLogger(__name__) + + +class LuaTestSetup(HttpdTestSetup): + + def __init__(self, env: 'HttpdTestEnv'): + super().__init__(env=env) + self.add_source_dir(os.path.dirname(inspect.getfile(LuaTestSetup))) + # mod_lua needs liblua, so it may not have been built; the tests + # skip when it is absent. + self.add_optional_modules(["lua"]) + + +class LuaTestEnv(HttpdTestEnv): + + def __init__(self, pytestconfig=None): + super().__init__(pytestconfig=pytestconfig) + self.add_httpd_log_modules(["lua"]) + + def setup_httpd(self, setup: HttpdTestSetup = None): + super().setup_httpd(setup=LuaTestSetup(env=self)) diff --git a/test/modules/lua/htdocs/test1/lua/docroot.lua b/test/modules/lua/htdocs/test1/lua/docroot.lua new file mode 100644 index 00000000000..4032f5dc02d --- /dev/null +++ b/test/modules/lua/htdocs/test1/lua/docroot.lua @@ -0,0 +1,38 @@ +-- Sets a document root from a string nothing else refers to, lets Lua +-- collect it, then reads it back. /lua/docroot.lua? +local LEN = 1000 + +local function fresh(tag) + -- A string no other value refers to. + return table.concat({string.rep("x", LEN), tag, tostring(os.time())}) +end + +local function churn() + collectgarbage() + collectgarbage() + local t = {} + for i = 1, 4096 do t[i] = string.rep("y", LEN + 12) .. i end + return t +end + +local setters = { + set_document_root = function(r) + r:set_document_root(fresh("/root")) + local _ = churn() + return r.document_root + end, + set_context_info = function(r) + r:set_context_info(fresh("/prefix"), fresh("/root")) + local _ = churn() + return r.context_prefix .. "|" .. r.context_document_root + end, +} + +function handle(r) + local setter = setters[r.args] + if not setter then return 404 end + local got = setter(r):gsub("%d+", "N") + r.content_type = "text/plain" + r:puts(got, "\n") + return apache2.OK +end diff --git a/test/modules/lua/htdocs/test1/lua/empty b/test/modules/lua/htdocs/test1/lua/empty new file mode 100644 index 00000000000..e69de29bb2d diff --git a/test/modules/lua/htdocs/test1/lua/mem.lua b/test/modules/lua/htdocs/test1/lua/mem.lua new file mode 100644 index 00000000000..ad24acb34b3 --- /dev/null +++ b/test/modules/lua/htdocs/test1/lua/mem.lua @@ -0,0 +1,70 @@ +-- Runs one r:* call in a loop and reports how much this child's +-- VmRSS grew, in kB. /lua/mem.lua?& +-- +-- Each call should hold on to nothing once it returns, so the growth +-- must not scale with the number of iterations. + +local s = string.rep("a/b &d%e\"f", 1260) -- ~16KB, needs escaping +local b64 = nil +local pct = string.rep("%41", 5461) -- ~16KB URL-escaped +local pattern = string.rep("a", 4000) .. "(b)" -- a large compiled pattern +local path = string.rep("d", 2000) -- under PATH_MAX + +local cases = { + noop = function(r) end, + + -- input-sized results + base64_encode = function(r) r:base64_encode(s) end, + base64_decode = function(r) r:base64_decode(b64) end, + escape = function(r) r:escape(s) end, + unescape = function(r) r:unescape(pct) end, + escape_html = function(r) r:escape_html(s) end, + os_escape_path = function(r) r:os_escape_path(s) end, + escape_logitem = function(r) r:escape_logitem(s) end, + construct_url = function(r) r:construct_url(s) end, + runtime_dir_relative = function(r) r:runtime_dir_relative(path) end, + htpassword = function(r) r:htpassword(s, 1) end, -- ALG_APSHA + expr = function(r) r:expr("'" .. s .. "' == 'x'") end, + regex = function(r) r:regex(s, "^(.*)$") end, + regex_compile = function(r) r:regex("x", pattern) end, + ivm_get = function(r) r:ivm_get(s) end, + ivm_set = function(r) r:ivm_set(s, "v") end, + + -- fixed-size results + md5 = function(r) r:md5("x") end, + sha1 = function(r) r:sha1("x") end, + scoreboard_worker = function(r) r:scoreboard_worker(0, 0) end, + config = function(r) r:config() end, + options = function(r) local _ = r.options end, + allowoverrides = function(r) local _ = r.allowoverrides end, + headers_in = function(r) local _ = r.headers_in end, + get_direntries = function(r) r:get_direntries(r.document_root) end, + sendfile = function(r) r:sendfile(r.document_root .. "/lua/empty") end, + wspeek = function(r) r:wspeek() end, +} + +local function rss() + collectgarbage() + collectgarbage() + for line in io.lines("/proc/self/status") do + local kb = line:match("^VmRSS:%s+(%d+)") + if kb then return tonumber(kb) end + end + return 0 +end + +function handle(r) + local name, count = r.args:match("^([%w_]+)&(%d+)$") + local case = cases[name] + if not case then return 404 end + count = tonumber(count) + b64 = r:base64_encode(string.rep("x", 12288)) + -- One call first, so one-time setup is not counted. + case(r) + local before = rss() + for _ = 1, count do case(r) end + local after = rss() + r.content_type = "text/plain" + r:puts(string.format("%d\n", after - before)) + return apache2.OK +end diff --git a/test/modules/lua/htdocs/test1/lua/regex.lua b/test/modules/lua/htdocs/test1/lua/regex.lua new file mode 100644 index 00000000000..e39091a0d24 --- /dev/null +++ b/test/modules/lua/htdocs/test1/lua/regex.lua @@ -0,0 +1,9 @@ +-- r:regex() with as many capture groups as it accepts: prints the +-- last capture. +function handle(r) + local n = tonumber(r.args) + local m = r:regex(string.rep("x", n), string.rep("(x)", n)) + r.content_type = "text/plain" + r:puts(tostring(m and m[n]), "\n") + return apache2.OK +end diff --git a/test/modules/lua/htdocs/test1/lua/wsread.lua b/test/modules/lua/htdocs/test1/lua/wsread.lua new file mode 100644 index 00000000000..754bef4fe9d --- /dev/null +++ b/test/modules/lua/htdocs/test1/lua/wsread.lua @@ -0,0 +1,24 @@ +-- The r:wsread() loop from the report. Two text messages get an +-- answer: "sync" is acknowledged, and "rss" is answered with this +-- child's VmRSS in kB, after a full Lua GC so that only memory held +-- outside the Lua heap is left. +function rss() + collectgarbage() + for line in io.lines("/proc/self/status") do + local kb = line:match("^VmRSS:%s+(%d+)") + if kb then return kb end + end + return "0" +end + +function handle(r) + if r:wsupgrade() then + while true do + local data = r:wsread() + if data == nil then break end + if data == "sync" then r:wswrite("ok") end + if data == "rss" then r:wswrite(rss()) end + end + end + return apache2.OK +end diff --git a/test/modules/lua/test_001_websocket.py b/test/modules/lua/test_001_websocket.py new file mode 100644 index 00000000000..e7ca8c1ceb1 --- /dev/null +++ b/test/modules/lua/test_001_websocket.py @@ -0,0 +1,180 @@ +import base64 +import os +import socket +import struct +import textwrap +import time + +import pytest + +from pyhttpd.conf import HttpdConf + + +# Payload per frame, and how many to send: 4096 x 32KiB. Each frame +# is paced by a "sync" round trip and fits in one loopback segment: +# r:wsread() fails on a frame which has only partly arrived. +FRAME_SIZE = 32 * 1024 +FRAME_COUNT = 4096 + + +@pytest.mark.skipif(not os.path.exists("/proc/self/status"), + reason="needs /proc/self/status") +class TestWebsocket: + + @pytest.fixture(autouse=True, scope='class') + def _class_scope(self, env): + env.require(env.has_shared_module("lua"), "mod_lua not built", + dep="mod_lua") + conf = HttpdConf(env, extras={ + 'base': textwrap.dedent(f""" + + SetHandler lua-script + + """), + }) + conf.add_vhost_test1() + conf.install() + assert env.apache_restart() == 0 + + @staticmethod + def frame(payload: bytes, opcode=0x2, masked=True) -> bytes: + # A client frame; an all-zero mask leaves the payload as is. + n = len(payload) + m = 0x80 if masked else 0 + if n < 126: + hdr = struct.pack("!BB", 0x80 | opcode, m | n) + elif n < 65536: + hdr = struct.pack("!BBH", 0x80 | opcode, m | 126, n) + else: + hdr = struct.pack("!BBQ", 0x80 | opcode, m | 127, n) + return hdr + (b"\0\0\0\0" if masked else b"") + payload + + @staticmethod + def recv_exact(sock, n: int) -> bytes: + buf = b"" + while len(buf) < n: + chunk = sock.recv(n - len(buf)) + assert chunk, f"connection closed after {len(buf)}/{n} bytes" + buf += chunk + return buf + + def recv_opcode_frame(self, sock): + b0, b1 = self.recv_exact(sock, 2) + n = b1 & 0x7f + if n == 126: + n, = struct.unpack("!H", self.recv_exact(sock, 2)) + elif n == 127: + n, = struct.unpack("!Q", self.recv_exact(sock, 8)) + return b0 & 0xf, self.recv_exact(sock, n) + + def recv_frame(self, sock) -> bytes: + return self.recv_opcode_frame(sock)[1] + + def sync(self, sock) -> bytes: + sock.sendall(self.frame(b"sync", opcode=0x1)) + return self.recv_frame(sock) + + def rss(self, sock) -> int: + sock.sendall(self.frame(b"rss", opcode=0x1)) + return int(self.recv_frame(sock)) + + def upgrade(self, env): + sock = socket.create_connection((env.http_addr, env.http_port), + timeout=30) + key = base64.b64encode(os.urandom(16)).decode() + sock.sendall((f"GET /lua/wsread.lua HTTP/1.1\r\n" + f"Host: test1.{env.http_tld}:{env.http_port}\r\n" + f"Upgrade: websocket\r\n" + f"Connection: Upgrade\r\n" + f"Sec-WebSocket-Key: {key}\r\n" + f"Sec-WebSocket-Version: 13\r\n\r\n").encode()) + head = b"" + while b"\r\n\r\n" not in head: + chunk = sock.recv(1) + assert chunk, f"connection closed in response head: {head!r}" + head += chunk + assert head.startswith(b"HTTP/1.1 101 "), head + return sock + + # r:wsread() caps its memory use: nothing it allocates for a frame + # outlives the call, so memory use does not scale with the number + # of frames read. + @pytest.mark.skipif("TEST_ASAN" in os.environ, + reason="RSS is not a measure of memory use under ASan") + def test_lua_001_01_wsread_memory(self, env): + sock = self.upgrade(env) + try: + before = self.rss(sock) + payload = b"x" * FRAME_SIZE + data = self.frame(payload) + self.frame(b"sync", opcode=0x1) + for _ in range(FRAME_COUNT): + sock.sendall(data) + assert self.recv_frame(sock) == b"ok" + after = self.rss(sock) + finally: + sock.close() + sent_kb = FRAME_SIZE * FRAME_COUNT // 1024 + growth_kb = after - before + assert growth_kb < sent_kb // 4, \ + f"child RSS grew {growth_kb}kB ({before}kB -> {after}kB) " \ + f"reading {sent_kb}kB of websocket frames" + + # A frame which arrives in two parts is still read whole. + def test_lua_001_02_partial_frame(self, env): + sock = self.upgrade(env) + try: + data = self.frame(b"sync", opcode=0x1) + sock.sendall(data[:-2]) + time.sleep(0.5) + sock.sendall(data[-2:]) + assert self.recv_frame(sock) == b"ok" + finally: + sock.close() + + # A frame larger than one socket read, sent without pacing. + def test_lua_001_03_large_frame(self, env): + sock = self.upgrade(env) + try: + sock.sendall(self.frame(b"x" * 7 * 1024 * 1024)) + assert self.sync(sock) == b"ok" + finally: + sock.close() + + # RFC 6455 5.5.3: a Pong echoes the Ping's application data. + def test_lua_001_04_ping_payload(self, env): + sock = self.upgrade(env) + try: + sock.sendall(self.frame(b"hello", opcode=0x9)) + assert self.recv_opcode_frame(sock) == (0xa, b"hello") + assert self.sync(sock) == b"ok" + finally: + sock.close() + + # An empty message is a message, not the end of the connection. + def test_lua_001_05_empty_frame(self, env): + sock = self.upgrade(env) + try: + sock.sendall(self.frame(b"", opcode=0x1)) + assert self.sync(sock) == b"ok" + finally: + sock.close() + + # RFC 6455 5.1: the server closes the connection on an unmasked + # client frame. + def test_lua_001_06_unmasked_frame(self, env): + sock = self.upgrade(env) + try: + sock.sendall(self.frame(b"sync", opcode=0x1, masked=False)) + sock.settimeout(5) + data = b"" + try: + while True: + chunk = sock.recv(4096) + if not chunk: + break + data += chunk + except socket.timeout: + pytest.fail(f"connection left open, received {data!r}") + assert b"ok" not in data, data + finally: + sock.close() diff --git a/test/modules/lua/test_002_memory.py b/test/modules/lua/test_002_memory.py new file mode 100644 index 00000000000..b70c8d66482 --- /dev/null +++ b/test/modules/lua/test_002_memory.py @@ -0,0 +1,87 @@ +import os +import textwrap + +import pytest + +from pyhttpd.conf import HttpdConf + + +# Iterations per case, chosen so that a call which leaves its result +# (or anything else proportional to its input) in a long-lived pool +# grows the child by roughly 64MB or more. +CASES = { + # 16KB of input per call + "base64_encode": 4096, + "base64_decode": 4096, + "escape": 4096, + "unescape": 4096, + "escape_html": 4096, + "os_escape_path": 4096, + "escape_logitem": 4096, + "construct_url": 4096, + "htpassword": 4096, + "expr": 4096, + "regex": 4096, + "regex_compile": 16384, + # 2KB of input per call + "runtime_dir_relative": 32768, + "ivm_get": 4096, + "ivm_set": 4096, + # a fixed-size result per call + "md5": 2000000, + "sha1": 2000000, + "scoreboard_worker": 200000, + "config": 20000, + "options": 1000000, + "allowoverrides": 4000000, + "headers_in": 2000000, + "get_direntries": 100000, + "sendfile": 200000, + "wspeek": 1000000, +} + +# Growth below this is noise: the Lua heap, malloc arenas, and so on. +LIMIT_KB = 16 * 1024 + + +@pytest.mark.skipif(not os.path.exists("/proc/self/status"), + reason="needs /proc/self/status") +@pytest.mark.skipif("TEST_ASAN" in os.environ, + reason="RSS is not a measure of memory use under ASan") +class TestMemory: + + @pytest.fixture(autouse=True, scope='class') + def _class_scope(self, env): + env.require(env.has_shared_module("lua"), "mod_lua not built", + dep="mod_lua") + # A fresh child for each request, so that memory one case + # leaves behind in the allocator cannot hide growth in the next. + conf = HttpdConf(env, extras={ + 'base': textwrap.dedent(f""" + MaxConnectionsPerChild 1 + LuaScope once + + SetHandler lua-script + # Neither All nor None, for r.allowoverrides + AllowOverride FileInfo + + """), + }) + conf.add_vhost_test1() + conf.install() + assert env.apache_restart() == 0 + + def growth(self, env, case, count): + url = env.mkurl("http", "test1", f"/lua/mem.lua?{case}&{count}") + r = env.curl_raw([url], timeout=120) + assert r.response["status"] == 200, r.response + return int(r.response["body"]) + + def test_lua_002_01_noop(self, env): + assert self.growth(env, "noop", 1000000) < LIMIT_KB + + @pytest.mark.parametrize("case", CASES.keys()) + def test_lua_002_02_growth(self, env, case): + growth = self.growth(env, case, CASES[case]) + assert growth < LIMIT_KB, \ + f"r:{case} x {CASES[case]} grew the child by {growth}kB" diff --git a/test/modules/lua/test_003_request.py b/test/modules/lua/test_003_request.py new file mode 100644 index 00000000000..b873ebe07e2 --- /dev/null +++ b/test/modules/lua/test_003_request.py @@ -0,0 +1,44 @@ +import textwrap + +import pytest + +from pyhttpd.conf import HttpdConf + + +class TestRequest: + + @pytest.fixture(autouse=True, scope='class') + def _class_scope(self, env): + env.require(env.has_shared_module("lua"), "mod_lua not built", + dep="mod_lua") + conf = HttpdConf(env, extras={ + 'base': textwrap.dedent(f""" + + SetHandler lua-script + + """), + }) + conf.add_vhost_test1() + conf.install() + assert env.apache_restart() == 0 + + def get(self, env, path): + r = env.curl_get(env.mkurl("http", "test1", path)) + assert r.response["status"] == 200, r.response + return r.response["body"].decode().strip() + + # MODLUA_MAX_REG_MATCH (25) capture groups are allowed, and the + # last of them is returned. + def test_lua_003_01_regex_max_groups(self, env): + assert self.get(env, "/lua/regex.lua?24") == "x" + assert self.get(env, "/lua/regex.lua?25") == "x" + + # The document root and context info outlive the Lua strings they + # were set from. + def test_lua_003_02_set_document_root(self, env): + assert self.get(env, "/lua/docroot.lua?set_document_root") == \ + "x" * 1000 + "/rootN" + + def test_lua_003_03_set_context_info(self, env): + assert self.get(env, "/lua/docroot.lua?set_context_info") == \ + "x" * 1000 + "/prefixN|" + "x" * 1000 + "/rootN" From e17d7a5365e8243699e6fa1e4ab0c30ca83fb2fa Mon Sep 17 00:00:00 2001 From: Joe Orton Date: Thu, 12 Nov 2026 17:36:43 +0000 Subject: [PATCH 02/18] mod_lua: Cap memory use of r:wsread(): * modules/lua/lua_request.c (lua_temp_ctx_t, lua_temp_ctx, lua_temp_pool, req_call): New. (req_dispatch): Push request methods as a req_call closure. (lua_websocket_read): Allocate from the temporary pool, and the mask on the stack. * changes-entries/lua-wsread-memory.txt: Add. Fixes: 731d18dd4c (SVN r1522030) Co-Authored-By: Claude Opus 5.5 --- changes-entries/lua-wsread-memory.txt | 1 + modules/lua/lua_request.c | 68 ++++++++++++++++++++++++--- 2 files changed, 62 insertions(+), 7 deletions(-) create mode 100644 changes-entries/lua-wsread-memory.txt diff --git a/changes-entries/lua-wsread-memory.txt b/changes-entries/lua-wsread-memory.txt new file mode 100644 index 00000000000..609907f0f19 --- /dev/null +++ b/changes-entries/lua-wsread-memory.txt @@ -0,0 +1 @@ + *) mod_lua: Cap memory use of r:wsread(). [Joe Orton] diff --git a/modules/lua/lua_request.c b/modules/lua/lua_request.c index 6843679006b..363c287ff3f 100644 --- a/modules/lua/lua_request.c +++ b/modules/lua/lua_request.c @@ -546,6 +546,38 @@ static int req_add_output_filter(lua_State *L) return 0; } +/* Per-request state for the temporary pool of a call from Lua. */ +typedef struct { + apr_pool_t *pool; +} lua_temp_ctx_t; + +static lua_temp_ctx_t *lua_temp_ctx(request_rec *r) +{ + lua_temp_ctx_t *ctx; + + apr_pool_userdata_get((void **)&ctx, "mod_lua_temp", r->pool); + if (ctx == NULL) { + ctx = apr_pcalloc(r->pool, sizeof *ctx); + apr_pool_userdata_setn(ctx, "mod_lua_temp", NULL, r->pool); + } + return ctx; +} + +/* Returns a pool which lasts for the current call of a request method + * from Lua, created on first use. req_call() destroys it when the + * call returns, since a script may call a method repeatedly for the + * life of the request. */ +static apr_pool_t *lua_temp_pool(request_rec *r) +{ + lua_temp_ctx_t *ctx = lua_temp_ctx(r); + + if (ctx->pool == NULL) { + apr_pool_create(&ctx->pool, r->pool); + apr_pool_tag(ctx->pool, "lua_temp"); + } + return ctx->pool; +} + /* wrap ap_construct_url as r:construct_url(String) */ static int req_construct_url(lua_State *L) { @@ -1868,6 +1900,27 @@ static int lua_ap_usleep(lua_State *L) /* END dispatch methods for request_rec fields */ +/* Calls the request method in upvalue 1 for the request in upvalue 2, + * then destroys any temporary pool it used. The pool of an outer call + * is restored afterwards, since a method can run filters which call + * back into Lua for the same request. */ +static int req_call(lua_State *L) +{ + const req_fun_t *rft = lua_touserdata(L, lua_upvalueindex(1)); + request_rec *r = lua_touserdata(L, lua_upvalueindex(2)); + lua_temp_ctx_t *ctx = lua_temp_ctx(r); + apr_pool_t *outer = ctx->pool; + int n; + + ctx->pool = NULL; + n = ((lua_CFunction)rft->fun)(L); + if (ctx->pool) { + apr_pool_destroy(ctx->pool); + } + ctx->pool = outer; + return n; +} + static int req_dispatch(lua_State *L) { apr_hash_t *dispatch; @@ -1895,11 +1948,12 @@ static int req_dispatch(lua_State *L) } case APL_REQ_FUNTYPE_LUACFUN:{ - lua_CFunction func = (lua_CFunction)rft->fun; ap_log_rerror(APLOG_MARK, APLOG_DEBUG, 0, r, APLOGNO(01487) "request_rec->dispatching %s -> lua_CFunction", name); - lua_pushcfunction(L, func); + lua_pushlightuserdata(L, rft); + lua_pushlightuserdata(L, r); + lua_pushcclosure(L, req_call, 2); return 1; } case APL_REQ_FUNTYPE_STRING:{ @@ -2283,17 +2337,17 @@ static int lua_websocket_read(lua_State *L) apr_size_t plen = 0; unsigned short payload_short = 0; apr_uint64_t payload_long = 0; - unsigned char *mask_bytes; + unsigned char mask_bytes[4]; char byte; + apr_pool_t *pool; apr_bucket_brigade *brigade; conn_rec* c; request_rec *r = ap_lua_check_request_rec(L, 1); c = r->connection; - mask_bytes = apr_pcalloc(r->pool, 4); - - brigade = apr_brigade_create(r->pool, c->bucket_alloc); + pool = lua_temp_pool(r); + brigade = apr_brigade_create(pool, c->bucket_alloc); while (do_read) { do_read = 0; @@ -2355,7 +2409,7 @@ static int lua_websocket_read(lua_State *L) } if (plen < (HUGE_STRING_LEN*1024) && plen > 0) { apr_size_t remaining = plen; - char *buffer = apr_palloc(r->pool, plen+1); + char *buffer = apr_palloc(pool, plen+1); buffer[plen] = 0; rv = lua_websocket_readbytes(c, brigade, buffer, remaining); From 90485f0b0048e2d8486c19d247a082ebc5600237 Mon Sep 17 00:00:00 2001 From: Joe Orton Date: Thu, 12 Nov 2026 17:43:44 +0000 Subject: [PATCH 03/18] * modules/lua/lua_request.c (lua_websocket_readbytes): Loop until the requested length has been read. * changes-entries/lua-fixes.txt: Add. Fixes: 11a3fcbf9e (SVN r1901502) Co-Authored-By: Claude Opus 5.5 --- changes-entries/lua-fixes.txt | 2 ++ modules/lua/lua_request.c | 28 ++++++++++++++++++---------- 2 files changed, 20 insertions(+), 10 deletions(-) create mode 100644 changes-entries/lua-fixes.txt diff --git a/changes-entries/lua-fixes.txt b/changes-entries/lua-fixes.txt new file mode 100644 index 00000000000..8d4c0abff4b --- /dev/null +++ b/changes-entries/lua-fixes.txt @@ -0,0 +1,2 @@ + *) mod_lua: Fix r:wsread() handling of websocket frames which arrive + in parts. [Joe Orton] diff --git a/modules/lua/lua_request.c b/modules/lua/lua_request.c index 363c287ff3f..b0eb69432bb 100644 --- a/modules/lua/lua_request.c +++ b/modules/lua/lua_request.c @@ -2290,22 +2290,30 @@ static int lua_websocket_greet(lua_State *L) static apr_status_t lua_websocket_readbytes(conn_rec* c, apr_bucket_brigade *brigade, - char* buffer, apr_off_t len) + char* buffer, apr_size_t len) { apr_size_t delivered; apr_status_t rv; - rv = ap_get_brigade(c->input_filters, brigade, AP_MODE_READBYTES, - APR_BLOCK_READ, len); - if (rv == APR_SUCCESS) { - delivered = len; - rv = apr_brigade_flatten(brigade, buffer, &delivered); - if ((rv == APR_SUCCESS) && (delivered < len)) { - rv = APR_INCOMPLETE; + /* A READBYTES read may return fewer bytes than asked for. */ + while (len > 0) { + rv = ap_get_brigade(c->input_filters, brigade, AP_MODE_READBYTES, + APR_BLOCK_READ, len); + if (rv == APR_SUCCESS) { + delivered = len; + rv = apr_brigade_flatten(brigade, buffer, &delivered); + } + apr_brigade_cleanup(brigade); + if (rv != APR_SUCCESS) { + return rv; } + if (delivered == 0) { + return APR_EOF; + } + buffer += delivered; + len -= delivered; } - apr_brigade_cleanup(brigade); - return rv; + return APR_SUCCESS; } static int lua_websocket_peek(lua_State *L) From 4c6b77197ca0e7f720674f3e153dfa506c9722f3 Mon Sep 17 00:00:00 2001 From: Joe Orton Date: Thu, 12 Nov 2026 17:43:44 +0000 Subject: [PATCH 04/18] * modules/lua/lua_request.c (lua_websocket_read): Return empty data frames; answer a ping with its payload; skip a pong; stop at a close frame, an unmasked frame or an oversized control frame; check the 64-bit length before narrowing it. * changes-entries/lua-fixes.txt: Update. Fixes: 731d18dd4c (SVN r1522030) Co-Authored-By: Claude Opus 5.5 --- changes-entries/lua-fixes.txt | 4 +- modules/lua/lua_request.c | 202 +++++++++++++++++----------------- 2 files changed, 103 insertions(+), 103 deletions(-) diff --git a/changes-entries/lua-fixes.txt b/changes-entries/lua-fixes.txt index 8d4c0abff4b..a23186c6b4f 100644 --- a/changes-entries/lua-fixes.txt +++ b/changes-entries/lua-fixes.txt @@ -1,2 +1,2 @@ - *) mod_lua: Fix r:wsread() handling of websocket frames which arrive - in parts. [Joe Orton] + *) mod_lua: Fix r:wsread() handling of partially received, empty and + control websocket frames. [Joe Orton] diff --git a/modules/lua/lua_request.c b/modules/lua/lua_request.c index b0eb69432bb..672f6c72e86 100644 --- a/modules/lua/lua_request.c +++ b/modules/lua/lua_request.c @@ -2340,13 +2340,12 @@ static int lua_websocket_peek(lua_State *L) static int lua_websocket_read(lua_State *L) { apr_status_t rv; - int do_read = 1; - int n = 0; - apr_size_t plen = 0; + int ret = 0; + apr_size_t n, plen; unsigned short payload_short = 0; apr_uint64_t payload_long = 0; unsigned char mask_bytes[4]; - char byte; + unsigned char header[2]; apr_pool_t *pool; apr_bucket_brigade *brigade; conn_rec* c; @@ -2357,114 +2356,115 @@ static int lua_websocket_read(lua_State *L) pool = lua_temp_pool(r); brigade = apr_brigade_create(pool, c->bucket_alloc); - while (do_read) { - do_read = 0; - /* Get opcode and FIN bit */ - rv = lua_websocket_readbytes(c, brigade, &byte, 1); - if (rv == APR_SUCCESS) { - unsigned char ubyte, fin, opcode, mask, payload; - ubyte = (unsigned char)byte; - /* fin bit is the first bit */ - fin = ubyte >> (CHAR_BIT - 1); - /* opcode is the last four bits (there's 3 reserved bits we don't care about) */ - opcode = ubyte & 0xf; - - /* Get the payload length and mask bit */ - rv = lua_websocket_readbytes(c, brigade, &byte, 1); - if (rv == APR_SUCCESS) { - ubyte = (unsigned char)byte; - /* Mask is the first bit */ - mask = ubyte >> (CHAR_BIT - 1); - /* Payload is the last 7 bits */ - payload = ubyte & 0x7f; - plen = payload; - - /* Extended payload? */ - if (payload == 126) { - rv = lua_websocket_readbytes(c, brigade, - (char*) &payload_short, 2); - - if (rv != APR_SUCCESS) { - return 0; - } - - plen = ntohs(payload_short); - } - /* Super duper extended payload? */ - if (payload == 127) { - rv = lua_websocket_readbytes(c, brigade, - (char*) &payload_long, 8); + for (;;) { + unsigned char fin, opcode, mask; + char *buffer; - if (rv != APR_SUCCESS) { - return 0; - } + /* FIN bit, opcode, mask bit and payload length */ + rv = lua_websocket_readbytes(c, brigade, (char *) header, 2); + if (rv != APR_SUCCESS) { + break; + } + fin = header[0] >> (CHAR_BIT - 1); + /* opcode is the last four bits (there's 3 reserved bits we don't care about) */ + opcode = header[0] & 0xf; + mask = header[1] >> (CHAR_BIT - 1); + plen = header[1] & 0x7f; + + /* Extended payload? */ + if (plen == 126) { + rv = lua_websocket_readbytes(c, brigade, + (char*) &payload_short, 2); + if (rv != APR_SUCCESS) { + break; + } + plen = ntohs(payload_short); + } + /* Super duper extended payload? */ + else if (plen == 127) { + apr_uint64_t len64; - plen = ap_ntoh64(&payload_long); - } - ap_log_rerror(APLOG_MARK, APLOG_DEBUG, 0, r, APLOGNO(03210) - "Websocket: Reading %" APR_SIZE_T_FMT " (%s) bytes, masking is %s. %s", - plen, - (payload >= 126) ? "extra payload" : "no extra payload", - mask ? "on" : "off", - fin ? "This is a final frame" : "more to follow"); - if (mask) { - rv = lua_websocket_readbytes(c, brigade, - (char*) mask_bytes, 4); - - if (rv != APR_SUCCESS) { - return 0; - } - } - if (plen < (HUGE_STRING_LEN*1024) && plen > 0) { - apr_size_t remaining = plen; - char *buffer = apr_palloc(pool, plen+1); - buffer[plen] = 0; - - rv = lua_websocket_readbytes(c, brigade, buffer, remaining); - - if (rv != APR_SUCCESS) { - return 0; - } - - ap_log_rerror(APLOG_MARK, APLOG_TRACE1, 0, r, - "Websocket: Frame contained %" APR_SIZE_T_FMT \ - " bytes, pushed to Lua stack", remaining); - if (mask) { - for (n = 0; n < plen; n++) { - buffer[n] ^= mask_bytes[n%4]; - } - } - - lua_pushlstring(L, buffer, (size_t) plen); /* push to stack */ - lua_pushboolean(L, fin); /* push FIN bit to stack as boolean */ - return 2; - } + rv = lua_websocket_readbytes(c, brigade, + (char*) &payload_long, 8); + if (rv != APR_SUCCESS) { + break; + } + len64 = ap_ntoh64(&payload_long); + if (len64 >= HUGE_STRING_LEN*1024) { + break; + } + plen = (apr_size_t) len64; + } + ap_log_rerror(APLOG_MARK, APLOG_DEBUG, 0, r, APLOGNO(03210) + "Websocket: Reading %" APR_SIZE_T_FMT " (%s) bytes, masking is %s. %s", + plen, + ((header[1] & 0x7f) >= 126) ? "extra payload" : "no extra payload", + mask ? "on" : "off", + fin ? "This is a final frame" : "more to follow"); + + /* Every client frame is masked (RFC 6455 5.1), a control frame + * carries at most 125 bytes (5.5), and anything larger than + * HUGE_STRING_LEN*1024 is refused. */ + if (!mask || plen >= HUGE_STRING_LEN*1024 + || ((opcode & 0x8) && plen > 125)) { + break; + } - /* Decide if we need to react to the opcode or not */ - if (opcode == 0x09) { /* ping */ - char frame[2]; - apr_bucket *b; + rv = lua_websocket_readbytes(c, brigade, (char*) mask_bytes, 4); + if (rv != APR_SUCCESS) { + break; + } - frame[0] = 0x8A; - frame[1] = 0; + buffer = apr_palloc(pool, plen + 1); + buffer[plen] = 0; + rv = lua_websocket_readbytes(c, brigade, buffer, plen); + if (rv != APR_SUCCESS) { + break; + } + for (n = 0; n < plen; n++) { + buffer[n] ^= mask_bytes[n%4]; + } - /* Pong! */ - b = apr_bucket_transient_create(frame, 2, c->bucket_alloc); - APR_BRIGADE_INSERT_TAIL(brigade, b); + if (opcode == 0x09) { /* ping */ + apr_bucket *b; - rv = ap_pass_brigade(c->output_filters, brigade); - apr_brigade_cleanup(brigade); + header[0] = 0x8A; + header[1] = (unsigned char) plen; - if (rv != APR_SUCCESS) { - return 0; - } + /* Pong, with the ping's payload */ + b = apr_bucket_transient_create((char *) header, 2, + c->bucket_alloc); + APR_BRIGADE_INSERT_TAIL(brigade, b); + b = apr_bucket_transient_create(buffer, plen, c->bucket_alloc); + APR_BRIGADE_INSERT_TAIL(brigade, b); + b = apr_bucket_flush_create(c->bucket_alloc); + APR_BRIGADE_INSERT_TAIL(brigade, b); - do_read = 1; - } + rv = ap_pass_brigade(c->output_filters, brigade); + apr_brigade_cleanup(brigade); + if (rv != APR_SUCCESS) { + break; } + continue; + } + if (opcode == 0x0A) { /* unsolicited pong */ + continue; + } + if (opcode == 0x08) { /* close */ + break; } + + ap_log_rerror(APLOG_MARK, APLOG_TRACE1, 0, r, + "Websocket: Frame contained %" APR_SIZE_T_FMT \ + " bytes, pushed to Lua stack", plen); + + lua_pushlstring(L, buffer, plen); /* push to stack */ + lua_pushboolean(L, fin); /* push FIN bit to stack as boolean */ + ret = 2; + break; } - return 0; + + return ret; } From 18aa967d0aa609eb586ec13f4cde1cfbe0b3a8e0 Mon Sep 17 00:00:00 2001 From: Joe Orton Date: Thu, 12 Nov 2026 17:44:21 +0000 Subject: [PATCH 05/18] * modules/lua/lua_request.c (lua_ap_regex): Pass the full size of the matches array to ap_regexec(). * changes-entries/lua-fixes.txt: Update. Fixes: 49fcd96e73 (SVN r1433967) Co-Authored-By: Claude Opus 5.5 --- changes-entries/lua-fixes.txt | 3 ++- modules/lua/lua_request.c | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/changes-entries/lua-fixes.txt b/changes-entries/lua-fixes.txt index a23186c6b4f..a22faf2ea11 100644 --- a/changes-entries/lua-fixes.txt +++ b/changes-entries/lua-fixes.txt @@ -1,2 +1,3 @@ *) mod_lua: Fix r:wsread() handling of partially received, empty and - control websocket frames. [Joe Orton] + control websocket frames, and r:regex() with 25 capture groups. + [Joe Orton] diff --git a/modules/lua/lua_request.c b/modules/lua/lua_request.c index 672f6c72e86..e73b4f08c1d 100644 --- a/modules/lua/lua_request.c +++ b/modules/lua/lua_request.c @@ -1239,7 +1239,7 @@ static int lua_ap_regex(lua_State *L) return 2; } - rv = ap_regexec(®ex, source, MODLUA_MAX_REG_MATCH, matches, 0); + rv = ap_regexec(®ex, source, MODLUA_MAX_REG_MATCH+1, matches, 0); if (rv == AP_REG_NOMATCH) { lua_pushboolean(L, 0); return 1; From 0fb541846deb8a014af745d245ce1bdff639370f Mon Sep 17 00:00:00 2001 From: Joe Orton Date: Thu, 12 Nov 2026 17:46:23 +0000 Subject: [PATCH 06/18] mod_lua: Cap memory use of r:regex(): * modules/lua/lua_request.c (lua_ap_regex): Free the compiled pattern, push captures without copying them into r->pool, and use a stack buffer for error messages. * changes-entries/lua-wsread-memory.txt: Update. Fixes: 3b6cdb0f92 (SVN r1420377) Co-Authored-By: Claude Opus 5.5 --- changes-entries/lua-wsread-memory.txt | 2 +- modules/lua/lua_request.c | 19 +++++++++---------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/changes-entries/lua-wsread-memory.txt b/changes-entries/lua-wsread-memory.txt index 609907f0f19..5f2ba84f0bb 100644 --- a/changes-entries/lua-wsread-memory.txt +++ b/changes-entries/lua-wsread-memory.txt @@ -1 +1 @@ - *) mod_lua: Cap memory use of r:wsread(). [Joe Orton] + *) mod_lua: Cap memory use of r:wsread() and r:regex(). [Joe Orton] diff --git a/modules/lua/lua_request.c b/modules/lua/lua_request.c index e73b4f08c1d..5b67d6413f6 100644 --- a/modules/lua/lua_request.c +++ b/modules/lua/lua_request.c @@ -1202,20 +1202,19 @@ static int lua_ap_expr(lua_State *L) */ static int lua_ap_regex(lua_State *L) { - request_rec *r; int i, rv, flags; const char *pattern, *source; - char *err; + char err[256]; ap_regex_t regex; ap_regmatch_t matches[MODLUA_MAX_REG_MATCH+1]; luaL_checktype(L, 1, LUA_TUSERDATA); luaL_checktype(L, 2, LUA_TSTRING); luaL_checktype(L, 3, LUA_TSTRING); - r = ap_lua_check_request_rec(L, 1); + ap_lua_check_request_rec(L, 1); source = lua_tostring(L, 2); pattern = lua_tostring(L, 3); flags = luaL_optinteger(L, 4, 0); @@ -1223,24 +1222,24 @@ static int lua_ap_regex(lua_State *L) rv = ap_regcomp(®ex, pattern, flags); if (rv) { lua_pushboolean(L, 0); - err = apr_palloc(r->pool, 256); - ap_regerror(rv, ®ex, err, 256); + ap_regerror(rv, ®ex, err, sizeof err); lua_pushstring(L, err); return 2; } if (regex.re_nsub > MODLUA_MAX_REG_MATCH) { lua_pushboolean(L, 0); - err = apr_palloc(r->pool, 64); - apr_snprintf(err, 64, + apr_snprintf(err, sizeof err, "regcomp found %d matches; only %d allowed.", regex.re_nsub, MODLUA_MAX_REG_MATCH); + ap_regfree(®ex); lua_pushstring(L, err); return 2; } rv = ap_regexec(®ex, source, MODLUA_MAX_REG_MATCH+1, matches, 0); if (rv == AP_REG_NOMATCH) { + ap_regfree(®ex); lua_pushboolean(L, 0); return 1; } @@ -1249,14 +1248,14 @@ static int lua_ap_regex(lua_State *L) for (i = 0; i <= regex.re_nsub; i++) { lua_pushinteger(L, i); if (matches[i].rm_so >= 0 && matches[i].rm_eo >= 0) - lua_pushstring(L, - apr_pstrndup(r->pool, source + matches[i].rm_so, - matches[i].rm_eo - matches[i].rm_so)); + lua_pushlstring(L, source + matches[i].rm_so, + matches[i].rm_eo - matches[i].rm_so); else lua_pushnil(L); lua_settable(L, -3); } + ap_regfree(®ex); return 1; } From 3cf7ce9a53673662849a9015276abe166b6c48e8 Mon Sep 17 00:00:00 2001 From: Joe Orton Date: Thu, 12 Nov 2026 17:47:01 +0000 Subject: [PATCH 07/18] * modules/lua/lua_request.c (lua_ap_set_document_root, lua_ap_set_context_info): Copy the strings into r->pool. * changes-entries/lua-fixes.txt: Update. Fixes: 3b6cdb0f92 (SVN r1420377) Co-Authored-By: Claude Opus 5.5 --- changes-entries/lua-fixes.txt | 4 ++-- modules/lua/lua_request.c | 5 +++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/changes-entries/lua-fixes.txt b/changes-entries/lua-fixes.txt index a22faf2ea11..4a8c59cbcaf 100644 --- a/changes-entries/lua-fixes.txt +++ b/changes-entries/lua-fixes.txt @@ -1,3 +1,3 @@ *) mod_lua: Fix r:wsread() handling of partially received, empty and - control websocket frames, and r:regex() with 25 capture groups. - [Joe Orton] + control websocket frames, r:regex() with 25 capture groups, and + r:set_document_root(). [Joe Orton] diff --git a/modules/lua/lua_request.c b/modules/lua/lua_request.c index 5b67d6413f6..6e37501bcd2 100644 --- a/modules/lua/lua_request.c +++ b/modules/lua/lua_request.c @@ -1509,7 +1509,7 @@ static int lua_ap_set_document_root(lua_State *L) luaL_checktype(L, 2, LUA_TSTRING); r = ap_lua_check_request_rec(L, 1); root = lua_tostring(L, 2); - ap_set_document_root(r, root); + ap_set_document_root(r, apr_pstrdup(r->pool, root)); return 0; } @@ -1676,7 +1676,8 @@ static int lua_ap_set_context_info(lua_State *L) prefix = lua_tostring(L, 2); luaL_checktype(L, 3, LUA_TSTRING); document_root = lua_tostring(L, 3); - ap_set_context_info(r, prefix, document_root); + ap_set_context_info(r, apr_pstrdup(r->pool, prefix), + apr_pstrdup(r->pool, document_root)); return 0; } From f056d1271f13fd9b0d1317126a56af5934bccb73 Mon Sep 17 00:00:00 2001 From: Joe Orton Date: Thu, 12 Nov 2026 17:54:53 +0000 Subject: [PATCH 08/18] mod_lua: Cap memory use of string methods such as r:escape(): * modules/lua/lua_request.c (req_construct_url, req_escape_html, lua_apr_b64encode, lua_apr_b64decode, lua_ap_unescape, lua_ap_escape, lua_apr_htpassword, lua_ap_expr, lua_ap_runtime_dir_relative, lua_ap_getdir, lua_ap_os_escape_path, lua_ap_escape_logitem, lua_ivm_get, lua_ivm_set): Allocate from the temporary pool. * changes-entries/lua-wsread-memory.txt: Update. Co-Authored-By: Claude Opus 5.5 --- changes-entries/lua-wsread-memory.txt | 3 ++- modules/lua/lua_request.c | 34 ++++++++++++++------------- 2 files changed, 20 insertions(+), 17 deletions(-) diff --git a/changes-entries/lua-wsread-memory.txt b/changes-entries/lua-wsread-memory.txt index 5f2ba84f0bb..0373a2b3adf 100644 --- a/changes-entries/lua-wsread-memory.txt +++ b/changes-entries/lua-wsread-memory.txt @@ -1 +1,2 @@ - *) mod_lua: Cap memory use of r:wsread() and r:regex(). [Joe Orton] + *) mod_lua: Cap memory use of r:wsread(), r:regex() and other request + methods. [Joe Orton] diff --git a/modules/lua/lua_request.c b/modules/lua/lua_request.c index 6e37501bcd2..912fc229dd3 100644 --- a/modules/lua/lua_request.c +++ b/modules/lua/lua_request.c @@ -583,7 +583,7 @@ static int req_construct_url(lua_State *L) { request_rec *r = ap_lua_check_request_rec(L, 1); const char *name = luaL_checkstring(L, 2); - lua_pushstring(L, ap_construct_url(r->pool, name, r)); + lua_pushstring(L, ap_construct_url(lua_temp_pool(r), name, r)); return 1; } @@ -592,7 +592,7 @@ static int req_escape_html(lua_State *L) { request_rec *r = ap_lua_check_request_rec(L, 1); const char *s = luaL_checkstring(L, 2); - lua_pushstring(L, ap_escape_html(r->pool, s)); + lua_pushstring(L, ap_escape_html(lua_temp_pool(r), s)); return 1; } @@ -905,7 +905,7 @@ static int lua_apr_b64encode(lua_State *L) plain = lua_tolstring(L, 2, &plain_len); encoded_len = apr_base64_encode_len(plain_len); if (encoded_len) { - encoded = apr_palloc(r->pool, encoded_len); + encoded = apr_palloc(lua_temp_pool(r), encoded_len); encoded_len = apr_base64_encode(encoded, plain, plain_len); if (encoded_len > 0 && encoded[encoded_len - 1] == '\0') encoded_len--; @@ -930,7 +930,7 @@ static int lua_apr_b64decode(lua_State *L) encoded = lua_tolstring(L, 2, &encoded_len); decoded_len = apr_base64_decode_len(encoded); if (decoded_len) { - plain = apr_palloc(r->pool, decoded_len); + plain = apr_palloc(lua_temp_pool(r), decoded_len); decoded_len = apr_base64_decode(plain, encoded); if (decoded_len > 0 && plain[decoded_len - 1] == '\0') decoded_len--; @@ -953,7 +953,7 @@ static int lua_ap_unescape(lua_State *L) r = ap_lua_check_request_rec(L, 1); luaL_checktype(L, 2, LUA_TSTRING); escaped = lua_tolstring(L, 2, &x); - plain = apr_pstrdup(r->pool, escaped); + plain = apr_pstrdup(lua_temp_pool(r), escaped); y = ap_unescape_urlencoded(plain); if (!y) { lua_pushstring(L, plain); @@ -974,7 +974,7 @@ static int lua_ap_escape(lua_State *L) r = ap_lua_check_request_rec(L, 1); luaL_checktype(L, 2, LUA_TSTRING); plain = lua_tolstring(L, 2, &x); - escaped = ap_escape_urlencoded(r->pool, plain); + escaped = ap_escape_urlencoded(lua_temp_pool(r), plain); lua_pushstring(L, escaped); return 1; } @@ -1033,11 +1033,11 @@ static int lua_apr_htpassword(lua_State *L) r = ap_lua_check_request_rec(L, 1); luaL_checktype(L, 2, LUA_TSTRING); - ctx.passwd = apr_pstrdup(r->pool, lua_tostring(L, 2)); ctx.alg = luaL_optinteger(L, 3, ALG_APMD5); ctx.cost = luaL_optinteger(L, 4, 0); - ctx.pool = r->pool; - ctx.out = apr_pcalloc(r->pool, MAX_PASSWD_LEN); + ctx.pool = lua_temp_pool(r); + ctx.passwd = apr_pstrdup(ctx.pool, lua_tostring(L, 2)); + ctx.out = apr_pcalloc(ctx.pool, MAX_PASSWD_LEN); ctx.out_len = MAX_PASSWD_LEN; if (mk_password_hash(&ctx)) { lua_pushboolean(L, 0); @@ -1165,6 +1165,7 @@ static int lua_ap_expr(lua_State *L) const char *expr, *err; ap_expr_info_t res; + apr_pool_t *p; luaL_checktype(L, 1, LUA_TUSERDATA); luaL_checktype(L, 2, LUA_TSTRING); @@ -1177,7 +1178,8 @@ static int lua_ap_expr(lua_State *L) res.line_number = 0; res.module_index = APLOG_MODULE_INDEX; - err = ap_expr_parse(r->pool, r->pool, &res, expr, NULL); + p = lua_temp_pool(r); + err = ap_expr_parse(p, p, &res, expr, NULL); if (!err) { x = ap_expr_exec(r, &res, &err); lua_pushboolean(L, x); @@ -1492,7 +1494,7 @@ static int lua_ap_runtime_dir_relative(lua_State *L) luaL_checktype(L, 1, LUA_TUSERDATA); r = ap_lua_check_request_rec(L, 1); file = luaL_optstring(L, 2, "."); - lua_pushstring(L, ap_runtime_dir_relative(r->pool, file)); + lua_pushstring(L, ap_runtime_dir_relative(lua_temp_pool(r), file)); return 1; } @@ -1529,7 +1531,7 @@ static int lua_ap_getdir(lua_State *L) luaL_checktype(L, 2, LUA_TSTRING); r = ap_lua_check_request_rec(L, 1); directory = lua_tostring(L, 2); - if (apr_dir_open(&thedir, directory, r->pool) == APR_SUCCESS) { + if (apr_dir_open(&thedir, directory, lua_temp_pool(r)) == APR_SUCCESS) { int i = 0; lua_newtable(L); do { @@ -1703,7 +1705,7 @@ static int lua_ap_os_escape_path(lua_State *L) path = lua_tostring(L, 2); if (lua_isboolean(L, 3)) partial = lua_toboolean(L, 3); - returnValue = ap_os_escape_path(r->pool, path, partial); + returnValue = ap_os_escape_path(lua_temp_pool(r), path, partial); lua_pushstring(L, returnValue); return 1; } @@ -1725,7 +1727,7 @@ static int lua_ap_escape_logitem(lua_State *L) r = ap_lua_check_request_rec(L, 1); luaL_checktype(L, 2, LUA_TSTRING); str = lua_tostring(L, 2); - returnValue = ap_escape_logitem(r->pool, str); + returnValue = ap_escape_logitem(lua_temp_pool(r), str); lua_pushstring(L, returnValue); return 1; } @@ -2048,7 +2050,7 @@ static int lua_ivm_get(lua_State *L) lua_ivm_object *object = NULL; request_rec *r = ap_lua_check_request_rec(L, 1); key = luaL_checkstring(L, 2); - raw_key = apr_pstrcat(r->pool, "lua_ivm_", key, NULL); + raw_key = apr_pstrcat(lua_temp_pool(r), "lua_ivm_", key, NULL); apr_global_mutex_lock(lua_ivm_mutex); pool = *((apr_pool_t**) apr_shm_baseaddr_get(lua_ivm_shm)); apr_pool_userdata_get((void **)&object, raw_key, pool); @@ -2076,7 +2078,7 @@ static int lua_ivm_set(lua_State *L) request_rec *r = ap_lua_check_request_rec(L, 1); key = luaL_checkstring(L, 2); luaL_checkany(L, 3); - raw_key = apr_pstrcat(r->pool, "lua_ivm_", key, NULL); + raw_key = apr_pstrcat(lua_temp_pool(r), "lua_ivm_", key, NULL); apr_global_mutex_lock(lua_ivm_mutex); pool = *((apr_pool_t**) apr_shm_baseaddr_get(lua_ivm_shm)); From f7bb9144840c47c7de72f7b49e686cf73de5373e Mon Sep 17 00:00:00 2001 From: Joe Orton Date: Thu, 12 Nov 2026 17:57:22 +0000 Subject: [PATCH 09/18] mod_lua: Cap memory use of r:md5(), r:sha1() and other methods: * modules/lua/lua_request.c (lua_apr_md5): Hash with apr_md5_update() into a stack buffer. (lua_apr_sha1, lua_ap_scoreboard_worker): Use stack buffers. (read_cfg_tree): Drop the request_rec argument; build the directive string with lua_pushfstring(). (lua_ap_get_config, lua_ap_get_active_config): Adjust. Co-Authored-By: Claude Opus 5.5 --- modules/lua/lua_request.c | 44 +++++++++++++++++++++------------------ 1 file changed, 24 insertions(+), 20 deletions(-) diff --git a/modules/lua/lua_request.c b/modules/lua/lua_request.c index 912fc229dd3..a1529508943 100644 --- a/modules/lua/lua_request.c +++ b/modules/lua/lua_request.c @@ -28,6 +28,7 @@ #include "apr_thread_mutex.h" #include "apr_tables.h" #include "util_cookies.h" +#include "util_charset.h" #define APR_WANT_BYTEFUNC #include "apr_want.h" @@ -984,15 +985,24 @@ static int lua_ap_escape(lua_State *L) */ static int lua_apr_md5(lua_State *L) { + unsigned char digest[APR_MD5_DIGESTSIZE]; + apr_md5_ctx_t md5; const char *buffer; - char *result; + char result[APR_MD5_DIGESTSIZE * 2 + 1]; size_t len; - request_rec *r; - r = ap_lua_check_request_rec(L, 1); + ap_lua_check_request_rec(L, 1); luaL_checktype(L, 2, LUA_TSTRING); buffer = lua_tolstring(L, 2, &len); - result = ap_md5_binary(r->pool, (const unsigned char *)buffer, len); + /* As ap_md5_binary(), which takes an int length. */ + apr_md5_init(&md5); +#if APR_CHARSET_EBCDIC + apr_md5_set_xlate(&md5, ap_hdrs_to_ascii); +#endif + apr_md5_update(&md5, buffer, len); + apr_md5_final(digest, &md5); + + ap_bin2hex(digest, sizeof(digest), result); lua_pushstring(L, result); return 1; } @@ -1005,13 +1015,11 @@ static int lua_apr_sha1(lua_State *L) unsigned char digest[APR_SHA1_DIGESTSIZE]; apr_sha1_ctx_t sha1; const char *buffer; - char *result; + char result[APR_SHA1_DIGESTSIZE * 2 + 1]; size_t len; - request_rec *r; - r = ap_lua_check_request_rec(L, 1); + ap_lua_check_request_rec(L, 1); luaL_checktype(L, 2, LUA_TSTRING); - result = apr_pcalloc(r->pool, sizeof(digest) * 2 + 1); buffer = lua_tolstring(L, 2, &len); apr_sha1_init(&sha1); apr_sha1_update(&sha1, buffer, len); @@ -1327,7 +1335,7 @@ static int lua_ap_scoreboard_process(lua_State *L) static int lua_ap_scoreboard_worker(lua_State *L) { int i, j; - worker_score *ws_record = NULL; + worker_score ws_buf, *ws_record = &ws_buf; request_rec *r = NULL; luaL_checktype(L, 1, LUA_TUSERDATA); @@ -1339,8 +1347,6 @@ static int lua_ap_scoreboard_worker(lua_State *L) i = lua_tointeger(L, 2); j = lua_tointeger(L, 3); - ws_record = apr_palloc(r->pool, sizeof *ws_record); - ap_copy_scoreboard_worker(ws_record, i, j); if (ws_record) { lua_newtable(L); @@ -2682,9 +2688,8 @@ static int req_newindex(lua_State *L) /* helper function for walking config trees */ -static void read_cfg_tree(lua_State *L, request_rec *r, ap_directive_t *rcfg) { +static void read_cfg_tree(lua_State *L, ap_directive_t *rcfg) { int x = 0; - const char* value; ap_directive_t *cfg; lua_newtable(L); @@ -2692,9 +2697,8 @@ static void read_cfg_tree(lua_State *L, request_rec *r, ap_directive_t *rcfg) { x++; lua_pushnumber(L, x); lua_newtable(L); - value = apr_psprintf(r->pool, "%s %s", cfg->directive, cfg->args); lua_pushstring(L, "directive"); - lua_pushstring(L, value); + lua_pushfstring(L, "%s %s", cfg->directive, cfg->args); lua_settable(L, -3); lua_pushstring(L, "file"); lua_pushstring(L, cfg->filename); @@ -2704,7 +2708,7 @@ static void read_cfg_tree(lua_State *L, request_rec *r, ap_directive_t *rcfg) { lua_settable(L, -3); if (cfg->first_child) { lua_pushstring(L, "children"); - read_cfg_tree(L, r, cfg->first_child); + read_cfg_tree(L, cfg->first_child); lua_settable(L, -3); } lua_settable(L, -3); @@ -2712,8 +2716,8 @@ static void read_cfg_tree(lua_State *L, request_rec *r, ap_directive_t *rcfg) { } static int lua_ap_get_config(lua_State *L) { - request_rec *r = ap_lua_check_request_rec(L, 1); - read_cfg_tree(L, r, ap_conftree); + ap_lua_check_request_rec(L, 1); + read_cfg_tree(L, ap_conftree); return 1; } @@ -2730,12 +2734,12 @@ static int lua_ap_get_active_config(lua_State *L) { for (subdir = dir->first_child; subdir; subdir = subdir->next) { if (ap_strcasecmp_match(subdir->directive, "servername") && !ap_strcasecmp_match(r->hostname, subdir->args)) { - read_cfg_tree(L, r, dir->first_child); + read_cfg_tree(L, dir->first_child); return 1; } if (ap_strcasecmp_match(subdir->directive, "serveralias") && !ap_strcasecmp_match(r->hostname, subdir->args)) { - read_cfg_tree(L, r, dir->first_child); + read_cfg_tree(L, dir->first_child); return 1; } } From 149c7c7aa58b75cb84cf62ae568b5df0d515dcdd Mon Sep 17 00:00:00 2001 From: Joe Orton Date: Thu, 12 Nov 2026 17:59:18 +0000 Subject: [PATCH 10/18] mod_lua: Cap memory use of request table and option fields: * modules/lua/lua_request.c (req_table): New function. (req_headers_in, req_headers_out, req_err_headers_out, req_subprocess_env, req_notes): Use it. (req_flags_string): New function. (lua_ap_options, lua_ap_allowoverrides): Rebuild the string only when the flags change. * test/modules/lua/test_003_request.py (test_lua_003_04_fields): New test. * test/modules/lua/htdocs/test1/lua/fields.lua: New file. Co-Authored-By: Claude Opus 5.5 --- modules/lua/lua_request.c | 87 +++++++++++++------- test/modules/lua/htdocs/test1/lua/fields.lua | 12 +++ test/modules/lua/test_003_request.py | 12 +++ 3 files changed, 83 insertions(+), 28 deletions(-) create mode 100644 test/modules/lua/htdocs/test1/lua/fields.lua diff --git a/modules/lua/lua_request.c b/modules/lua/lua_request.c index a1529508943..86ae6855db4 100644 --- a/modules/lua/lua_request.c +++ b/modules/lua/lua_request.c @@ -739,49 +739,50 @@ static int req_assbackwards_field(request_rec *r) return r->assbackwards; } +/* Returns the req_table_t for table t, created once per request and + * kept as r->pool userdata under key, since a script may read the + * field repeatedly for the life of the request. */ +static req_table_t *req_table(request_rec *r, apr_table_t *t, + const char *key, const char *n) +{ + req_table_t *rt; + + apr_pool_userdata_get((void **)&rt, key, r->pool); + if (rt == NULL || rt->t != t) { + rt = apr_palloc(r->pool, sizeof *rt); + rt->r = r; + rt->t = t; + rt->n = n; + apr_pool_userdata_setn(rt, key, NULL, r->pool); + } + return rt; +} + static req_table_t* req_headers_in(request_rec *r) { - req_table_t* t = apr_palloc(r->pool, sizeof(req_table_t)); - t->r = r; - t->t = r->headers_in; - t->n = "headers_in"; - return t; + return req_table(r, r->headers_in, "mod_lua_headers_in", "headers_in"); } static req_table_t* req_headers_out(request_rec *r) { - req_table_t* t = apr_palloc(r->pool, sizeof(req_table_t)); - t->r = r; - t->t = r->headers_out; - t->n = "headers_out"; - return t; + return req_table(r, r->headers_out, "mod_lua_headers_out", "headers_out"); } static req_table_t* req_err_headers_out(request_rec *r) { - req_table_t* t = apr_palloc(r->pool, sizeof(req_table_t)); - t->r = r; - t->t = r->err_headers_out; - t->n = "err_headers_out"; - return t; + return req_table(r, r->err_headers_out, "mod_lua_err_headers_out", + "err_headers_out"); } static req_table_t* req_subprocess_env(request_rec *r) { - req_table_t* t = apr_palloc(r->pool, sizeof(req_table_t)); - t->r = r; - t->t = r->subprocess_env; - t->n = "subprocess_env"; - return t; + return req_table(r, r->subprocess_env, "mod_lua_subprocess_env", + "subprocess_env"); } static req_table_t* req_notes(request_rec *r) { - req_table_t* t = apr_palloc(r->pool, sizeof(req_table_t)); - t->r = r; - t->t = r->notes; - t->n = "notes"; - return t; + return req_table(r, r->notes, "mod_lua_notes", "notes"); } static int req_ssl_is_https_field(request_rec *r) @@ -806,16 +807,42 @@ static int lua_ap_rflush (lua_State *L) { } +/* A string built from a set of flags, kept as r->pool userdata under + * key and rebuilt only when the flags change, since a script may read + * the field repeatedly for the life of the request. */ +typedef struct { + int flags; + const char *s; +} req_flags_string_t; + +static req_flags_string_t *req_flags_string(request_rec *r, const char *key) +{ + req_flags_string_t *fs; + + apr_pool_userdata_get((void **)&fs, key, r->pool); + if (fs == NULL) { + fs = apr_pcalloc(r->pool, sizeof *fs); + apr_pool_userdata_setn(fs, key, NULL, r->pool); + } + return fs; +} + static const char* lua_ap_options(request_rec* r) { int opts; + req_flags_string_t *fs = req_flags_string(r, "mod_lua_options"); opts = ap_allow_options(r); - return apr_psprintf(r->pool, "%s %s %s %s %s %s", (opts&OPT_INDEXES) ? "Indexes" : "", (opts&OPT_INCLUDES) ? "Includes" : "", (opts&OPT_SYM_LINKS) ? "FollowSymLinks" : "", (opts&OPT_EXECCGI) ? "ExecCGI" : "", (opts&OPT_MULTI) ? "MultiViews" : "", (opts&OPT_ALL) == OPT_ALL ? "All" : "" ); + if (fs->s == NULL || fs->flags != opts) { + fs->flags = opts; + fs->s = apr_psprintf(r->pool, "%s %s %s %s %s %s", (opts&OPT_INDEXES) ? "Indexes" : "", (opts&OPT_INCLUDES) ? "Includes" : "", (opts&OPT_SYM_LINKS) ? "FollowSymLinks" : "", (opts&OPT_EXECCGI) ? "ExecCGI" : "", (opts&OPT_MULTI) ? "MultiViews" : "", (opts&OPT_ALL) == OPT_ALL ? "All" : "" ); + } + return fs->s; } static const char* lua_ap_allowoverrides(request_rec* r) { int opts; + req_flags_string_t *fs; opts = ap_allow_overrides(r); if ( (opts & OR_ALL) == OR_ALL) { return "All"; @@ -823,8 +850,12 @@ static const char* lua_ap_allowoverrides(request_rec* r) else if (opts == OR_NONE) { return "None"; } - return apr_psprintf(r->pool, "%s %s %s %s %s", (opts & OR_LIMIT) ? "Limit" : "", (opts & OR_OPTIONS) ? "Options" : "", (opts & OR_FILEINFO) ? "FileInfo" : "", (opts & OR_AUTHCFG) ? "AuthCfg" : "", (opts & OR_INDEXES) ? "Indexes" : "" ); - + fs = req_flags_string(r, "mod_lua_allowoverrides"); + if (fs->s == NULL || fs->flags != opts) { + fs->flags = opts; + fs->s = apr_psprintf(r->pool, "%s %s %s %s %s", (opts & OR_LIMIT) ? "Limit" : "", (opts & OR_OPTIONS) ? "Options" : "", (opts & OR_FILEINFO) ? "FileInfo" : "", (opts & OR_AUTHCFG) ? "AuthCfg" : "", (opts & OR_INDEXES) ? "Indexes" : "" ); + } + return fs->s; } static int lua_ap_started(request_rec* r) diff --git a/test/modules/lua/htdocs/test1/lua/fields.lua b/test/modules/lua/htdocs/test1/lua/fields.lua new file mode 100644 index 00000000000..b1388ee84a7 --- /dev/null +++ b/test/modules/lua/htdocs/test1/lua/fields.lua @@ -0,0 +1,12 @@ +-- Reads and writes request tables through separate accesses of the +-- same field, and prints the options and overrides strings. +function handle(r) + r.notes["lua-a"] = "1" + local n = r.notes + n["lua-b"] = "2" + r.headers_out["X-Lua-Notes"] = r.notes["lua-a"] .. n["lua-b"] + r.headers_out["X-Lua-Host"] = r.headers_in["Host"] + r.content_type = "text/plain" + r:puts(r.options, "|", r.allowoverrides, "\n") + return apache2.OK +end diff --git a/test/modules/lua/test_003_request.py b/test/modules/lua/test_003_request.py index b873ebe07e2..20ce0f04bb4 100644 --- a/test/modules/lua/test_003_request.py +++ b/test/modules/lua/test_003_request.py @@ -15,6 +15,8 @@ def _class_scope(self, env): 'base': textwrap.dedent(f""" SetHandler lua-script + Options +Indexes + AllowOverride FileInfo """), }) @@ -42,3 +44,13 @@ def test_lua_003_02_set_document_root(self, env): def test_lua_003_03_set_context_info(self, env): assert self.get(env, "/lua/docroot.lua?set_context_info") == \ "x" * 1000 + "/prefixN|" + "x" * 1000 + "/rootN" + + # Request tables read and written through repeated field accesses. + def test_lua_003_04_fields(self, env): + r = env.curl_get(env.mkurl("http", "test1", "/lua/fields.lua")) + assert r.response["status"] == 200, r.response + assert r.response["header"]["x-lua-notes"] == "12" + assert r.response["header"]["x-lua-host"].startswith("test1.") + options, overrides = r.response["body"].decode().strip().split("|") + assert "Indexes" in options.split() + assert overrides.split() == ["FileInfo"] From 50fc6bf824bf8e23aab50a9734e32a831ce7a250 Mon Sep 17 00:00:00 2001 From: Joe Orton Date: Thu, 12 Nov 2026 18:00:26 +0000 Subject: [PATCH 11/18] * test/modules/lua/test_001_websocket.py (test_lua_001_07_wspeek_empty, test_lua_001_08_wspeek_read): New tests. * test/modules/lua/htdocs/test1/lua/wsread.lua (handle): Answer "peek" and "peekread". Co-Authored-By: Claude Opus 5.5 --- test/modules/lua/htdocs/test1/lua/wsread.lua | 15 +++++++++++---- test/modules/lua/test_001_websocket.py | 20 ++++++++++++++++++++ 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/test/modules/lua/htdocs/test1/lua/wsread.lua b/test/modules/lua/htdocs/test1/lua/wsread.lua index 754bef4fe9d..6983ed66cc5 100644 --- a/test/modules/lua/htdocs/test1/lua/wsread.lua +++ b/test/modules/lua/htdocs/test1/lua/wsread.lua @@ -1,7 +1,8 @@ --- The r:wsread() loop from the report. Two text messages get an --- answer: "sync" is acknowledged, and "rss" is answered with this --- child's VmRSS in kB, after a full Lua GC so that only memory held --- outside the Lua heap is left. +-- The r:wsread() loop from the report. Some text messages get an +-- answer: "sync" is acknowledged; "rss" is answered with this child's +-- VmRSS in kB, after a full Lua GC so that only memory held outside +-- the Lua heap is left; "peek" with what r:wspeek() returns; and +-- "peekread" by waiting for r:wspeek() and echoing the next message. function rss() collectgarbage() for line in io.lines("/proc/self/status") do @@ -18,6 +19,12 @@ function handle(r) if data == nil then break end if data == "sync" then r:wswrite("ok") end if data == "rss" then r:wswrite(rss()) end + if data == "peek" then r:wswrite(tostring(r:wspeek())) end + if data == "peekread" then + while not r:wspeek() do r.usleep(10000) end + local next = r:wsread() + r:wswrite(next) + end end end return apache2.OK diff --git a/test/modules/lua/test_001_websocket.py b/test/modules/lua/test_001_websocket.py index e7ca8c1ceb1..27f4ba47f61 100644 --- a/test/modules/lua/test_001_websocket.py +++ b/test/modules/lua/test_001_websocket.py @@ -178,3 +178,23 @@ def test_lua_001_06_unmasked_frame(self, env): assert b"ok" not in data, data finally: sock.close() + + # r:wspeek() is false with nothing to read. + def test_lua_001_07_wspeek_empty(self, env): + sock = self.upgrade(env) + try: + sock.sendall(self.frame(b"peek", opcode=0x1)) + assert self.recv_frame(sock) == b"false" + finally: + sock.close() + + # r:wspeek() does not consume what it finds. + def test_lua_001_08_wspeek_read(self, env): + sock = self.upgrade(env) + try: + sock.sendall(self.frame(b"peekread", opcode=0x1)) + time.sleep(0.5) + sock.sendall(self.frame(b"hello", opcode=0x1)) + assert self.recv_frame(sock) == b"hello" + finally: + sock.close() From c592f2c4a0e2dd71455891e87372bbd080b49b13 Mon Sep 17 00:00:00 2001 From: Joe Orton Date: Thu, 12 Nov 2026 18:02:15 +0000 Subject: [PATCH 12/18] * modules/lua/lua_request.c (lua_websocket_peek): Use a speculative read, return false for an empty brigade, and allocate from the temporary pool. * changes-entries/lua-fixes.txt: Update. Fixes: c92f2cf0ad (SVN r1570208) Co-Authored-By: Claude Opus 5.5 --- changes-entries/lua-fixes.txt | 6 +++--- modules/lua/lua_request.c | 12 +++++------- 2 files changed, 8 insertions(+), 10 deletions(-) diff --git a/changes-entries/lua-fixes.txt b/changes-entries/lua-fixes.txt index 4a8c59cbcaf..0e263529898 100644 --- a/changes-entries/lua-fixes.txt +++ b/changes-entries/lua-fixes.txt @@ -1,3 +1,3 @@ - *) mod_lua: Fix r:wsread() handling of partially received, empty and - control websocket frames, r:regex() with 25 capture groups, and - r:set_document_root(). [Joe Orton] + *) mod_lua: Fix r:wsread() handling of partial, empty and control + websocket frames, r:wspeek() consuming input, r:regex() with 25 + capture groups, and r:set_document_root(). [Joe Orton] diff --git a/modules/lua/lua_request.c b/modules/lua/lua_request.c index 86ae6855db4..b8db6e07023 100644 --- a/modules/lua/lua_request.c +++ b/modules/lua/lua_request.c @@ -2358,20 +2358,18 @@ static apr_status_t lua_websocket_readbytes(conn_rec* c, static int lua_websocket_peek(lua_State *L) { apr_status_t rv; + apr_off_t len = 0; apr_bucket_brigade *brigade; request_rec *r = ap_lua_check_request_rec(L, 1); - brigade = apr_brigade_create(r->connection->pool, - r->connection->bucket_alloc); + brigade = apr_brigade_create(lua_temp_pool(r), r->connection->bucket_alloc); rv = ap_get_brigade(r->connection->input_filters, brigade, - AP_MODE_READBYTES, APR_NONBLOCK_READ, 1); + AP_MODE_SPECULATIVE, APR_NONBLOCK_READ, 1); if (rv == APR_SUCCESS) { - lua_pushboolean(L, 1); - } - else { - lua_pushboolean(L, 0); + rv = apr_brigade_length(brigade, 1, &len); } + lua_pushboolean(L, rv == APR_SUCCESS && len > 0); apr_brigade_cleanup(brigade); return 1; } From d45bec40ff6cef4a12a95542a2ef1dcc99eda2f6 Mon Sep 17 00:00:00 2001 From: Joe Orton Date: Thu, 12 Nov 2026 18:08:58 +0000 Subject: [PATCH 13/18] mod_lua: Cap memory use of r:sendfile(): * modules/lua/lua_request.c (lua_ap_sendfile): Allocate the file and brigade from the temporary pool in place of ap_send_fd(); fail if apr_stat() fails. * test/modules/lua/test_003_request.py (test_lua_003_05_sendfile): New test. * test/modules/lua/htdocs/test1/lua/sendfile.lua, test/modules/lua/htdocs/test1/lua/file.txt: New files. Co-Authored-By: Claude Opus 5.5 --- modules/lua/lua_request.c | 24 +++-- test/modules/lua/htdocs/test1/lua/file.txt | 100 ++++++++++++++++++ .../modules/lua/htdocs/test1/lua/sendfile.lua | 11 ++ test/modules/lua/test_003_request.py | 10 ++ 4 files changed, 137 insertions(+), 8 deletions(-) create mode 100644 test/modules/lua/htdocs/test1/lua/file.txt create mode 100644 test/modules/lua/htdocs/test1/lua/sendfile.lua diff --git a/modules/lua/lua_request.c b/modules/lua/lua_request.c index b8db6e07023..019b5586cbc 100644 --- a/modules/lua/lua_request.c +++ b/modules/lua/lua_request.c @@ -891,26 +891,34 @@ static int lua_ap_sendfile(lua_State *L) apr_finfo_t file_info; const char *filename; request_rec *r; + apr_pool_t *p; luaL_checktype(L, 1, LUA_TUSERDATA); luaL_checktype(L, 2, LUA_TSTRING); r = ap_lua_check_request_rec(L, 1); filename = lua_tostring(L, 2); - apr_stat(&file_info, filename, APR_FINFO_MIN, r->pool); - if (file_info.filetype == APR_NOFILE || file_info.filetype == APR_DIR) { + p = lua_temp_pool(r); + if (apr_stat(&file_info, filename, APR_FINFO_MIN, p) != APR_SUCCESS + || file_info.filetype == APR_NOFILE || file_info.filetype == APR_DIR) { lua_pushboolean(L, 0); } else { - apr_size_t sent; apr_status_t rc; apr_file_t *file; - rc = apr_file_open(&file, filename, APR_READ, APR_OS_DEFAULT, - r->pool); + rc = apr_file_open(&file, filename, APR_READ, APR_OS_DEFAULT, p); if (rc == APR_SUCCESS) { - ap_send_fd(file, r, 0, (apr_size_t)file_info.size, &sent); - apr_file_close(file); - lua_pushinteger(L, sent); + apr_bucket_brigade *bb; + + /* As ap_send_fd(), but with the brigade in the call pool. + * A filter which sets the file aside moves it to its own + * pool, so destroying this one does not close it. */ + bb = apr_brigade_create(p, r->connection->bucket_alloc); + apr_brigade_insert_file(bb, file, 0, file_info.size, p); + rc = ap_pass_brigade(r->output_filters, bb); + apr_brigade_cleanup(bb); + lua_pushinteger(L, rc == APR_SUCCESS + ? (lua_Integer) file_info.size : 0); } else { lua_pushboolean(L, 0); diff --git a/test/modules/lua/htdocs/test1/lua/file.txt b/test/modules/lua/htdocs/test1/lua/file.txt new file mode 100644 index 00000000000..7f34f551b40 --- /dev/null +++ b/test/modules/lua/htdocs/test1/lua/file.txt @@ -0,0 +1,100 @@ +line 0000 +line 0001 +line 0002 +line 0003 +line 0004 +line 0005 +line 0006 +line 0007 +line 0008 +line 0009 +line 0010 +line 0011 +line 0012 +line 0013 +line 0014 +line 0015 +line 0016 +line 0017 +line 0018 +line 0019 +line 0020 +line 0021 +line 0022 +line 0023 +line 0024 +line 0025 +line 0026 +line 0027 +line 0028 +line 0029 +line 0030 +line 0031 +line 0032 +line 0033 +line 0034 +line 0035 +line 0036 +line 0037 +line 0038 +line 0039 +line 0040 +line 0041 +line 0042 +line 0043 +line 0044 +line 0045 +line 0046 +line 0047 +line 0048 +line 0049 +line 0050 +line 0051 +line 0052 +line 0053 +line 0054 +line 0055 +line 0056 +line 0057 +line 0058 +line 0059 +line 0060 +line 0061 +line 0062 +line 0063 +line 0064 +line 0065 +line 0066 +line 0067 +line 0068 +line 0069 +line 0070 +line 0071 +line 0072 +line 0073 +line 0074 +line 0075 +line 0076 +line 0077 +line 0078 +line 0079 +line 0080 +line 0081 +line 0082 +line 0083 +line 0084 +line 0085 +line 0086 +line 0087 +line 0088 +line 0089 +line 0090 +line 0091 +line 0092 +line 0093 +line 0094 +line 0095 +line 0096 +line 0097 +line 0098 +line 0099 diff --git a/test/modules/lua/htdocs/test1/lua/sendfile.lua b/test/modules/lua/htdocs/test1/lua/sendfile.lua new file mode 100644 index 00000000000..aea914c0fab --- /dev/null +++ b/test/modules/lua/htdocs/test1/lua/sendfile.lua @@ -0,0 +1,11 @@ +-- r:sendfile() of a small file, more output after it, then the file +-- again: /lua/sendfile.lua? +function handle(r) + local path = r.document_root .. "/lua/file.txt" + r.content_type = "text/plain" + for _ = 1, tonumber(r.args) do + r:sendfile(path) + r:puts("--\n") + end + return apache2.OK +end diff --git a/test/modules/lua/test_003_request.py b/test/modules/lua/test_003_request.py index 20ce0f04bb4..e2e64961d35 100644 --- a/test/modules/lua/test_003_request.py +++ b/test/modules/lua/test_003_request.py @@ -1,3 +1,4 @@ +import os import textwrap import pytest @@ -54,3 +55,12 @@ def test_lua_003_04_fields(self, env): options, overrides = r.response["body"].decode().strip().split("|") assert "Indexes" in options.split() assert overrides.split() == ["FileInfo"] + + # Output from r:sendfile() is kept in order with output around it. + @pytest.mark.parametrize("count", [1, 3]) + def test_lua_003_05_sendfile(self, env, count): + with open(os.path.join(os.path.dirname(__file__), + "htdocs/test1/lua/file.txt")) as f: + content = f.read() + assert self.get(env, f"/lua/sendfile.lua?{count}") + "\n" == \ + (content + "--\n") * count From f497c72017094834c52ee90f9803e4923e106dac Mon Sep 17 00:00:00 2001 From: Joe Orton Date: Fri, 13 Nov 2026 14:55:53 +0000 Subject: [PATCH 14/18] * test/modules/lua/test_004_body.py: New test suite. * test/modules/lua/test_003_request.py (test_lua_003_06_limit_req_body, test_lua_003_07_mpm_query_unknown, test_lua_003_08_scoreboard_worker_range): New tests. * test/modules/lua/htdocs/test1/lua/body.lua, test/modules/lua/htdocs/test1/lua/info.lua: New files. Co-Authored-By: Claude Opus 5.5 --- test/modules/lua/htdocs/test1/lua/body.lua | 43 +++++++++ test/modules/lua/htdocs/test1/lua/info.lua | 16 ++++ test/modules/lua/test_003_request.py | 13 +++ test/modules/lua/test_004_body.py | 103 +++++++++++++++++++++ 4 files changed, 175 insertions(+) create mode 100644 test/modules/lua/htdocs/test1/lua/body.lua create mode 100644 test/modules/lua/htdocs/test1/lua/info.lua create mode 100644 test/modules/lua/test_004_body.py diff --git a/test/modules/lua/htdocs/test1/lua/body.lua b/test/modules/lua/htdocs/test1/lua/body.lua new file mode 100644 index 00000000000..518ffb1a6b2 --- /dev/null +++ b/test/modules/lua/htdocs/test1/lua/body.lua @@ -0,0 +1,43 @@ +-- r:requestbody() in several modes: /lua/body.lua? +-- read the body, with its length, md5 and the child's RSS growth +-- readmax the body with a 100 byte limit +-- file the body stored to a file, with its length and md5 +-- filemax the body stored to a file with a 100 byte limit +-- fileover the body stored over the file left by an earlier request + +local function rss() + collectgarbage() + for line in io.lines("/proc/self/status") do + local kb = line:match("^VmRSS:%s+(%d+)") + if kb then return tonumber(kb) end + end + return 0 +end + +function handle(r) + local mode = r.args + local path = r.document_root .. "/lua/upload.out" + local res, extra + r.content_type = "text/plain" + if mode == "read" then + local before = rss() + local body, len = r:requestbody() + local growth = rss() - before + r:puts(tostring(len), " ", body and r:md5(body) or "-", " ", + growth, "\n") + elseif mode == "readmax" then + res = {r:requestbody(nil, 100)} + r:puts(tostring(res[2] and res[1] and #res[1] or res[1]), "|", + tostring(res[2]), "\n") + elseif mode == "file" or mode == "filemax" or mode == "fileover" then + if mode ~= "fileover" then os.remove(path) end + res = {r:requestbody(path, mode == "filemax" and 100 or nil)} + local f = io.open(path, "rb") + extra = f and r:md5(f:read("*a")) or "-" + if f then f:close() end + r:puts(tostring(res[1]), "|", tostring(res[2]), "|", extra, "\n") + else + return 404 + end + return apache2.OK +end diff --git a/test/modules/lua/htdocs/test1/lua/info.lua b/test/modules/lua/htdocs/test1/lua/info.lua new file mode 100644 index 00000000000..86ae68400af --- /dev/null +++ b/test/modules/lua/htdocs/test1/lua/info.lua @@ -0,0 +1,16 @@ +-- Prints one request field or call result: /lua/info.lua? +local fields = { + limit_req_body = function(r) return r.limit_req_body end, + started = function(r) return r.started end, + mpm_query = function(r) return r.mpm_query(9999) end, + scoreboard_worker = function(r) return r:scoreboard_worker(100000, 0) end, +} + +function handle(r) + local field = fields[r.args] + if not field then return 404 end + r.content_type = "text/plain" + local value = field(r) + r:puts(tostring(value), "\n") + return apache2.OK +end diff --git a/test/modules/lua/test_003_request.py b/test/modules/lua/test_003_request.py index e2e64961d35..2ede8c5c7b0 100644 --- a/test/modules/lua/test_003_request.py +++ b/test/modules/lua/test_003_request.py @@ -19,6 +19,9 @@ def _class_scope(self, env): Options +Indexes AllowOverride FileInfo + + LimitRequestBody 5000000000 + """), }) conf.add_vhost_test1() @@ -64,3 +67,13 @@ def test_lua_003_05_sendfile(self, env, count): content = f.read() assert self.get(env, f"/lua/sendfile.lua?{count}") + "\n" == \ (content + "--\n") * count + + # A limit over 2^32 is reported in full. + def test_lua_003_06_limit_req_body(self, env): + assert self.get(env, "/lua/info.lua?limit_req_body") == "5000000000" + + def test_lua_003_07_mpm_query_unknown(self, env): + assert self.get(env, "/lua/info.lua?mpm_query") == "nil" + + def test_lua_003_08_scoreboard_worker_range(self, env): + assert self.get(env, "/lua/info.lua?scoreboard_worker") == "nil" diff --git a/test/modules/lua/test_004_body.py b/test/modules/lua/test_004_body.py new file mode 100644 index 00000000000..c148564aa08 --- /dev/null +++ b/test/modules/lua/test_004_body.py @@ -0,0 +1,103 @@ +import hashlib +import os +import socket +import textwrap +import time + +import pytest + +from pyhttpd.conf import HttpdConf + + +BODY = bytes(range(256)) * 4 + + +class TestBody: + + @pytest.fixture(autouse=True, scope='class') + def _class_scope(self, env): + env.require(env.has_shared_module("lua"), "mod_lua not built", + dep="mod_lua") + conf = HttpdConf(env, extras={ + 'base': textwrap.dedent(f""" + + SetHandler lua-script + + """), + }) + conf.add_vhost_test1() + conf.install() + assert env.apache_restart() == 0 + + def post(self, env, mode, body=BODY): + url = env.mkurl("http", "test1", f"/lua/body.lua?{mode}") + fpath = os.path.join(env.gen_dir, "lua-body.bin") + with open(fpath, "wb") as f: + f.write(body) + r = env.curl_raw([url], options=[ + "-H", "Content-Type: application/octet-stream", + "--data-binary", f"@{fpath}"]) + assert r.response["status"] == 200, r.response + return r.response["body"].decode().strip() + + def test_lua_004_01_read(self, env): + length, digest, _ = self.post(env, "read").split() + assert length == str(len(BODY)) + assert digest == hashlib.md5(BODY).hexdigest() + + @staticmethod + def max_child_rss(env) -> int: + """The largest VmRSS of the server's children, in kB.""" + ppid = env.read_pid_file() + assert ppid, "no httpd.pid" + largest = 0 + for pid in filter(str.isdigit, os.listdir("/proc")): + try: + with open(f"/proc/{pid}/status") as f: + status = dict(line.split(":", 1) for line in f) + except OSError: + continue + if int(status.get("PPid", "0")) == ppid and "VmRSS" in status: + largest = max(largest, int(status["VmRSS"].split()[0])) + return largest + + # Memory for the body is not allocated from the Content-Length + # before the body arrives. + @pytest.mark.skipif(not os.path.exists("/proc/self/status"), + reason="needs /proc/self/status") + def test_lua_004_02_declared_length(self, env): + before = self.max_child_rss(env) + sock = socket.create_connection((env.http_addr, env.http_port), + timeout=30) + try: + sock.sendall((f"POST /lua/body.lua?read HTTP/1.1\r\n" + f"Host: test1.{env.http_tld}:{env.http_port}\r\n" + f"Content-Length: {256 * 1024 * 1024}\r\n" + f"\r\n").encode() + b"x" * 10) + # The handler is now waiting in r:requestbody() for the rest. + time.sleep(1) + growth = self.max_child_rss(env) - before + finally: + sock.close() + assert growth < 64 * 1024, \ + f"child RSS grew {growth}kB on 10 bytes of a declared 256MB body" + + # A body over the limit is refused with nil and a message. + def test_lua_004_03_read_max(self, env): + assert self.post(env, "readmax") == \ + "nil|Request body was larger than the permitted size." + assert self.post(env, "readmax", b"x" * 50) == "50|50" + + def test_lua_004_04_file(self, env): + assert self.post(env, "file") == \ + f"{len(BODY)}|nil|{hashlib.md5(BODY).hexdigest()}" + + def test_lua_004_05_file_max(self, env): + assert self.post(env, "filemax") == \ + "nil|Request body was larger than the permitted size.|-" + + # A shorter body replaces a longer file entirely. + def test_lua_004_06_file_overwrite(self, env): + self.post(env, "file") + assert self.post(env, "fileover", b"abc") == \ + f"3|nil|{hashlib.md5(b'abc').hexdigest()}" From 10da342f41e6422ea3ad0ee64090b362d8da0c57 Mon Sep 17 00:00:00 2001 From: Joe Orton Date: Fri, 13 Nov 2026 14:58:54 +0000 Subject: [PATCH 15/18] mod_lua: Cap memory use of r:requestbody(): * modules/lua/lua_request.c (lua_read_body): Collect the body as it arrives rather than allocating the Content-Length up front; refuse a length which does not fit in an apr_size_t; return only OK, DONE or an HTTP status. (lua_write_body): Take the filename and open the file, truncating it, after checking the length against a new maxsize argument; drop the apr_size_t cast; fail on a short read; return only OK, DONE or an HTTP status; set *size to the bytes written. (lua_ap_requestbody): Return nil and a message for a body over the limit when storing it to a file too. (req_parsebody): Terminate the buffer at the flattened length. * changes-entries/lua-wsread-memory.txt, changes-entries/lua-fixes.txt: Update. Fixes: 1a50285096 (SVN r1420286) Fixes: 49fcd96e73 (SVN r1433967) Co-Authored-By: Claude Opus 5.5 --- changes-entries/lua-fixes.txt | 6 +- changes-entries/lua-wsread-memory.txt | 4 +- modules/lua/lua_request.c | 112 +++++++++++++++++--------- 3 files changed, 77 insertions(+), 45 deletions(-) diff --git a/changes-entries/lua-fixes.txt b/changes-entries/lua-fixes.txt index 0e263529898..28b68f80a5a 100644 --- a/changes-entries/lua-fixes.txt +++ b/changes-entries/lua-fixes.txt @@ -1,3 +1,3 @@ - *) mod_lua: Fix r:wsread() handling of partial, empty and control - websocket frames, r:wspeek() consuming input, r:regex() with 25 - capture groups, and r:set_document_root(). [Joe Orton] + *) mod_lua: Fix websocket frame handling in r:wsread() and r:wspeek(), + r:requestbody() size limits, r:regex() with 25 capture groups and + r:set_document_root(). [Joe Orton] diff --git a/changes-entries/lua-wsread-memory.txt b/changes-entries/lua-wsread-memory.txt index 0373a2b3adf..3a87227b360 100644 --- a/changes-entries/lua-wsread-memory.txt +++ b/changes-entries/lua-wsread-memory.txt @@ -1,2 +1,2 @@ - *) mod_lua: Cap memory use of r:wsread(), r:regex() and other request - methods. [Joe Orton] + *) mod_lua: Cap memory use of r:wsread(), r:requestbody(), r:regex() + and other request methods. [Joe Orton] diff --git a/modules/lua/lua_request.c b/modules/lua/lua_request.c index 019b5586cbc..54015c74d70 100644 --- a/modules/lua/lua_request.c +++ b/modules/lua/lua_request.c @@ -231,6 +231,8 @@ static int req_aprtable2luatable_cb_len(void *l, const char *key, requests. Used for multipart POST data. ======================================================================================================================= */ +/* Reads the request body into *rbuf. Returns OK, DONE if there is + * no body, or an HTTP status. */ static int lua_read_body(request_rec *r, const char **rbuf, apr_off_t *size, apr_off_t maxsize) { @@ -245,24 +247,40 @@ static int lua_read_body(request_rec *r, const char **rbuf, apr_off_t *size, if (ap_should_client_block(r)) { /*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/ - apr_off_t len_read = -1; - apr_off_t rpos = 0; + char argsbuffer[HUGE_STRING_LEN]; + long len_read; apr_off_t length = r->remaining; + apr_size_t total; + apr_bucket_brigade *bb; + char *buf; /*~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~*/ if (maxsize != 0 && length > maxsize) { - return APR_EINCOMPLETE; /* Only room for incomplete data chunk :( */ + return HTTP_REQUEST_ENTITY_TOO_LARGE; + } + /* The body and its NUL must fit in an apr_size_t. */ + if ((apr_uint64_t) length >= APR_SIZE_MAX) { + return HTTP_REQUEST_ENTITY_TOO_LARGE; } - *rbuf = (const char *) apr_pcalloc(r->pool, (apr_size_t) (length) + 1); - while ((rpos < length) - && (len_read = ap_get_client_block(r, (char *) *rbuf + rpos, - length - rpos)) > 0) { - rpos += len_read; + + /* Collect the body as it arrives rather than allocating the + * Content-Length up front. */ + bb = apr_brigade_create(r->pool, r->connection->bucket_alloc); + while ((len_read = ap_get_client_block(r, argsbuffer, + sizeof(argsbuffer))) > 0) { + apr_brigade_write(bb, NULL, NULL, argsbuffer, len_read); } if (len_read < 0) { - return APR_EINCOMPLETE; + apr_brigade_destroy(bb); + return HTTP_BAD_REQUEST; } - *size = rpos; + apr_brigade_length(bb, 1, size); + total = (apr_size_t) *size; + buf = apr_palloc(r->pool, total + 1); + apr_brigade_flatten(bb, buf, &total); + buf[total] = '\0'; + apr_brigade_destroy(bb); + *rbuf = buf; } else { rc = DONE; @@ -278,9 +296,12 @@ static int lua_read_body(request_rec *r, const char **rbuf, apr_off_t *size, * and writes to a file. * ======================================================================================================================= */ -static apr_status_t lua_write_body(request_rec *r, apr_file_t *file, apr_off_t *size) +/* Stores the request body in the file filename. Returns OK, DONE if + * there is no body, or an HTTP status. */ +static int lua_write_body(request_rec *r, const char *filename, + apr_off_t *size, apr_off_t maxsize) { - apr_status_t rc = OK; + int rc; *size = 0; @@ -292,22 +313,36 @@ static apr_status_t lua_write_body(request_rec *r, apr_file_t *file, apr_off_t * len_read, rpos = 0; apr_off_t length = r->remaining; + apr_file_t *file; - *size = length; + if (maxsize != 0 && length > maxsize) { + return HTTP_REQUEST_ENTITY_TOO_LARGE; + } + if (apr_file_open(&file, filename, + APR_FOPEN_CREATE | APR_FOPEN_WRITE + | APR_FOPEN_TRUNCATE, + APR_FPROT_OS_DEFAULT, r->pool) != APR_SUCCESS) { + return HTTP_INTERNAL_SERVER_ERROR; + } while ((len_read = ap_get_client_block(r, argsbuffer, sizeof(argsbuffer))) > 0) { if ((rpos + len_read) > length) - rsize = (apr_size_t) length - rpos; + rsize = length - rpos; else rsize = len_read; - rc = apr_file_write_full(file, argsbuffer, (apr_size_t) rsize, - NULL); - if (rc != APR_SUCCESS) - return rc; + if (apr_file_write_full(file, argsbuffer, (apr_size_t) rsize, + NULL) != APR_SUCCESS) { + apr_file_close(file); + return HTTP_INTERNAL_SERVER_ERROR; + } rpos += rsize; } + apr_file_close(file); + if (len_read < 0) + return HTTP_BAD_REQUEST; + *size = rpos; } else { rc = DONE; @@ -443,7 +478,7 @@ static int req_parsebody(lua_State *L) size = (apr_size_t) len; buffer = apr_palloc(r->pool, size + 1); apr_brigade_flatten(pair->value, buffer, &size); - buffer[len] = 0; + buffer[size] = 0; req_aprtable2luatable_cb(L, pair->name, buffer); } } @@ -468,40 +503,37 @@ static int lua_ap_requestbody(lua_State *L) if (r) { apr_off_t size; - if (maxSize > 0 && r->remaining > maxSize) { - lua_pushnil(L); - lua_pushliteral(L, "Request body was larger than the permitted size."); - return 2; - } + int rc; if (r->method_number != M_POST && r->method_number != M_PUT) return (0); if (!filename) { const char *data; - if (lua_read_body(r, &data, &size, maxSize) != OK) + rc = lua_read_body(r, &data, &size, maxSize); + if (rc == HTTP_REQUEST_ENTITY_TOO_LARGE) { + lua_pushnil(L); + lua_pushliteral(L, "Request body was larger than the permitted size."); + return 2; + } + if (rc != OK) return (0); lua_pushlstring(L, data, (size_t) size); lua_pushinteger(L, (lua_Integer) size); return (2); } else { - apr_status_t rc; - apr_file_t *file; - - rc = apr_file_open(&file, filename, APR_CREATE | APR_FOPEN_WRITE, - APR_FPROT_OS_DEFAULT, r->pool); + rc = lua_write_body(r, filename, &size, maxSize); lua_settop(L, 0); - if (rc == APR_SUCCESS) { - rc = lua_write_body(r, file, &size); - apr_file_close(file); - if (rc != OK) { - lua_pushboolean(L, 0); - return 1; - } - lua_pushinteger(L, (lua_Integer) size); - return (1); - } else + if (rc == HTTP_REQUEST_ENTITY_TOO_LARGE) { + lua_pushnil(L); + lua_pushliteral(L, "Request body was larger than the permitted size."); + return 2; + } + if (rc != OK) { lua_pushboolean(L, 0); + return 1; + } + lua_pushinteger(L, (lua_Integer) size); return (1); } } From 3d0941a2baf82ffaf508b711826de0ef210c5973 Mon Sep 17 00:00:00 2001 From: Joe Orton Date: Fri, 13 Nov 2026 15:03:04 +0000 Subject: [PATCH 16/18] * modules/lua/lua_request.c (lua_ap_scoreboard_worker): Return nothing for indexes outside the scoreboard. (lua_ap_mpm_query): Return nothing if the query fails. Fixes: b2605d20c4 (SVN r1610491) Fixes: 49fcd96e73 (SVN r1433967) Co-Authored-By: Claude Opus 5.5 --- modules/lua/lua_request.c | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/modules/lua/lua_request.c b/modules/lua/lua_request.c index 54015c74d70..09e4f7fa8e9 100644 --- a/modules/lua/lua_request.c +++ b/modules/lua/lua_request.c @@ -1229,7 +1229,8 @@ static int lua_ap_mpm_query(lua_State *L) y; x = lua_tointeger(L, 1); - ap_mpm_query(x, &y); + if (ap_mpm_query(x, &y) != APR_SUCCESS) + return 0; lua_pushinteger(L, y); return 1; } @@ -1418,8 +1419,8 @@ static int lua_ap_scoreboard_worker(lua_State *L) i = lua_tointeger(L, 2); j = lua_tointeger(L, 3); - ap_copy_scoreboard_worker(ws_record, i, j); - if (ws_record) { + if (ap_get_scoreboard_worker_from_indexes(i, j) != NULL) { + ap_copy_scoreboard_worker(ws_record, i, j); lua_newtable(L); lua_pushstring(L, "access_count"); From f7afacc48b9eb728e0f5eaaf91cf2af723d44cbf Mon Sep 17 00:00:00 2001 From: Joe Orton Date: Fri, 13 Nov 2026 15:06:47 +0000 Subject: [PATCH 17/18] * modules/lua/lua_request.h (APL_REQ_FUNTYPE_INT64): New field type. * modules/lua/lua_request.c (req_dispatch): Handle it. (lua_ap_started, lua_ap_limit_req_body): Return apr_int64_t, and register as APL_REQ_FUNTYPE_INT64. * test/modules/lua/test_003_request.py (test_lua_003_09_started): New test. Fixes: 2e9e74fd6c (SVN r1370158) Co-Authored-By: Claude Opus 5.5 --- modules/lua/lua_request.c | 27 +++++++++++++++++++++------ modules/lua/lua_request.h | 1 + test/modules/lua/test_003_request.py | 6 ++++++ 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/modules/lua/lua_request.c b/modules/lua/lua_request.c index 09e4f7fa8e9..e878638c9b6 100644 --- a/modules/lua/lua_request.c +++ b/modules/lua/lua_request.c @@ -45,6 +45,7 @@ APLOG_USE_MODULE(lua); typedef char *(*req_field_string_f) (request_rec * r); typedef int (*req_field_int_f) (request_rec * r); +typedef apr_int64_t (*req_field_int64_f) (request_rec * r); typedef req_table_t *(*req_field_apr_table_f) (request_rec * r); @@ -890,9 +891,9 @@ static const char* lua_ap_allowoverrides(request_rec* r) return fs->s; } -static int lua_ap_started(request_rec* r) +static apr_int64_t lua_ap_started(request_rec* r) { - return (int)(ap_scoreboard_image->global->restart_time / 1000000); + return apr_time_sec(ap_scoreboard_image->global->restart_time); } static const char* lua_ap_basic_auth_pw(request_rec* r) @@ -902,9 +903,9 @@ static const char* lua_ap_basic_auth_pw(request_rec* r) return pw ? pw : ""; } -static int lua_ap_limit_req_body(request_rec* r) +static apr_int64_t lua_ap_limit_req_body(request_rec* r) { - return (int) ap_get_limit_req_body(r); + return ap_get_limit_req_body(r); } static int lua_ap_is_initial_req(request_rec *r) @@ -2054,6 +2055,20 @@ static int req_dispatch(lua_State *L) lua_pushinteger(L, rs); return 1; } + case APL_REQ_FUNTYPE_INT64:{ + req_field_int64_f func = (req_field_int64_f)rft->fun; + apr_int64_t rs; + ap_log_rerror(APLOG_MARK, APLOG_DEBUG, 0, r, + "request_rec->dispatching %s -> int64", name); + rs = (*func) (r); + /* lua_Integer may be narrower, as with Lua 5.1 on a + * 32-bit platform. */ + if (sizeof(lua_Integer) >= sizeof(rs)) + lua_pushinteger(L, (lua_Integer) rs); + else + lua_pushnumber(L, (lua_Number) rs); + return 1; + } case APL_REQ_FUNTYPE_BOOLEAN:{ req_field_int_f func = (req_field_int_f)rft->fun; int rs; @@ -2998,11 +3013,11 @@ void ap_lua_load_request_lmodule(lua_State *L, apr_pool_t *p) apr_hash_set(dispatch, "allowoverrides", APR_HASH_KEY_STRING, makefun(&lua_ap_allowoverrides, APL_REQ_FUNTYPE_STRING, p)); apr_hash_set(dispatch, "started", APR_HASH_KEY_STRING, - makefun(&lua_ap_started, APL_REQ_FUNTYPE_INT, p)); + makefun(&lua_ap_started, APL_REQ_FUNTYPE_INT64, p)); apr_hash_set(dispatch, "basic_auth_pw", APR_HASH_KEY_STRING, makefun(&lua_ap_basic_auth_pw, APL_REQ_FUNTYPE_STRING, p)); apr_hash_set(dispatch, "limit_req_body", APR_HASH_KEY_STRING, - makefun(&lua_ap_limit_req_body, APL_REQ_FUNTYPE_INT, p)); + makefun(&lua_ap_limit_req_body, APL_REQ_FUNTYPE_INT64, p)); apr_hash_set(dispatch, "server_built", APR_HASH_KEY_STRING, makefun(&ap_get_server_built, APL_REQ_FUNTYPE_STRING, p)); apr_hash_set(dispatch, "is_initial_req", APR_HASH_KEY_STRING, diff --git a/modules/lua/lua_request.h b/modules/lua/lua_request.h index a39f4b97b1b..b9f57536360 100644 --- a/modules/lua/lua_request.h +++ b/modules/lua/lua_request.h @@ -31,6 +31,7 @@ void ap_lua_push_request(lua_State *L, request_rec *r); #define APL_REQ_FUNTYPE_TABLE 3 #define APL_REQ_FUNTYPE_LUACFUN 4 #define APL_REQ_FUNTYPE_BOOLEAN 5 +#define APL_REQ_FUNTYPE_INT64 6 typedef struct { diff --git a/test/modules/lua/test_003_request.py b/test/modules/lua/test_003_request.py index 2ede8c5c7b0..c981e3eb67d 100644 --- a/test/modules/lua/test_003_request.py +++ b/test/modules/lua/test_003_request.py @@ -1,5 +1,6 @@ import os import textwrap +import time import pytest @@ -77,3 +78,8 @@ def test_lua_003_07_mpm_query_unknown(self, env): def test_lua_003_08_scoreboard_worker_range(self, env): assert self.get(env, "/lua/info.lua?scoreboard_worker") == "nil" + + # The server start time, in seconds since the epoch. + def test_lua_003_09_started(self, env): + started = int(self.get(env, "/lua/info.lua?started")) + assert 0 <= time.time() - started < 3600 From cb43ed2ca3d8b7f9e6d183c2a1619e65dfa4306f Mon Sep 17 00:00:00 2001 From: Joe Orton Date: Fri, 13 Nov 2026 15:10:12 +0000 Subject: [PATCH 18/18] * modules/lua/lua_request.c (MODLUA_MAX_BASE64): New macro. (lua_apr_b64encode, lua_apr_b64decode): Refuse a string longer than MODLUA_MAX_BASE64. (lua_apr_sha1): Pass the string to apr_sha1_update() in pieces which fit its length argument. * test/modules/lua/test_003_request.py (test_lua_003_10_digests, test_lua_003_11_base64_max): New tests. * test/modules/lua/htdocs/test1/lua/info.lua: Add md5, sha1 and base64_max. Co-Authored-By: Claude Opus 5.5 --- modules/lua/lua_request.c | 17 ++++++++++++++++- test/modules/lua/htdocs/test1/lua/info.lua | 7 +++++++ test/modules/lua/test_003_request.py | 10 ++++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/modules/lua/lua_request.c b/modules/lua/lua_request.c index e878638c9b6..19599b5e655 100644 --- a/modules/lua/lua_request.c +++ b/modules/lua/lua_request.c @@ -43,6 +43,11 @@ APLOG_USE_MODULE(lua); #define MODLUA_MAX_REG_MATCH 25 #endif +/* The longest string r:base64_encode() and r:base64_decode() accept. */ +#ifndef MODLUA_MAX_BASE64 +#define MODLUA_MAX_BASE64 (HUGE_STRING_LEN*1024) +#endif + typedef char *(*req_field_string_f) (request_rec * r); typedef int (*req_field_int_f) (request_rec * r); typedef apr_int64_t (*req_field_int64_f) (request_rec * r); @@ -976,6 +981,7 @@ static int lua_apr_b64encode(lua_State *L) r = ap_lua_check_request_rec(L, 1); luaL_checktype(L, 2, LUA_TSTRING); plain = lua_tolstring(L, 2, &plain_len); + luaL_argcheck(L, plain_len <= MODLUA_MAX_BASE64, 2, "string too long"); encoded_len = apr_base64_encode_len(plain_len); if (encoded_len) { encoded = apr_palloc(lua_temp_pool(r), encoded_len); @@ -1001,6 +1007,8 @@ static int lua_apr_b64decode(lua_State *L) r = ap_lua_check_request_rec(L, 1); luaL_checktype(L, 2, LUA_TSTRING); encoded = lua_tolstring(L, 2, &encoded_len); + luaL_argcheck(L, encoded_len <= MODLUA_MAX_BASE64, 2, + "string too long"); decoded_len = apr_base64_decode_len(encoded); if (decoded_len) { plain = apr_palloc(lua_temp_pool(r), decoded_len); @@ -1094,7 +1102,14 @@ static int lua_apr_sha1(lua_State *L) luaL_checktype(L, 2, LUA_TSTRING); buffer = lua_tolstring(L, 2, &len); apr_sha1_init(&sha1); - apr_sha1_update(&sha1, buffer, len); + /* apr_sha1_update() takes an unsigned int length. */ + while (len > 0) { + unsigned int n = len > UINT_MAX ? UINT_MAX : (unsigned int) len; + + apr_sha1_update(&sha1, buffer, n); + buffer += n; + len -= n; + } apr_sha1_final(digest, &sha1); ap_bin2hex(digest, sizeof(digest), result); diff --git a/test/modules/lua/htdocs/test1/lua/info.lua b/test/modules/lua/htdocs/test1/lua/info.lua index 86ae68400af..5ae6de6a0dc 100644 --- a/test/modules/lua/htdocs/test1/lua/info.lua +++ b/test/modules/lua/htdocs/test1/lua/info.lua @@ -4,6 +4,13 @@ local fields = { started = function(r) return r.started end, mpm_query = function(r) return r.mpm_query(9999) end, scoreboard_worker = function(r) return r:scoreboard_worker(100000, 0) end, + md5 = function(r) return r:md5("abc") end, + sha1 = function(r) return r:sha1("abc") end, + base64_max = function(r) + local ok = pcall(r.base64_encode, r, string.rep("x", 8 * 1024 * 1024)) + local over = pcall(r.base64_encode, r, string.rep("x", 8 * 1024 * 1024 + 1)) + return tostring(ok) .. "|" .. tostring(over) + end, } function handle(r) diff --git a/test/modules/lua/test_003_request.py b/test/modules/lua/test_003_request.py index c981e3eb67d..85ab57dae61 100644 --- a/test/modules/lua/test_003_request.py +++ b/test/modules/lua/test_003_request.py @@ -83,3 +83,13 @@ def test_lua_003_08_scoreboard_worker_range(self, env): def test_lua_003_09_started(self, env): started = int(self.get(env, "/lua/info.lua?started")) assert 0 <= time.time() - started < 3600 + + def test_lua_003_10_digests(self, env): + assert self.get(env, "/lua/info.lua?md5") == \ + "900150983cd24fb0d6963f7d28e17f72" + assert self.get(env, "/lua/info.lua?sha1") == \ + "a9993e364706816aba3e25717850c26c9cd0d89d" + + # r:base64_encode() takes at most 8MB. + def test_lua_003_11_base64_max(self, env): + assert self.get(env, "/lua/info.lua?base64_max") == "true|false"