From 799945a6f3d15d0cd1eb5df328248da3859985f5 Mon Sep 17 00:00:00 2001 From: arin30 <43118055+arin30@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:21:06 -0700 Subject: [PATCH 1/2] Reject embedded NUL bytes in HTTP request lines and headers Signed-off-by: Arin Aeruva <43118055+arin30@users.noreply.github.com> --- src/http.c | 2 ++ tests/test_http.c | 21 +++++++++++++++++++++ 2 files changed, 23 insertions(+) diff --git a/src/http.c b/src/http.c index 82b2cbd..4ead51b 100644 --- a/src/http.c +++ b/src/http.c @@ -71,6 +71,7 @@ http_parse_result_t http_parse_request(const char *buf,size_t len,size_t max_hea if(len>max_header_bytes) return HTTP_PARSE_TOO_LARGE; memset(out,0,sizeof(*out)); out->content_length=0; const char *end=buf+len,*line=find_crlf(buf,end); if(!line) return HTTP_PARSE_BAD_REQUEST; + if(memchr(buf,'\0',(size_t)(line-buf))) return HTTP_PARSE_BAD_REQUEST; const char *sp1=memchr(buf,' ',(size_t)(line-buf)); if(!sp1||sp1==buf) return HTTP_PARSE_BAD_REQUEST; const char *sp2=memchr(sp1+1,' ',(size_t)(line-(sp1+1))); if(!sp2) return HTTP_PARSE_BAD_REQUEST; if(memchr(sp2+1,' ',(size_t)(line-(sp2+1)))) return HTTP_PARSE_BAD_REQUEST; @@ -81,6 +82,7 @@ http_parse_result_t http_parse_request(const char *buf,size_t len,size_t max_hea int host_count=0,cl_seen=0; const char *p=line+2; while(pversion,"HTTP/1.1")&&host_count!=1) return HTTP_PARSE_BAD_REQUEST; return HTTP_PARSE_OK; } if(out->header_count>=HTTP_MAX_HEADERS) return HTTP_PARSE_TOO_LARGE; const char *colon=memchr(p,':',(size_t)(eol-p)); if(!colon||colon==p) return HTTP_PARSE_BAD_REQUEST; diff --git a/tests/test_http.c b/tests/test_http.c index f914cae..e44e23e 100644 --- a/tests/test_http.c +++ b/tests/test_http.c @@ -5,6 +5,27 @@ static http_parse_result_t parse(const char *s){ http_request_t r; size_t e=0; return http_parse_request(s,strlen(s),16384,&r,&e); } int main(void){ + { + const char *requests[]={ + "GET\0X / HTTP/1.1\r\nHost: localhost\r\n\r\n", + "GET /ok\0hidden HTTP/1.1\r\nHost: localhost\r\n\r\n", + "GET / HTTP/1.1\0hidden\r\nHost: localhost\r\n\r\n", + "GET / HTTP/1.1\r\nHost\0hidden: localhost\r\n\r\n", + "GET / HTTP/1.1\r\nHost: localhost\0hidden\r\n\r\n" + }; + const size_t lengths[]={ + sizeof("GET\0X / HTTP/1.1\r\nHost: localhost\r\n\r\n")-1, + sizeof("GET /ok\0hidden HTTP/1.1\r\nHost: localhost\r\n\r\n")-1, + sizeof("GET / HTTP/1.1\0hidden\r\nHost: localhost\r\n\r\n")-1, + sizeof("GET / HTTP/1.1\r\nHost\0hidden: localhost\r\n\r\n")-1, + sizeof("GET / HTTP/1.1\r\nHost: localhost\0hidden\r\n\r\n")-1 + }; + http_request_t r; size_t e=0; + for(size_t i=0;i Date: Mon, 26 Oct 2026 11:22:35 -0700 Subject: [PATCH 2/2] Test NUL header rejection through the proxy socket reader Use a byte-bounded header terminator search so embedded NUL bytes reach parser validation. Verify malformed requests never reach the backend, with a valid forwarding control. Signed-off-by: Arin Aeruva <43118055+arin30@users.noreply.github.com> --- src/cinderproxy.c | 11 ++++++-- tests/test_integration.py | 53 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 2 deletions(-) diff --git a/src/cinderproxy.c b/src/cinderproxy.c index 3a29e46..33ddfdf 100644 --- a/src/cinderproxy.c +++ b/src/cinderproxy.c @@ -23,6 +23,13 @@ #define QUEUE_CAPACITY 256 #define BODY_CHUNK 16384 +static int headers_complete(const char *buf, size_t len) { + for (size_t i = 0; i + 3 < len; i++) { + if (memcmp(buf + i, "\r\n\r\n", 4) == 0) return 1; + } + return 0; +} + typedef struct { char ip[64]; time_t start; int count; int used; } rate_entry_t; typedef struct { rate_entry_t e[RATE_SLOTS]; pthread_mutex_t lock; int limit; int window; } limiter_t; typedef struct backend_health backend_health_t; @@ -284,9 +291,9 @@ static void handle_client(client_t *c) { if (n <= 0) { free(buf); goto done; } used += (size_t)n; buf[used] = '\0'; - if (strstr(buf, "\r\n\r\n")) break; + if (headers_complete(buf, used)) break; } - if (!strstr(buf, "\r\n\r\n")) { + if (!headers_complete(buf, used)) { error_resp(fd, tls, 431, "Request Header Fields Too Large"); free(buf); goto done; diff --git a/tests/test_integration.py b/tests/test_integration.py index 175c41d..9392556 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -3,6 +3,8 @@ import socket import subprocess import time +import threading +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) PROXY_PORT = 18080 @@ -53,6 +55,56 @@ def oversized_header_status(): return response.split(b" ", 2)[1] +def test_nul_headers_never_reach_backend(): + received = [] + + class RecordingHandler(BaseHTTPRequestHandler): + def do_GET(self): + received.append(self.path) + self.send_response(200) + self.send_header("Content-Length", "0") + self.end_headers() + + def log_message(self, *_): + pass + + backend = ThreadingHTTPServer(("127.0.0.1", 0), RecordingHandler) + thread = threading.Thread(target=backend.serve_forever, daemon=True) + thread.start() + proxy = None + try: + proxy = subprocess.Popen([ + "./build/cinderproxy", "--listen", str(PROXY_PORT), + "--backend-host", "127.0.0.1", "--backend-port", str(backend.server_port), + "--timeout", "2", + ], cwd=ROOT) + wait_for_port(PROXY_PORT) + assert proxy.poll() is None, "test proxy did not start" + malformed = [ + b"GET\x00X /nul HTTP/1.1\r\nHost: localhost\r\n\r\n", + b"GET /nul\x00hidden HTTP/1.1\r\nHost: localhost\r\n\r\n", + b"GET /nul HTTP/1.1\x00hidden\r\nHost: localhost\r\n\r\n", + b"GET /nul HTTP/1.1\r\nHost\x00hidden: localhost\r\n\r\n", + b"GET /nul HTTP/1.1\r\nHost: localhost\x00hidden\r\n\r\n", + ] + for payload in malformed: + with socket.create_connection(("127.0.0.1", PROXY_PORT), timeout=3) as sock: + sock.sendall(payload) + response = http.client.HTTPResponse(sock) + response.begin() + response.read() + assert response.status == 400, (payload, response.status) + assert received == [], received + assert request("GET", "/control")[0] == 200 + assert received == ["/control"], received + print("test_nul_headers_never_reach_backend: ok") + finally: + stop_process(proxy) + backend.shutdown() + backend.server_close() + thread.join(timeout=3) + + def main(): backend = subprocess.Popen([ "python3", @@ -103,4 +155,5 @@ def main(): if __name__ == "__main__": + test_nul_headers_never_reach_backend() main()