#1309·Nginx

Defense-in-depth hardening for HTTP/1.1 chunked parser and HTTP/3 QPACK index computation

Author: jskoetsierCreated May 1, 2026Updated Sep 15, 2026
Labelsrefinedneeds-analysis

Summary

Source code review of nginx 1.31.0 (mainline) identified two areas for defense-in-depth hardening in protocol parsers. None are exploitable vulnerabilities under current system constraints.


Finding 1: Chunked transfer encoding lacks input length and character validation

Module: src/http/ngx_http_parse.c

Observation: sw_chunk_extension and sw_last_chunk_extension states accept unlimited extension data. sw_trailer_header accepts any byte except CR/LF (NUL, control characters pass through). No per-line length bound. There is no smuggling impact — trailer bytes in the request-body path are not stored or forwarded into a request buffer. The extension unbounded-length concern is mitigated by existing client-body timeouts.

Patch: Added NGX_HTTP_CHUNKED_MAX_EXT_LENGTH (4096 bytes) with a shared counter in ngx_http_chunked_s. Chunk/last-chunk extensions and trailer header lines reject control characters (< 0x20) and enforce the 4096-byte limit.


Finding 2: QPACK dynamic table index computation can underflow

Module: src/http/v3/ngx_http_v3_parse.c:752,860

Observation: ngx_http_v3_parse_field_ri() and ngx_http_v3_parse_field_lri() compute st->base - st->index - 1 using ngx_uint_t (64-bit unsigned). When st->index >= st->base, the result wraps to ~UINT64_MAX. The existing bounds check in ngx_http_v3_lookup() (if (index < dt->base || index - dt->base >= dt->nelts)) already catches the wrapped value for any reachable table state. Reaching a bypass would require dt->base near UINT64_MAX/2, which is not achievable.

Patch: Added explicit bounds check before subtraction in both functions, returning NGX_HTTP_V3_ERR_DECOMPRESSION_FAILED (0x200) — defense-in-depth to fail at the parser level rather than relying on the downstream bounds check.


HTTP/2 frame_size direction — NOT a vulnerability

The original report claimed nginx fails to enforce SETTINGS_MAX_FRAME_SIZE on receive. This was incorrect: h2c->frame_size stores the peer's advertised limit — what nginx must obey when sending. nginx advertises MAX_FRAME_SIZE = 16777215 (~16MB) outbound, so a client sending frames up to 16MB is within what nginx promised to accept. No fix included for this path.


Patch

Two files, 28 insertions. Built and tested in Docker (debian:bookworm-slim, nginx 1.31.0 mainline).

diff
--- a/src/http/ngx_http.h
+++ b/src/http/ngx_http.h
@@ -61,10 +61,14 @@

+#define NGX_HTTP_CHUNKED_MAX_EXT_LENGTH  4096
+
 struct ngx_http_chunked_s {
     ngx_uint_t           state;
     off_t                size;
     off_t                length;
+    off_t                extension_length; /* shared: chunk ext, last-chunk ext,
+                                             trailer line; reset by sw_chunk_start
+                                             and sw_trailer_header_almost_done */
 };

--- a/src/http/ngx_http_parse.c
+++ b/src/http/ngx_http_parse.c
@@ -2237,6 +2237,7 @@
         case sw_chunk_start:
+            ctx->extension_length = 0;
+
             if (ch >= '0' && ch <= '9') {
                 state = sw_chunk_size;
                 ctx->size = ch - '0';
@@ -2305,6 +2306,9 @@
         case sw_chunk_extension:
+            if (++ctx->extension_length > NGX_HTTP_CHUNKED_MAX_EXT_LENGTH) {
+                goto invalid;
+            }
             switch (ch) {
             case CR:
                 state = sw_chunk_extension_almost_done;
@@ -2346,6 +2350,9 @@
         case sw_last_chunk_extension:
+            if (++ctx->extension_length > NGX_HTTP_CHUNKED_MAX_EXT_LENGTH) {
+                goto invalid;
+            }
             switch (ch) {
             case CR:
                 state = sw_last_chunk_extension_almost_done;
@@ -2387,6 +2394,12 @@
         case sw_trailer_header:
+            if (ch < 0x20) {
+                goto invalid;
+            }
+            if (++ctx->extension_length > NGX_HTTP_CHUNKED_MAX_EXT_LENGTH) {
+                goto invalid;
+            }
             switch (ch) {
             case CR:
                 state = sw_trailer_header_almost_done;
@@ -2400,6 +2413,7 @@
         case sw_trailer_header_almost_done:
             if (ch == LF) {
                 state = sw_trailer;
+                ctx->extension_length = 0;
                 break;
             }
             goto invalid;

--- a/src/http/v3/ngx_http_v3_parse.c
+++ b/src/http/v3/ngx_http_v3_parse.c
@@ -749,6 +749,11 @@
     if (st->dynamic) {
+        if (st->index >= st->base) {
+            ngx_log_error(NGX_LOG_INFO, c->log, 0,
+                          "http3 parse field ri index exceeds base");
+            return NGX_HTTP_V3_ERR_DECOMPRESSION_FAILED;
+        }
         st->index = st->base - st->index - 1;
     }

@@ -862,6 +867,11 @@
     if (st->dynamic) {
+        if (st->index >= st->base) {
+            ngx_log_error(NGX_LOG_INFO, c->log, 0,
+                          "http3 parse field lri index exceeds base");
+            return NGX_HTTP_V3_ERR_DECOMPRESSION_FAILED;
+        }
         st->index = st->base - st->index - 1;
     }