mirror of
https://github.com/rakshasa/rtorrent.git
synced 2026-08-05 17:52:29 +00:00
fix SCGI parse_headers: defer content-type body peek until body received
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.
This commit is contained in:
@@ -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()
|
||||
+13
-9
@@ -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.
|
||||
|
||||
@@ -67,6 +67,7 @@ private:
|
||||
|
||||
bool m_accepts_compression{};
|
||||
bool m_trusted{true};
|
||||
bool m_content_type_set{false};
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user