#!/usr/bin/env python3
'''
Send a single chunked POST whose chunk extension carries a quoted-string value.
The quoted value embeds octets that look like a second HTTP request. Per RFC 9110
Section 5.6.4 the quoted-string runs until its closing DQUOTE, so a correct proxy
treats the whole thing as one request and the embedded GET is never parsed as a
request of its own.

The script prints the number of HTTP responses received on the connection and the
raw response, so the test can assert that exactly one response came back and that
the embedded endpoint was not reached.
'''
#  Licensed to the Apache Software Foundation (ASF) under one
#  or more contributor license agreements.  See the NOTICE file
#  distributed with this work for additional information
#  regarding copyright ownership.  The ASF licenses this file
#  to you under the Apache License, Version 2.0 (the
#  "License"); you may not use this file except in compliance
#  with the License.  You may obtain a copy of the License at
#
#      http://www.apache.org/licenses/LICENSE-2.0
#
#  Unless required by applicable law or agreed to in writing, software
#  distributed under the License is distributed on an "AS IS" BASIS,
#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
#  See the License for the specific language governing permissions and
#  limitations under the License.

import socket
import sys
import time

# A chunked POST whose first chunk opens a quoted-string extension value with a
# CR/LF inside it. A correct parser rejects the request (the quoted-string cannot
# contain CR/LF). A parser that ignores the quoted-string ends the chunk size
# line at the first CRLF, reads "X" as the one-byte chunk, sees the 0-chunk, and
# then treats the trailing GET as a separate pipelined request, forwarding it to
# the origin. The origin answers that GET /second with a distinctive body, so if
# it ever reaches the client the request was smuggled.
PAYLOAD = (
    b"POST / HTTP/1.1\r\n"
    b"Host: localhost\r\n"
    b"uuid: 1\r\n"
    b"Transfer-Encoding: chunked\r\n"
    b"\r\n"
    b'1;a="\r\n'
    b"X\r\n"
    b"0\r\n"
    b"\r\n"
    b"GET /second HTTP/1.1\r\n"
    b"Host: localhost\r\n"
    b"uuid: 2\r\n"
    b"Connection: close\r\n"
    b"\r\n")

# Byte offset at which to split the payload across two sends, right after the
# opening DQUOTE and before the CR inside it, so the proxy must suspend parsing
# the extension on the first read and resume it (and reject) on the second.
SPLIT_AT = PAYLOAD.index(b'1;a="') + len(b'1;a="')


def main() -> int:
    if len(sys.argv) < 3:
        print(f"usage: {sys.argv[0]} <host> <port> [--split]", file=sys.stderr)
        return 2

    host, port = sys.argv[1], int(sys.argv[2])
    split = "--split" in sys.argv[3:]

    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.settimeout(5)
    response = b""
    try:
        sock.connect((host, port))
        if split:
            sock.sendall(PAYLOAD[:SPLIT_AT])
            time.sleep(0.3)  # Force the remainder into a separate read on the proxy.
            try:
                sock.sendall(PAYLOAD[SPLIT_AT:])
            except OSError:
                pass  # The proxy may have already rejected and closed the connection.
        else:
            sock.sendall(PAYLOAD)
        while True:
            try:
                chunk = sock.recv(4096)
            except socket.timeout:
                break
            if not chunk:
                break
            response += chunk
    finally:
        sock.close()

    responses = response.count(b"HTTP/1.1 ")
    status = response.split(b"\r\n", 1)[0].decode(errors="replace") if response else "(no response)"
    print(f"responses={responses}")
    print(f"status={status}")
    print("=== response ===")
    print(response.decode(errors="replace"))
    print("=== end ===")
    return 0


if __name__ == "__main__":
    sys.exit(main())
