From e6dbe521cf59418d28bd93d864adb4949cd500a2 Mon Sep 17 00:00:00 2001 From: Xavier Roche Date: Sat, 14 Nov 2026 18:06:09 +0100 Subject: [PATCH 1/4] Drop a response whose header holds a NUL byte The header reader ends a line at a NUL, so "Content-Length: 16\0, 8" hid the ", 8", and a NUL before the line end could end the header block early. RFC 9110 section 5.5 lets a recipient reject such a field or replace the NUL with a space. Rejecting is the safer choice: a space would let the hidden value through, and #2237 already drops a response whose framing headers cannot be trusted. Closes #2238 Co-Authored-By: Claude Opus 5.5 Signed-off-by: Xavier Roche --- src/htsback.c | 24 +++++++++++++++++++----- tests/2311_local-header-nul.test | 14 ++++++++++++++ tests/local-server.py | 31 +++++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+), 5 deletions(-) create mode 100755 tests/2311_local-header-nul.test diff --git a/src/htsback.c b/src/htsback.c index 1a3eac7ed..f07a0172c 100644 --- a/src/htsback.c +++ b/src/htsback.c @@ -4092,9 +4092,24 @@ void back_wait(struct_back * sback, httrackp * opt, cache_back * cache, int ptr = 0; int adv = 0; int noFreebuff = 0; - - /* Hack for zero-length headers */ - if (back_status(&back[i]) != 0 && back[i].r.adr[0] != '<') { + /* RFC 9110 5.5: a NUL ends a line early, so the fields after + it are not the ones the server sent */ + const hts_boolean has_nul = + back[i].r.adr[0] != '<' && + memchr(back[i].r.adr, '\0', (size_t) back[i].r.size) != + NULL; + + if (has_nul) { + hts_log_print(opt, LOG_WARNING, + "NUL byte in response header, response " + "dropped: %s%s", + adr_nologin(opt, back[i].url_adr), + back[i].url_fil); + back_slot_close_fail(opt, sback, i, STATUSCODE_INVALID, + "NUL byte in response header"); + } else if (back_status(&back[i]) != 0 && + back[i].r.adr[0] != '<') { + /* Hack for zero-length headers */ // ---------------------------------------- // traiter en-tête! @@ -4177,8 +4192,7 @@ void back_wait(struct_back * sback, httrackp * opt, cache_back * cache, noFreebuff = 1; } - // Callback - { + if (!has_nul) { int test_head = RUN_CALLBACK6(opt, receivehead, back[i].r.adr, back[i].url_adr, diff --git a/tests/2311_local-header-nul.test b/tests/2311_local-header-nul.test new file mode 100755 index 000000000..b8f94f504 --- /dev/null +++ b/tests/2311_local-header-nul.test @@ -0,0 +1,14 @@ +#!/bin/bash +# +# RFC 9110 5.5: a NUL byte in a response header drops the response. + +set -euo pipefail + +: "${top_srcdir:=..}" + +bash "$top_srcdir/tests/local-crawl.sh" --errors 2 \ + --file-bytes 'nulhdr/clean.bin' 16 \ + --not-found 'nulhdr/in-list.bin' --not-found 'nulhdr/ends-block.bin' \ + --log-found 'NUL byte in response header, response dropped: .*/nulhdr/in-list\.bin' \ + --log-found 'NUL byte in response header, response dropped: .*/nulhdr/ends-block\.bin' \ + httrack 'BASEURL/nulhdr/index.html' diff --git a/tests/local-server.py b/tests/local-server.py index 474f57a33..a7121707a 100755 --- a/tests/local-server.py +++ b/tests/local-server.py @@ -4038,7 +4038,38 @@ def route_warc100_normal(self): except OSError: pass + # A NUL inside a header (RFC 9110 5.5) must drop the response: each file + # sends NULHDR_BODY after the header lines listed. + NULHDR_BODY = b"0123456789abcdef" + NULHDR_FILES = { + "clean.bin": (b"Content-Length: 16",), + "in-list.bin": (b"Content-Length: 16\0, 8",), + "ends-block.bin": (b"Content-Length: 16\0", b"Content-Length: 8"), + } + + def route_nulhdr_index(self): + self.send_html( + "".join('%s\n' % (n, n) for n in self.NULHDR_FILES) + ) + + def route_nulhdr_file(self): + lines = self.NULHDR_FILES[self.path.rsplit("/", 1)[1]] + self.close_connection = True + head = b"HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\n" + head += b"".join(line + b"\r\n" for line in lines) + try: + self.wfile.write(head + b"Connection: close\r\n\r\n") + if self.command != "HEAD": + self.wfile.write(self.NULHDR_BODY) + self.wfile.flush() + except OSError: + pass + ROUTES = { + "/nulhdr/index.html": route_nulhdr_index, + "/nulhdr/clean.bin": route_nulhdr_file, + "/nulhdr/in-list.bin": route_nulhdr_file, + "/nulhdr/ends-block.bin": route_nulhdr_file, "/sfmark.html": route_singlefile_mark, "/warc100/index.html": route_warc100_index, "/warc100/interim.html": route_warc100_interim, From d583d16756b2d84066a08d4e68d17af19ebddab0 Mon Sep 17 00:00:00 2001 From: Xavier Roche Date: Sat, 14 Nov 2026 18:20:47 +0100 Subject: [PATCH 2/4] Test cookies, the status line and headerless replies with a NUL Add fixtures for a Set-Cookie before the NUL line, a NUL in the status line, a NUL-filled body and a headerless reply holding a NUL, and run the error count last so each mutant fails on its own assertion. Make the htsback.c comments accurate and translate the French ones touched. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Xavier Roche --- src/htsback.c | 9 +++---- tests/2311_local-header-nul.test | 10 +++++++- tests/local-server.py | 43 ++++++++++++++++++++++++-------- 3 files changed, 45 insertions(+), 17 deletions(-) diff --git a/src/htsback.c b/src/htsback.c index f07a0172c..804335a06 100644 --- a/src/htsback.c +++ b/src/htsback.c @@ -4092,8 +4092,7 @@ void back_wait(struct_back * sback, httrackp * opt, cache_back * cache, int ptr = 0; int adv = 0; int noFreebuff = 0; - /* RFC 9110 5.5: a NUL ends a line early, so the fields after - it are not the ones the server sent */ + /* binput_line stops at a NUL, hiding the fields after it */ const hts_boolean has_nul = back[i].r.adr[0] != '<' && memchr(back[i].r.adr, '\0', (size_t) back[i].r.size) != @@ -4107,13 +4106,11 @@ void back_wait(struct_back * sback, httrackp * opt, cache_back * cache, back[i].url_fil); back_slot_close_fail(opt, sback, i, STATUSCODE_INVALID, "NUL byte in response header"); + /* Hack for zero-length headers */ } else if (back_status(&back[i]) != 0 && back[i].r.adr[0] != '<') { - /* Hack for zero-length headers */ - // ---------------------------------------- - // traiter en-tête! - // status-line à récupérer + /* parse the header, status line first */ binput_line(back[i].r.adr + ptr, back[i].r.adr + back[i].r.size, rcvd, 2000, &adv); diff --git a/tests/2311_local-header-nul.test b/tests/2311_local-header-nul.test index b8f94f504..e3c95c415 100755 --- a/tests/2311_local-header-nul.test +++ b/tests/2311_local-header-nul.test @@ -6,9 +6,17 @@ set -euo pipefail : "${top_srcdir:=..}" -bash "$top_srcdir/tests/local-crawl.sh" --errors 2 \ +bash "$top_srcdir/tests/local-crawl.sh" \ --file-bytes 'nulhdr/clean.bin' 16 \ + --file-bytes 'nulhdr/body-nul.bin' 16 \ + --file-matches 'nulhdr/bare.html' 'bare-ok' \ + --file-matches '../cookies.txt' 'good' \ + --file-not-matches '../cookies.txt' 'nulcookie' \ --not-found 'nulhdr/in-list.bin' --not-found 'nulhdr/ends-block.bin' \ + --not-found 'nulhdr/cookie.bin' --not-found 'nulhdr/status.bin' \ --log-found 'NUL byte in response header, response dropped: .*/nulhdr/in-list\.bin' \ --log-found 'NUL byte in response header, response dropped: .*/nulhdr/ends-block\.bin' \ + --log-found 'NUL byte in response header, response dropped: .*/nulhdr/cookie\.bin' \ + --log-found 'NUL byte in response header, response dropped: .*/nulhdr/status\.bin' \ + --errors 4 \ httrack 'BASEURL/nulhdr/index.html' diff --git a/tests/local-server.py b/tests/local-server.py index a7121707a..f3e1afee0 100755 --- a/tests/local-server.py +++ b/tests/local-server.py @@ -4038,13 +4038,27 @@ def route_warc100_normal(self): except OSError: pass - # A NUL inside a header (RFC 9110 5.5) must drop the response: each file - # sends NULHDR_BODY after the header lines listed. + # A NUL inside a response header (RFC 9110 5.5) must drop the response. + # Each file is (status line, header lines, body); a None status line sends + # the body alone, as a server that answers without headers does. NULHDR_BODY = b"0123456789abcdef" + NULHDR_OK = b"HTTP/1.1 200 OK" NULHDR_FILES = { - "clean.bin": (b"Content-Length: 16",), - "in-list.bin": (b"Content-Length: 16\0, 8",), - "ends-block.bin": (b"Content-Length: 16\0", b"Content-Length: 8"), + "clean.bin": (NULHDR_OK, (b"Set-Cookie: good=1; Path=/",), NULHDR_BODY), + "body-nul.bin": (NULHDR_OK, (), b"\0" * 16), + "in-list.bin": (NULHDR_OK, (b"Content-Length: 16\0, 8",), NULHDR_BODY), + "ends-block.bin": ( + NULHDR_OK, + (b"Content-Length: 16\0", b"Content-Length: 8"), + NULHDR_BODY, + ), + "cookie.bin": ( + NULHDR_OK, + (b"Set-Cookie: nulcookie=1; Path=/", b"X-Pad: a\0b"), + NULHDR_BODY, + ), + "status.bin": (b"HTTP/1.1 200 OK\0junk", (), NULHDR_BODY), + "bare.html": (None, (), b"bare-ok\0x\n\n"), } def route_nulhdr_index(self): @@ -4053,14 +4067,19 @@ def route_nulhdr_index(self): ) def route_nulhdr_file(self): - lines = self.NULHDR_FILES[self.path.rsplit("/", 1)[1]] + status, lines, body = self.NULHDR_FILES[self.path.rsplit("/", 1)[1]] self.close_connection = True - head = b"HTTP/1.1 200 OK\r\nContent-Type: application/octet-stream\r\n" - head += b"".join(line + b"\r\n" for line in lines) + head = b"" + if status is not None: + head = status + b"\r\nContent-Type: application/octet-stream\r\n" + if not any(line.startswith(b"Content-Length:") for line in lines): + head += b"Content-Length: %d\r\n" % len(body) + head += b"".join(line + b"\r\n" for line in lines) + head += b"Connection: close\r\n\r\n" try: - self.wfile.write(head + b"Connection: close\r\n\r\n") + self.wfile.write(head) if self.command != "HEAD": - self.wfile.write(self.NULHDR_BODY) + self.wfile.write(body) self.wfile.flush() except OSError: pass @@ -4070,6 +4089,10 @@ def route_nulhdr_file(self): "/nulhdr/clean.bin": route_nulhdr_file, "/nulhdr/in-list.bin": route_nulhdr_file, "/nulhdr/ends-block.bin": route_nulhdr_file, + "/nulhdr/body-nul.bin": route_nulhdr_file, + "/nulhdr/cookie.bin": route_nulhdr_file, + "/nulhdr/status.bin": route_nulhdr_file, + "/nulhdr/bare.html": route_nulhdr_file, "/sfmark.html": route_singlefile_mark, "/warc100/index.html": route_warc100_index, "/warc100/interim.html": route_warc100_interim, From 4b212f58d641af230698002343578a1017558e8c Mon Sep 17 00:00:00 2001 From: Xavier Roche Date: Sat, 14 Nov 2026 18:26:48 +0100 Subject: [PATCH 3/4] Put the zero-length headers comment back on its branch Co-Authored-By: Claude Opus 5.5 Signed-off-by: Xavier Roche --- src/htsback.c | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/htsback.c b/src/htsback.c index 5f1e288f6..4bcba8e5c 100644 --- a/src/htsback.c +++ b/src/htsback.c @@ -4108,9 +4108,9 @@ void back_wait(struct_back * sback, httrackp * opt, cache_back * cache, back[i].url_fil); back_slot_close_fail(opt, sback, i, STATUSCODE_INVALID, "NUL byte in response header"); - /* Hack for zero-length headers */ } else if (back_status(&back[i]) != 0 && back[i].r.adr[0] != '<') { + /* Hack for zero-length headers */ // ---------------------------------------- /* parse the header, status line first */ binput_line(back[i].r.adr + ptr, From 05c88882af346cd602fe7d6f557644482052a250 Mon Sep 17 00:00:00 2001 From: Xavier Roche Date: Sat, 14 Nov 2026 18:36:14 +0100 Subject: [PATCH 4/4] Say why the header callback skips a NUL-bearing response Co-Authored-By: Claude Opus 5.5 Signed-off-by: Xavier Roche --- src/htsback.c | 1 + 1 file changed, 1 insertion(+) diff --git a/src/htsback.c b/src/htsback.c index 4bcba8e5c..610726774 100644 --- a/src/htsback.c +++ b/src/htsback.c @@ -4215,6 +4215,7 @@ void back_wait(struct_back * sback, httrackp * opt, cache_back * cache, back_slot_close_fail(opt, sback, i, STATUSCODE_INVALID, "Conflicting Content-Length"); } else if (!has_nul) { + /* a plugin must not see the header of a dropped response */ int test_head = RUN_CALLBACK6(opt, receivehead, back[i].r.adr, back[i].url_adr,