summaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
authorMarc Deslauriers <marc.deslauriers@canonical.com>2026-07-29 11:10:50 -0400
committerMarc Deslauriers <marc.deslauriers@canonical.com>2026-07-29 11:10:50 -0400
commitffd9fdd1d2a52f0f072fb6f92b7775443e44fdb2 (patch)
treea53e33cf93d39de587a1f5d0319461dffa30312b /scripts
parentccd39b42014d3126113569824ab25fa8e62b8e41 (diff)
test-nginx.py: added new testHEADmaster
Diffstat (limited to 'scripts')
-rwxr-xr-xscripts/test-nginx.py211
1 files changed, 211 insertions, 0 deletions
diff --git a/scripts/test-nginx.py b/scripts/test-nginx.py
index 05ff3389..7d58739e 100755
--- a/scripts/test-nginx.py
+++ b/scripts/test-nginx.py
@@ -67,6 +67,11 @@ class NginxTest(testlib_httpd.HttpdCommon):
self.ssl_crt = "/etc/nginx/cert.pem"
self.ssl_key = "/etc/nginx/cert.key"
+ # conf.d files are included in the http context by the default
+ # nginx.conf, so they are suitable for directives such as `map`
+ # that are only valid in the http context.
+ self.conf_d = "/etc/nginx/conf.d"
+
self.pidfile = "/var/run/nginx.pid"
self.daemon = testlib.TestDaemon("nginx")
@@ -81,6 +86,12 @@ class NginxTest(testlib_httpd.HttpdCommon):
if os.path.exists(self.ssl_key):
os.unlink(self.ssl_key)
+ # Remove any conf.d snippets created by individual tests so they
+ # don't leak into subsequent tests or the running configuration.
+ for filename in os.listdir(self.conf_d):
+ if filename.startswith("qrt-") and filename.endswith(".conf"):
+ os.unlink(os.path.join(self.conf_d, filename))
+
testlib.config_restore(self.default_vhost)
def _test_headers(self, url="https://localhost/", content="", invert=False, check_ssl=True):
@@ -258,6 +269,206 @@ server {
self._test_headers(url="http://localhost/", content="X-QRT-Test: headers-more-works", check_ssl=False)
self._test_headers(url="http://localhost/", content="Server:", invert=True, check_ssl=False)
+ def _get_nginx_worker_pids(self):
+ '''Return the set of current nginx worker process PIDs.'''
+ rc, report = testlib.cmd(['pgrep', '-x', 'nginx'])
+ # pgrep matches both master and workers; exclude the master pid.
+ master_pid = None
+ try:
+ with open(self.pidfile, 'r') as fd:
+ master_pid = fd.readline().rstrip('\n')
+ except (IOError, OSError):
+ pass
+ pids = set()
+ if rc == 0:
+ for line in report.strip().split('\n'):
+ pid = line.strip()
+ if pid and pid != master_pid:
+ pids.add(pid)
+ return pids
+
+ def test_cve_2026_42533(self):
+ '''Verify CVE-2026-42533 (heap buffer overflow with map regex)'''
+
+ # CVE-2026-42533: a heap buffer overflow could occur in a worker
+ # process when using the `map` directive with regex matching if the
+ # map variable was included in a string expression after a regex
+ # capture affected by that same map. A similar issue could happen
+ # when using a non-cacheable (`volatile`) variable in a string
+ # expression.
+ #
+ # The nginx script engine evaluates complex values (string
+ # expressions with variables) in two passes: a length pass to
+ # calculate the buffer size, then a copy pass to fill it. When a
+ # map variable with regex matching is evaluated, it runs the regex
+ # and sets r->captures as a side effect. If a regex capture ($1,
+ # $named_capture) appears in the same expression BEFORE the map
+ # variable, the length pass uses the original captures, but by the
+ # copy pass the captures have been overwritten by the map's regex —
+ # causing the copy to write more data than the buffer can hold.
+ #
+ # The fix (nginx 1.31.3, 1.30.4) adds buffer boundary checking in
+ # the script engine (ngx_http_script_check_length) that logs "no
+ # buffer space in script copy" and returns a 500 error instead of
+ # overflowing.
+ #
+ # On a vulnerable build the worker process crashes (SIGSEGV); on a
+ # fixed build the worker survives and returns an error response.
+ #
+ # This test has two parts:
+ # 1. Trigger test: exercise the exact CVE-2026-42533 code path and
+ # verify the worker does not crash.
+ # 2. Functionality test: verify normal map regex and volatile map
+ # usage produces correct values (no regression from the fix).
+
+ # The `map` directive is only valid in the http context, so place it
+ # in a conf.d snippet (included by the default nginx.conf) rather
+ # than in the server block.
+ map_conf = os.path.join(self.conf_d, "qrt-cve-2026-42533.conf")
+ testlib.create_fill(map_conf, '''
+# CVE-2026-42533 regression test
+#
+# Trigger 1: map with regex matching where the map value uses a named
+# capture from that same regex. When the map variable is used in a string
+# expression after a capture reference, the map's regex execution
+# overwrites r->captures between the length and copy passes.
+map $uri $qrt_map {
+ default "unknown";
+ "~^(?P<qrt_capture>.+)$" "$qrt_capture";
+}
+
+# Trigger 2: non-cacheable (volatile) map. A volatile variable is
+# re-evaluated on every access, so using it in a string expression can
+# cause the length to change between the two passes.
+map $request_uri $qrt_volatile {
+ volatile;
+ default "default-volatile";
+ "~^/volatile" "volatile-matched";
+}
+''')
+
+ # --- Part 1: Trigger test (worker crash detection) ---
+ #
+ # The trigger uses the rewrite module's `set` directive to create a
+ # string expression where a capture appears before the map variable.
+ # `set $qrt_temp "$qrt_capture $qrt_map"` evaluates $qrt_capture
+ # (empty, since no location regex set it) then $qrt_map (which runs
+ # the map regex, overwriting r->captures). On the copy pass,
+ # $qrt_capture reads from the now-modified captures — writing far
+ # more bytes than the 0 allocated for it → heap buffer overflow.
+ #
+ # A long URI is used so the overflow is large enough to crash the
+ # worker process on a vulnerable build.
+ testlib.config_replace(self.default_vhost, '''
+server {
+ listen 80;
+ server_name localhost;
+
+ root %s;
+ index index.html index.htm index.nginx-debian.html;
+
+ location / {
+ set $qrt_capture "";
+ set $qrt_temp "$qrt_capture $qrt_map";
+ return 200 "$qrt_temp\\n";
+ }
+
+ location = /volatile {
+ return 200 "volatile-endpoint-ok\\n";
+ }
+}
+''' % self.document_root, append=False)
+
+ rc, report = self.daemon.restart()
+ self.assertTrue(rc, report)
+ self.assertTrue(testlib.check_pidfile("nginx", self.pidfile))
+
+ # Record worker PIDs before sending the trigger request.
+ pids_before = self._get_nginx_worker_pids()
+ self.assertTrue(len(pids_before) > 0,
+ "No nginx worker processes found")
+
+ # Send a request with a long URI to trigger the overflow. On a
+ # vulnerable build, the worker crashes (segfault) and the master
+ # restarts it with a new PID. On a fixed build, the buffer overrun
+ # protection catches the overflow and returns a 500 error, but the
+ # worker stays alive.
+ long_uri = "/" + "A" * 4096
+ testlib.cmd(['curl', '-s', '-o', '/dev/null',
+ 'http://localhost' + long_uri])
+
+ # Worker PIDs should be unchanged — the same workers are still
+ # running. On a vulnerable build, the worker would have crashed and
+ # been restarted with a different PID.
+ pids_after = self._get_nginx_worker_pids()
+ self.assertEqual(pids_before, pids_after,
+ "nginx worker process crashed and was restarted "
+ "(PIDs changed: %s -> %s). This indicates "
+ "CVE-2026-42533 is not fixed." %
+ (pids_before, pids_after))
+
+ # --- Part 2: Functionality test (no regression) ---
+ #
+ # Replace the vhost config with normal map usage (no trigger) to
+ # verify the fix didn't break legitimate map regex and volatile
+ # functionality.
+ testlib.config_replace(self.default_vhost, '''
+server {
+ listen 80;
+ server_name localhost;
+
+ root %s;
+ index index.html index.htm index.nginx-debian.html;
+
+ # Map output used in a string expression (normal functionality).
+ # Use `return` instead of `add_header` because add_header is not
+ # applied to 404 responses, and we want to verify the map value
+ # directly regardless of whether a file exists on disk.
+ location / {
+ return 200 "mapped:$qrt_map vol:$qrt_volatile\\n";
+ }
+}
+''' % self.document_root, append=False)
+
+ rc, report = self.daemon.restart()
+ self.assertTrue(rc, report)
+ self.assertTrue(testlib.check_pidfile("nginx", self.pidfile))
+
+ # Regex map: a request URI should be captured by the map regex.
+ # The volatile map matches /testpath against default (no /volatile
+ # prefix), so we expect "default-volatile".
+ rc, report = testlib.cmd(['curl', '-s',
+ 'http://localhost/testpath'])
+ expected = 0
+ result = 'Got exit code %d, expected %d\n' % (rc, expected)
+ self.assertEqual(expected, rc, result + report)
+ self._word_find(report, "mapped:/testpath")
+ self._word_find(report, "vol:default-volatile")
+ # Worker must still be alive.
+ self.assertTrue(testlib.check_pidfile("nginx", self.pidfile))
+
+ # Regex map: a different path should produce a different capture.
+ rc, report = testlib.cmd(['curl', '-s',
+ 'http://localhost/another'])
+ expected = 0
+ result = 'Got exit code %d, expected %d\n' % (rc, expected)
+ self.assertEqual(expected, rc, result + report)
+ self._word_find(report, "mapped:/another")
+ # Worker must still be alive.
+ self.assertTrue(testlib.check_pidfile("nginx", self.pidfile))
+
+ # Volatile map: matching request URI should produce the matched
+ # value, and the volatile (non-cacheable) code path should work.
+ rc, report = testlib.cmd(['curl', '-s',
+ 'http://localhost/volatile'])
+ expected = 0
+ result = 'Got exit code %d, expected %d\n' % (rc, expected)
+ self.assertEqual(expected, rc, result + report)
+ self._word_find(report, "mapped:/volatile")
+ self._word_find(report, "vol:volatile-matched")
+ # Worker must still be alive.
+ self.assertTrue(testlib.check_pidfile("nginx", self.pidfile))
+
def test_proxy(self):
'''Test proxy'''