diff --git a/src/htsback.c b/src/htsback.c index e1a9bf962..610726774 100644 --- a/src/htsback.c +++ b/src/htsback.c @@ -4094,13 +4094,25 @@ void back_wait(struct_back * sback, httrackp * opt, cache_back * cache, int noFreebuff = 0; LLint content_length = -1; hts_boolean length_conflict = HTS_FALSE; + /* 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) != + NULL; - /* Hack for zero-length headers */ - if (back_status(&back[i]) != 0 && back[i].r.adr[0] != '<') { - + 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! - // 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); @@ -4202,7 +4214,8 @@ void back_wait(struct_back * sback, httrackp * opt, cache_back * cache, back[i].url_fil); back_slot_close_fail(opt, sback, i, STATUSCODE_INVALID, "Conflicting Content-Length"); - } else { + } 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, diff --git a/tests/2311_local-header-nul.test b/tests/2311_local-header-nul.test new file mode 100755 index 000000000..e3c95c415 --- /dev/null +++ b/tests/2311_local-header-nul.test @@ -0,0 +1,22 @@ +#!/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" \ + --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 546ef45a2..89e74ccf6 100755 --- a/tests/local-server.py +++ b/tests/local-server.py @@ -4076,7 +4076,61 @@ def route_warc100_normal(self): except OSError: pass + # 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": (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): + self.send_html( + "".join('%s\n' % (n, n) for n in self.NULHDR_FILES) + ) + + def route_nulhdr_file(self): + status, lines, body = self.NULHDR_FILES[self.path.rsplit("/", 1)[1]] + self.close_connection = True + 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) + if self.command != "HEAD": + self.wfile.write(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, + "/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,