From b0ef95592b9a60fe19cc0938fb3aa6b6d09c47bb Mon Sep 17 00:00:00 2001 From: trim21 Date: Sun, 10 May 2026 15:44:10 +0800 Subject: [PATCH] fix SCGI parse_headers: defer content-type body peek until body received MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit detect_content_type() peeked at m_buffer[m_body] to infer JSON vs XML when no CONTENT_TYPE header was provided. When the TCP header segment arrives without any body bytes, m_body equals m_position and the peek reads the null terminator padding byte — not the actual '{' or '[' — causing JSON requests to be incorrectly classified as XML and fail. Fix: - Remove the body peek from detect_content_type(); defer it to after the full body is confirmed present in event_read(). - Add a m_content_type_set flag to distinguish header-provided type from auto-detected type. --- repro-scgi-content-type.py | 93 ++++++++++++++++++++++++++++++++++++++ src/rpc/scgi_task.cc | 22 +++++---- src/rpc/scgi_task.h | 1 + 3 files changed, 107 insertions(+), 9 deletions(-) create mode 100644 repro-scgi-content-type.py diff --git a/repro-scgi-content-type.py b/repro-scgi-content-type.py new file mode 100644 index 00000000..34f38d30 --- /dev/null +++ b/repro-scgi-content-type.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +"""Reproduce SCGI content-type auto-detection bug in rtorrent. + +Sends a JSON SCGI request without Content-Type header, splitting the +TCP stream so that the header arrives before the body. If the server +peeks at the body start (m_buffer[m_body]) before the body has arrived, +it reads a null byte instead of '{' and defaults to XML — causing the +JSON-RPC call to fail. + +Usage: + python3 repro-scgi-content-type.py host:port + python3 repro-scgi-content-type.py 127.0.0.1:5101 +""" + +import socket +import sys +import time + + +SCGI_HEADERS = ( + b"CONTENT_LENGTH\x00154\x00" + b"SCGI\x001\x00" +) + +JSON_BODY = ( + b'{"method":"system.listMethods","params":[],"id":1}' +) + +HEADER_LENGTH = len(SCGI_HEADERS) +HEADER_LEN_STR = str(HEADER_LENGTH).encode() + + +def send_in_chunks(host: str, port: int, chunks: list[bytes]) -> bytes: + with socket.create_connection((host, port), timeout=5) as sock: + for data in chunks: + sock.sendall(data) + time.sleep(0.05) + return sock.recv(4096) + + +def test_normal(host: str, port: int) -> bool: + """All in one segment (works either way).""" + full_request = HEADER_LEN_STR + b":" + SCGI_HEADERS + b"," + JSON_BODY + response = send_in_chunks(host, port, [full_request]) + ok = len(response) > 0 + print(f" Normal send: {'OK' if ok else 'FAIL'} (got {len(response)} bytes)") + return ok + + +def test_split_header_body(host: str, port: int) -> bool: + """Header arrives first, body second (triggers the bug).""" + chunks = [ + HEADER_LEN_STR + b":" + SCGI_HEADERS + b",", + JSON_BODY, + ] + response = send_in_chunks(host, port, chunks) + ok = len(response) > 0 + print(f" Split header/body: {'OK' if ok else 'FAIL'} (got {len(response)} bytes)") + return ok + + +def main() -> None: + if len(sys.argv) != 2: + print(__doc__) + sys.exit(1) + + addr = sys.argv[1] + if ":" in addr: + host, port_str = addr.rsplit(":", 1) + port = int(port_str) + else: + host, port = addr, 80 + + print(f"=== Reproducing SCGI content-type defer bug against {host}:{port} ===\n") + + normal_ok = test_normal(host, port) + if not normal_ok: + print("\nServer doesn't seem to be running or reachable. Aborting.") + sys.exit(1) + + split_ok = test_split_header_body(host, port) + + print() + if not split_ok: + print("RESULT: BUG CONFIRMED — JSON request fails when body arrives after header.") + print(" detect_content_type() peeked at m_buffer[m_body] which was \\0 (not '{').") + else: + print("RESULT: Bug not reproduced — server handles content-type defer correctly.") + print(f" normal={normal_ok} split_header_body={split_ok}") + + +if __name__ == "__main__": + main() diff --git a/src/rpc/scgi_task.cc b/src/rpc/scgi_task.cc index 9032775f..aa93a8c7 100644 --- a/src/rpc/scgi_task.cc +++ b/src/rpc/scgi_task.cc @@ -34,6 +34,7 @@ SCgiTask::open(SCgi* parent, int fd) { m_content_length = 0; m_content_type = XML; + m_content_type_set = false; m_accepts_compression = false; m_trusted = true; // SCgiTask is pooled and reused; reset trust to default // so a prior untrusted connection does not leak its @@ -160,6 +161,11 @@ SCgiTask::event_read() { lt_log_print_dump(torrent::LOG_RPC_DUMP, m_buffer.data() + m_body, m_content_length, "scgi", "RPC read.", 0); + if (!m_content_type_set) { + if (m_buffer[m_body] == '{' || m_buffer[m_body] == '[') + m_content_type = ContentType::JSON; + } + receive_call(m_buffer.data() + m_body, m_content_length); return; @@ -293,19 +299,17 @@ scgi_match_content_type(const std::string& content_type, const char* type) { bool SCgiTask::detect_content_type(const std::string& content_type) { if (content_type.empty()) { - // If no CONTENT_TYPE was supplied, peek at the body to check if it's JSON - // { is a single request object, while [ is a batch array - - if (m_buffer[m_body] == '{' || m_buffer[m_body] == '[') - m_content_type = ContentType::JSON; - else - m_content_type = ContentType::XML; + // Defer body-peek detection until the full body is received. + // event_read() will auto-detect from the first body byte after + // confirming m_position >= m_body + m_content_length. } else if (scgi_match_content_type(content_type, "application/json")) { - m_content_type = ContentType::JSON; + m_content_type = ContentType::JSON; + m_content_type_set = true; } else if (scgi_match_content_type(content_type, "text/xml")) { - m_content_type = ContentType::XML; + m_content_type = ContentType::XML; + m_content_type_set = true; } else { // If the content type is not JSON or XML, we don't know how to handle it. diff --git a/src/rpc/scgi_task.h b/src/rpc/scgi_task.h index fea62b6c..e012e9be 100644 --- a/src/rpc/scgi_task.h +++ b/src/rpc/scgi_task.h @@ -67,6 +67,7 @@ private: bool m_accepts_compression{}; bool m_trusted{true}; + bool m_content_type_set{false}; }; }