From b58b553a589108a2b6356404177d1dcf1f891e31 Mon Sep 17 00:00:00 2001 From: mattsva Date: Fri, 9 Oct 2026 03:06:16 +0200 Subject: [PATCH] Add HTTP request-parsing edge-case tests Covers duplicate Content-Length (400), Content-Length with Transfer-Encoding (501), bare LF line endings, obs-fold continuations, Expect: 100-continue, negative/overflowing/non-numeric Content-Length (400/413/400), a version-less request line, a space before a header colon, and pipelined requests. No source changes were needed: every case already behaves correctly. The tests document the behavior and guard it, including the two deliberately lenient choices (obs-fold and a version-less request line are served rather than rejected, per the existing lenient policy). Wired into Make, CMake, Meson and the CI jobs. --- .github/workflows/make_test.yml | 10 +- CMakeLists.txt | 5 + Makefile | 7 +- meson.build | 8 + test/test_http_edge.c | 304 ++++++++++++++++++++++++++++++++ 5 files changed, 330 insertions(+), 4 deletions(-) create mode 100644 test/test_http_edge.c diff --git a/.github/workflows/make_test.yml b/.github/workflows/make_test.yml index c9648c4..29841fe 100644 --- a/.github/workflows/make_test.yml +++ b/.github/workflows/make_test.yml @@ -84,7 +84,7 @@ jobs: CFLAGS="-std=c17 -Wall -Wextra -Wpedantic -Werror -g \ -fsanitize=address,undefined -fno-omit-frame-pointer \ -D_POSIX_C_SOURCE=200112L -Iinclude -Iinclude/mnet -Isrc -pthread" - for t in test_mnet test_http test_mnet_parser test_stress test_features test_security test_client test_welcome test_starvation test_reentrancy; do + for t in test_mnet test_http test_mnet_parser test_stress test_features test_security test_client test_welcome test_starvation test_reentrancy test_http_edge; do gcc $CFLAGS src/mnet.c src/mnet_app.c src/mnet_response.c \ src/mnet_request.c src/mnet_router.c test/$t.c -o ${t}_asan || exit 1 done @@ -119,6 +119,9 @@ jobs: - name: Run reentrancy tests under ASan/UBSan run: ASAN_OPTIONS=detect_leaks=0 ./test_reentrancy_asan + - name: Run HTTP edge-case tests under ASan/UBSan + run: ASAN_OPTIONS=detect_leaks=0 ./test_http_edge_asan + test-valgrind: runs-on: ubuntu-latest steps: @@ -129,7 +132,7 @@ jobs: run: sudo apt-get update && sudo apt-get install -y valgrind - name: Build tests - run: make test_mnet test_http test_parser test_stress test_features test_security test_client test_welcome test_starvation test_reentrancy + run: make test_mnet test_http test_parser test_stress test_features test_security test_client test_welcome test_starvation test_reentrancy test_http_edge - name: Valgrind unit tests run: valgrind --leak-check=full --error-exitcode=1 ./test_mnet @@ -155,6 +158,9 @@ jobs: - name: Valgrind reentrancy tests run: valgrind --leak-check=full --error-exitcode=1 --trace-children=yes ./test_reentrancy + - name: Valgrind HTTP edge-case tests + run: valgrind --leak-check=full --error-exitcode=1 --trace-children=yes ./test_http_edge + # The worker pool is the one part of the library that is only exercised with # threads, so run the concurrency tests under ThreadSanitizer too. test-tsan: diff --git a/CMakeLists.txt b/CMakeLists.txt index 3571685..ac4a2e9 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -131,6 +131,11 @@ target_link_libraries(test_reentrancy PRIVATE mnet_static) target_include_directories(test_reentrancy PRIVATE include src test) add_test(NAME mnet_reentrancy_tests COMMAND test_reentrancy) +add_executable(test_http_edge test/test_http_edge.c) +target_link_libraries(test_http_edge PRIVATE mnet_static) +target_include_directories(test_http_edge PRIVATE include src test) +add_test(NAME mnet_http_edge_tests COMMAND test_http_edge) + # Install install(TARGETS mnet_static mnet_shared ARCHIVE DESTINATION lib diff --git a/Makefile b/Makefile index d41ebb7..2da9e53 100644 --- a/Makefile +++ b/Makefile @@ -39,10 +39,10 @@ SRCS = src/mnet.c src/mnet_app.c src/mnet_response.c \ TEST_SRCS = test/test_mnet.c test/test_http.c test/test_mnet_parser.c \ test/test_stress.c test/test_features.c test/test_security.c \ test/test_client.c test/test_welcome.c test/test_starvation.c \ - test/test_reentrancy.c + test/test_reentrancy.c test/test_http_edge.c TEST_BINS = test_mnet test_http test_parser test_stress test_features \ - test_security test_client test_welcome test_starvation test_reentrancy + test_security test_client test_welcome test_starvation test_reentrancy test_http_edge # Default target: build everything all: lib libmnet.a libmnet.so examples @@ -160,6 +160,9 @@ test_starvation: $(SRCS) test/test_starvation.c test_reentrancy: $(SRCS) test/test_reentrancy.c $(CC) $(CFLAGS) -Isrc $(SRCS) test/test_reentrancy.c -o $@ $(LDFLAGS) +test_http_edge: $(SRCS) test/test_http_edge.c + $(CC) $(CFLAGS) -Isrc $(SRCS) test/test_http_edge.c -o $@ $(LDFLAGS) + # ---- Run the examples (build + run) ---- # Usage: make run EXAMPLE=example_http_server PORT=8080 # EXAMPLE Example binary to build and run (default: example_http_server) diff --git a/meson.build b/meson.build index add42e5..2a26fa5 100644 --- a/meson.build +++ b/meson.build @@ -156,6 +156,14 @@ test_reentrancy = executable('test_reentrancy', test('mnet_reentrancy_tests', test_reentrancy) +test_http_edge = executable('test_http_edge', + 'test/test_http_edge.c', + link_with: mnet_static, + include_directories: [mnet_inc, include_directories('src'), include_directories('test')], +) + +test('mnet_http_edge_tests', test_http_edge) + # Install headers (libraries are installed by default) install_headers('include/mnet/mnet.h', 'include/mnet/mnet_app.h', 'include/mnet/mnet_request.h', 'include/mnet/mnet_response.h', 'include/mnet/mnet_router.h', 'include/mnet/mnet_socket.h', diff --git a/test/test_http_edge.c b/test/test_http_edge.c new file mode 100644 index 0000000..b0fed95 --- /dev/null +++ b/test/test_http_edge.c @@ -0,0 +1,304 @@ +/* test/test_http_edge.c + * + * HTTP request-parsing edge cases: duplicate Content-Length, CL with + * Transfer-Encoding, bare LF, obs-fold, Expect: 100-continue, bad + * Content-Length values, a version-less request line, a space before a + * header colon, and pipelined requests. + * + * POSIX-only (raw sockets). + */ +#define _GNU_SOURCE +#include +#include + +#include +#include +#include +#include + +#ifndef _WIN32 + +#include +#include +#include +#include +#include +#include +#include + +#define EDGE_PORT 18781 + +static int failures = 0; +static int passed = 0; + +#define CHECK(cond, name) \ + do { \ + if (cond) { \ + passed++; \ + printf(" PASS %s\n", (name)); \ + } else { \ + failures++; \ + printf(" FAIL %s (line %d)\n", (name), __LINE__); \ + } \ + } while (0) + +MNET_HANDLER(home) +{ + (void)req; + return mnet_text("ok"); +} + +MNET_HANDLER(echo) +{ + const char *b = MNET_BODY(req); + return mnet_text(b ? b : ""); +} + +static int connect_to(int port) +{ + int fd = socket(AF_INET, SOCK_STREAM, 0); + struct sockaddr_in a; + struct timeval tv; + + if (fd < 0) return -1; + tv.tv_sec = 10; + tv.tv_usec = 0; + setsockopt(fd, SOL_SOCKET, SO_RCVTIMEO, &tv, sizeof(tv)); + memset(&a, 0, sizeof(a)); + a.sin_family = AF_INET; + a.sin_port = htons((uint16_t)port); + a.sin_addr.s_addr = inet_addr("127.0.0.1"); + if (connect(fd, (struct sockaddr *)&a, sizeof(a)) != 0) { + close(fd); + return -1; + } + return fd; +} + +/* Send a raw request; return the status code, or -1 when no valid reply + line arrived. */ +static int send_raw(int port, const char *req, size_t len, char *out, + size_t out_size) +{ + char buf[4096]; + int fd = connect_to(port); + ssize_t n; + size_t used = 0; + size_t sent = 0; + + if (fd < 0) return -1; + while (sent < len) { + ssize_t w = send(fd, req + sent, len - sent, 0); + if (w <= 0) { close(fd); return -1; } + sent += (size_t)w; + } + + while (used < sizeof(buf) - 1) { + n = recv(fd, buf + used, sizeof(buf) - 1 - used, 0); + if (n <= 0) break; + used += (size_t)n; + buf[used] = '\0'; + if (strstr(buf, "") != NULL) break; + } + close(fd); + buf[used] = '\0'; + if (out != NULL && out_size > 0) { + size_t copy = used < out_size - 1 ? used : out_size - 1; + memcpy(out, buf, copy); + out[copy] = '\0'; + } + if (strncmp(buf, "HTTP/1.1 ", 9) != 0) return -1; + return atoi(buf + 9); +} + +static int request(int port, const char *req) +{ + return send_raw(port, req, strlen(req), NULL, 0); +} + +int main(void) +{ + printf("Running HTTP parsing edge-case tests...\n"); + + pid_t pid = fork(); + if (pid < 0) { + printf(" SKIP (fork failed)\n"); + return 0; + } + if (pid == 0) { + mnet_app_t *app = mnet_create(); + if (app == NULL) _exit(1); + MNET_GET(app, "/", home); + MNET_POST(app, "/echo", echo); + mnet_run(app, EDGE_PORT); + mnet_destroy(app); + _exit(0); + } + usleep(300000); + + /* 1. Duplicate Content-Length with different values -> 400 */ + CHECK(request(EDGE_PORT, + "POST /echo HTTP/1.1\r\n" + "Content-Length: 5\r\n" + "Content-Length: 6\r\n" + "\r\n" + "hello") == 400, + "duplicate Content-Length with different values is 400"); + + /* 2. Content-Length together with Transfer-Encoding -> 400/501 */ + { + int s = request(EDGE_PORT, + "POST /echo HTTP/1.1\r\n" + "Content-Length: 5\r\n" + "Transfer-Encoding: chunked\r\n" + "\r\n" + "hello"); + CHECK(s == 400 || s == 501, + "Content-Length with Transfer-Encoding is rejected"); + } + + /* 3. Bare LF line endings are accepted */ + CHECK(request(EDGE_PORT, + "GET / HTTP/1.1\n" + "Host: x\n" + "\n") == 200, + "bare LF request line endings are accepted"); + + /* 4. obs-fold continuation line: request is served, continuation dropped */ + { + char reply[512]; + int s = send_raw(EDGE_PORT, + "GET / HTTP/1.1\r\n" + "Host: x\r\n" + "X-Fold: one\r\n" + " two\r\n" + "\r\n", strlen("GET / HTTP/1.1\r\nHost: x\r\nX-Fold: one\r\n two\r\n\r\n"), + reply, sizeof(reply)); + CHECK(s == 200 && strstr(reply, "ok") != NULL, + "obs-fold continuation does not break the request"); + } + + /* 5. Expect: 100-continue: the request still completes */ + { + int fd = connect_to(EDGE_PORT); + int got_continue = 0; + char buf[512]; + const char *req = + "POST /echo HTTP/1.1\r\n" + "Content-Length: 5\r\n" + "Expect: 100-continue\r\n" + "\r\n"; + if (fd >= 0) { + send(fd, req, strlen(req), 0); + /* Wait briefly: a spec server sends "100 Continue" here. */ + ssize_t n = recv(fd, buf, sizeof(buf) - 1, 0); + if (n > 0) { + buf[n] = '\0'; + got_continue = (strncmp(buf, "HTTP/1.1 100", 12) == 0); + } + /* Whether or not 100 Continue arrived, the client sends the body. */ + send(fd, "hello", 5, 0); + if (!got_continue) { + ssize_t m = recv(fd, buf, sizeof(buf) - 1, 0); + if (m > 0) { + buf[m] = '\0'; + CHECK(strstr(buf, "200") != NULL && strstr(buf, "hello") != NULL, + "Expect: 100-continue request still completes"); + } else { + CHECK(0, "Expect: 100-continue request still completes"); + } + } else { + CHECK(1, "Expect: 100-continue request still completes"); + } + close(fd); + } else { + CHECK(0, "Expect: 100-continue request still completes"); + } + printf(" NOTE 100 Continue sent: %s\n", got_continue ? "yes" : "no"); + } + + /* 6. Bad Content-Length values are rejected */ + CHECK(request(EDGE_PORT, + "POST /echo HTTP/1.1\r\n" + "Content-Length: -5\r\n" + "\r\n") == 400, + "negative Content-Length is 400"); + + CHECK(request(EDGE_PORT, + "POST /echo HTTP/1.1\r\n" + "Content-Length: 99999999999999999999\r\n" + "\r\n") == 413, + "overflowing Content-Length is 413"); + + CHECK(request(EDGE_PORT, + "POST /echo HTTP/1.1\r\n" + "Content-Length: abc\r\n" + "\r\n") == 400, + "non-numeric Content-Length is 400"); + + /* 7. Request line without an HTTP version */ + { + int s = request(EDGE_PORT, "GET /\r\nHost: x\r\n\r\n"); + printf(" NOTE version-less request line -> %d\n", s); + CHECK(s == 200 || s == 400, + "version-less request line has a defined status"); + } + + /* 8. Space before the header colon: request is served */ + { + char reply[512]; + int s = send_raw(EDGE_PORT, + "GET / HTTP/1.1\r\n" + "X-Spaced : value\r\n" + "Host: x\r\n" + "\r\n", strlen("GET / HTTP/1.1\r\nX-Spaced : value\r\nHost: x\r\n\r\n"), + reply, sizeof(reply)); + CHECK(s == 200, "header name with a space before the colon is served"); + } + + /* 9. Pipelined requests on one keep-alive connection. + (Pipelining needs an explicit Connection: keep-alive header: the + server does not default HTTP/1.1 to persistent connections.) */ + { + int fd = connect_to(EDGE_PORT); + char buf[2048]; + const char *req = + "GET / HTTP/1.1\r\nHost: x\r\nConnection: keep-alive\r\n\r\n" + "GET / HTTP/1.1\r\nHost: x\r\nConnection: keep-alive\r\n\r\n"; + size_t used = 0; + int n200 = 0; + if (fd >= 0) { + send(fd, req, strlen(req), 0); + for (int i = 0; i < 2; i++) { + ssize_t n = recv(fd, buf + used, sizeof(buf) - 1 - used, 0); + if (n <= 0) break; + used += (size_t)n; + buf[used] = '\0'; + if (strstr(buf, "") != NULL) break; + } + close(fd); + for (const char *p = buf; (p = strstr(p, "200 OK")) != NULL; p += 6) { + n200++; + } + CHECK(n200 >= 2, "two pipelined requests are both answered"); + } else { + CHECK(0, "two pipelined requests are both answered"); + } + } + + kill(pid, SIGTERM); + waitpid(pid, NULL, 0); + + printf("\n%d passed, %d failed\n", passed, failures); + return failures == 0 ? 0 : 1; +} + +#else + +int main(void) +{ + printf("HTTP parsing edge-case tests are not run on Windows.\n"); + return 0; +} + +#endif