Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 19 additions & 6 deletions src/htsback.c
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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,
Expand Down
22 changes: 22 additions & 0 deletions tests/2311_local-header-nul.test
Original file line number Diff line number Diff line change
@@ -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'
54 changes: 54 additions & 0 deletions tests/local-server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"<html><body>bare-ok\0x</body></html>\n\n"),
}

def route_nulhdr_index(self):
self.send_html(
"".join('<a href="%s">%s</a>\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,
Expand Down
Loading