From 4303a12f60a8c3ec1111495243e264f5b6cf4757 Mon Sep 17 00:00:00 2001 From: Nikos Mavrogiannopoulos Date: Sat, 2 May 2026 19:44:01 +0200 Subject: [PATCH] worker: harden HTTP request header size limits Bound memory growth in the worker for unauthenticated connections by enforcing HTTP headers limit in addition to HTTP body limit. Resolves: #712 Signed-off-by: Nikos Mavrogiannopoulos --- src/worker-http.c | 20 ++++++++ src/worker.h | 1 + tests/meson.build | 1 + tests/test-http-limits | 114 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 136 insertions(+) create mode 100755 tests/test-http-limits diff --git a/src/worker-http.c b/src/worker-http.c index f329abfd..fee47cb3 100644 --- a/src/worker-http.c +++ b/src/worker-http.c @@ -45,6 +45,7 @@ * largest legitimate payload (KKDCP Kerberos messages, ~64 KB encoded) while * preventing unauthenticated clients from exhausting worker memory. */ #define MAX_HTTP_REQUEST_BODY (256 * 1024) +#define MAX_HTTP_HEADERS_SIZE (16 * 1024) struct known_urls_st { const char *url; @@ -828,6 +829,15 @@ int http_header_field_cb(llhttp_t *parser, const char *at, size_t length) str_reset(&req->header); } + if (length > MAX_HTTP_HEADERS_SIZE || + req->header_bytes > MAX_HTTP_HEADERS_SIZE - length) { + oclog(ws, LOG_HTTP_DEBUG, + "HTTP headers size limit exceeded (%zu+%zu > %u)", + req->header_bytes, length, MAX_HTTP_HEADERS_SIZE); + return -1; + } + req->header_bytes += length; + ret = str_append_data(&req->header, at, length); if (ret < 0) return -1; @@ -863,6 +873,15 @@ int http_header_value_cb(llhttp_t *parser, const char *at, size_t length) str_reset(&req->value); } + if (length > MAX_HTTP_HEADERS_SIZE || + req->header_bytes > MAX_HTTP_HEADERS_SIZE - length) { + oclog(ws, LOG_HTTP_DEBUG, + "HTTP headers size limit exceeded (%zu+%zu > %u)", + req->header_bytes, length, MAX_HTTP_HEADERS_SIZE); + return -1; + } + req->header_bytes += length; + ret = str_append_data(&req->value, at, length); if (ret < 0) return -1; @@ -937,6 +956,7 @@ void http_req_reset(worker_st *ws) ws->req.headers_complete = 0; ws->req.message_complete = 0; ws->req.body_length = 0; + ws->req.header_bytes = 0; ws->req.spnego_set = 0; ws->req.url[0] = 0; diff --git a/src/worker.h b/src/worker.h index 40f1dd7f..cb15d46a 100644 --- a/src/worker.h +++ b/src/worker.h @@ -134,6 +134,7 @@ struct http_req_st { str_st header; str_st value; unsigned int header_state; + size_t header_bytes; char devtype[MAX_AGENT_NAME]; /* Device-Type */ char devplatform[MAX_AGENT_NAME]; /* Device-Platform */ diff --git a/tests/meson.build b/tests/meson.build index 967e2c28..778dafe5 100644 --- a/tests/meson.build +++ b/tests/meson.build @@ -96,6 +96,7 @@ always_scripts = [ 'test-owasp-headers', 'test-replay', 'test-fw-normalize-route', + 'test-http-limits', ] foreach s : always_scripts diff --git a/tests/test-http-limits b/tests/test-http-limits new file mode 100755 index 00000000..e6d177a4 --- /dev/null +++ b/tests/test-http-limits @@ -0,0 +1,114 @@ +#!/bin/sh +# +# Copyright (C) 2026 Nikos Mavrogiannopoulos +# +# This file is part of ocserv. +# +# ocserv is free software; you can redistribute it and/or modify it +# under the terms of the GNU General Public License as published by the +# Free Software Foundation; either version 2 of the License, or (at +# your option) any later version. +# +# ocserv is distributed in the hope that it will be useful, but +# WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with GnuTLS; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +SERV="${SERV:-../src/ocserv}" +srcdir=${srcdir:-.} +NO_NEED_ROOT=1 + +. `dirname $0`/common.sh + +eval "${GETPORT}" + +echo "Testing HTTP body and header size limits..." + +update_config test-user-cert.config +launch_simple_sr_server -d 1 -f -c ${CONFIG} +PID=$! +wait_server $PID + +BODY_LIMIT=$((256 * 1024)) +HDR_LIMIT=$((16 * 1024)) +HDRFILE=test-http-limits.$$.tmp +CURL_OUT=test-http-limits.$$.curl + +# Run a POST via socket_wrapper; print HTTP status code to stdout. +# Returns curl's exit code (28 = timeout, 52 = empty reply, 0 = got response). +curl_post() { + LD_PRELOAD=libsocket_wrapper.so \ + curl --silent --insecure --max-time 10 --user-agent "AnyConnect" \ + --write-out "%{http_code}" \ + "$@" -o "$CURL_OUT" +} + +echo -n "Testing body within limit (${BODY_LIMIT} bytes)... " +http_code=$(head -c ${BODY_LIMIT} /dev/zero | \ + curl_post -X POST https://$ADDRESS:$PORT/auth --data-binary @-) +rc=$? +if [ $rc -ne 0 ] || [ "$http_code" = "000" ]; then + echo "FAILED (curl exit=$rc, HTTP=$http_code)" + cat "$CURL_OUT" + fail $PID "Body within limit was rejected" +fi +echo "ok (HTTP $http_code)" + +echo -n "Testing body over limit ($((BODY_LIMIT + 1)) bytes)... " +http_code=$(head -c $((BODY_LIMIT + 1)) /dev/zero | \ + curl_post -X POST https://$ADDRESS:$PORT/auth --data-binary @-) +rc=$? +if [ $rc -eq 0 ] && [ "$http_code" != "413" ] && [ "$http_code" != "431" ]; then + echo "FAILED (curl exit=$rc, HTTP=$http_code)" + cat "$CURL_OUT" + fail $PID "Body over limit was not rejected (HTTP $http_code)" +fi +echo "ok (HTTP $http_code)" + +# Generate N headers of 1 KB each into $HDRFILE for curl's -H @file. +# With a 16 KB limit: 3 headers (3 KB) is within; 20 headers (20 KB) is over. +HDR_CHUNK=$((1 * 1024)) +gen_headers() { + n=$1; rm -f "$HDRFILE"; i=0 + while [ $i -lt $n ]; do + { printf 'X-Test-%d: ' $i + head -c ${HDR_CHUNK} /dev/zero | tr '\0' 'A' + printf '\n'; } >> "$HDRFILE" + i=$((i+1)) + done +} + +echo -n "Testing headers within limit ($((3 * HDR_CHUNK)) bytes)... " +gen_headers 3 +# Explicit Content-Length: 0 is required: older curl (e.g. 7.76.1 on CentOS 9) +# omits it for empty bodies, leaving the server unable to determine +# message_complete and causing a timeout. +http_code=$(curl_post -X POST -H "Content-Length: 0" -H @"$HDRFILE" \ + https://$ADDRESS:$PORT/auth) +rc=$? +if [ $rc -ne 0 ] || [ "$http_code" = "000" ]; then + echo "FAILED (curl exit=$rc, HTTP=$http_code)" + cat "$CURL_OUT" + fail $PID "Headers within limit were rejected" +fi +echo "ok (HTTP $http_code)" + +echo -n "Testing headers over limit ($((20 * HDR_CHUNK)) bytes)... " +gen_headers 20 +http_code=$(curl_post -X POST -H "Content-Length: 0" -H @"$HDRFILE" \ + https://$ADDRESS:$PORT/auth) +rc=$? +if [ $rc -eq 0 ] && [ "$http_code" != "413" ] && [ "$http_code" != "431" ]; then + echo "FAILED (curl exit=$rc, HTTP=$http_code)" + cat "$CURL_OUT" + fail $PID "Headers over limit were not rejected (HTTP $http_code)" +fi +echo "ok (HTTP $http_code)" + +rm -f "$HDRFILE" "$CURL_OUT" +cleanup +exit 0