|
| 1 | +"""Check HTTPS masking, knock, server chains and nested TLS Jump on loopback.""" |
| 2 | +import argparse |
| 3 | +import base64 |
| 4 | +import json |
| 5 | +import socket |
| 6 | +import ssl |
| 7 | +import subprocess |
| 8 | +import tempfile |
| 9 | +import time |
| 10 | +from copy import deepcopy |
| 11 | +from pathlib import Path |
| 12 | +from socketserver import ThreadingTCPServer |
| 13 | +from threading import Thread |
| 14 | + |
| 15 | +from jinja2 import Environment, StrictUndefined |
| 16 | +from ruamel.yaml import YAML |
| 17 | + |
| 18 | +from megaproxy_server.inventory import ROOT, ansible_inventory |
| 19 | +from megaproxy_server.models import Inventory |
| 20 | +from transports import PASSWORD, USERNAME, TcpEcho, port, receive |
| 21 | + |
| 22 | + |
| 23 | +class NestedTLS: |
| 24 | + """A TLS stream inside an existing TLS CONNECT tunnel, using stdlib MemoryBIO.""" |
| 25 | + def __init__(self, outer, context): |
| 26 | + self.outer = outer |
| 27 | + self.incoming, self.outgoing = ssl.MemoryBIO(), ssl.MemoryBIO() |
| 28 | + self.tls = context.wrap_bio(self.incoming, self.outgoing, server_hostname="localhost") |
| 29 | + while True: |
| 30 | + try: |
| 31 | + self.tls.do_handshake() |
| 32 | + self.flush() |
| 33 | + break |
| 34 | + except ssl.SSLWantReadError: |
| 35 | + self.pump() |
| 36 | + |
| 37 | + def flush(self): |
| 38 | + if self.outgoing.pending: |
| 39 | + self.outer.sendall(self.outgoing.read()) |
| 40 | + |
| 41 | + def pump(self): |
| 42 | + self.flush() |
| 43 | + data = self.outer.recv(16384) |
| 44 | + if not data: |
| 45 | + raise EOFError("Nested TLS tunnel closed") |
| 46 | + self.incoming.write(data) |
| 47 | + |
| 48 | + def sendall(self, data): |
| 49 | + while data: |
| 50 | + written = self.tls.write(data) |
| 51 | + data = data[written:] |
| 52 | + self.flush() |
| 53 | + |
| 54 | + def recv(self, size): |
| 55 | + while True: |
| 56 | + try: |
| 57 | + return self.tls.read(size) |
| 58 | + except ssl.SSLWantReadError: |
| 59 | + self.pump() |
| 60 | + |
| 61 | + |
| 62 | +def connect(sock, host, target_port, *, password=PASSWORD): |
| 63 | + auth = base64.b64encode(f"{USERNAME}:{password}".encode()).decode() if password is not None else None |
| 64 | + headers = f"CONNECT {host}:{target_port} HTTP/1.1\r\nHost: {host}:{target_port}\r\n" |
| 65 | + if auth: |
| 66 | + headers += f"Proxy-Authorization: Basic {auth}\r\n" |
| 67 | + sock.sendall((headers + "\r\n").encode()) |
| 68 | + response = b"" |
| 69 | + while not response.endswith(b"\r\n\r\n"): |
| 70 | + response += receive(sock, 1) |
| 71 | + assert len(response) < 16384 |
| 72 | + return int(response.split(b" ", 2)[1]) |
| 73 | + |
| 74 | + |
| 75 | +def main(): |
| 76 | + parser = argparse.ArgumentParser(description=__doc__) |
| 77 | + parser.add_argument("--gost", required=True) |
| 78 | + args = parser.parse_args() |
| 79 | + echo = ThreadingTCPServer(("127.0.0.1", 0), TcpEcho) |
| 80 | + Thread(target=echo.serve_forever, daemon=True).start() |
| 81 | + processes, logs = [], [] |
| 82 | + yaml = YAML(typ="safe") |
| 83 | + try: |
| 84 | + with tempfile.TemporaryDirectory(prefix="megaproxy-https-") as directory: |
| 85 | + root = Path(directory) |
| 86 | + subprocess.run(["openssl", "req", "-x509", "-newkey", "rsa:2048", "-nodes", "-days", "1", |
| 87 | + "-subj", "/CN=localhost", "-addext", "subjectAltName=DNS:localhost,DNS:chain.localhost", |
| 88 | + "-keyout", str(root / "privkey.pem"), "-out", str(root / "fullchain.pem")], check=True, capture_output=True) |
| 89 | + entry_port, exit_port, chain_port = port(), port(), port() |
| 90 | + admin = {"user": "deploy", "private_key_file": "/unused", "public_key": "ssh-ed25519 TEST"} |
| 91 | + inv = Inventory.model_validate({"users": {"https": [{"name": USERNAME, "password": PASSWORD}]}, |
| 92 | + "settings": {"https_chains_enabled": True, "https_chain_domain": "localhost", |
| 93 | + "https_chain_pairs": [{"entry": "entry", "exit": "exit", "title": "CHAIN", "country_code": "US", |
| 94 | + "hostname": "chain.localhost", "probe_resistance": {"enabled": True, "knock": ["chain.localhost"]}}]}, |
| 95 | + "hosts": { |
| 96 | + "entry": {"address": "127.0.0.1", "admin": admin, "services": {"https": { |
| 97 | + "title": "ENTRY", "endpoint": "localhost", "port": entry_port, "certificate": "domain", |
| 98 | + "acme_email": "test@example.com", "chain_entry": True, |
| 99 | + "probe_resistance": {"enabled": True, "knock": ["localhost"]}}}}, |
| 100 | + "exit": {"address": "127.0.0.1", "admin": admin, "services": {"https": { |
| 101 | + "title": "EXIT", "endpoint": "localhost", "port": exit_port, "certificate": "domain", |
| 102 | + "acme_email": "test@example.com", "chain_exit": True, "chain_password": "machine-password-123"}}}}}) |
| 103 | + env = Environment(undefined=StrictUndefined) |
| 104 | + env.filters["to_json"] = json.dumps |
| 105 | + template = env.from_string((ROOT / "roles/https_proxy/templates/gost.yml.j2").read_text()) |
| 106 | + variables = ansible_inventory(inv)["all"]["hosts"] |
| 107 | + configs = {} |
| 108 | + for name, public in (("entry", entry_port), ("exit", exit_port)): |
| 109 | + config = yaml.load(template.render(**dict(variables[name], megaproxy_container_certificate_directory=str(root)))) |
| 110 | + # Distinct loopback ports replace separate host network namespaces and HAProxy. |
| 111 | + for service in config["services"]: |
| 112 | + if service["name"] == "direct": |
| 113 | + service["addr"] = f"127.0.0.1:{public}" |
| 114 | + elif service["name"] == "via-exit": |
| 115 | + service["addr"] = f"127.0.0.1:{chain_port}" |
| 116 | + elif service["name"] == "decoy": |
| 117 | + decoy_port = port() |
| 118 | + service["addr"] = f"127.0.0.1:{decoy_port}" |
| 119 | + service["handler"]["metadata"]["dir"] = str(root) |
| 120 | + for candidate in config["services"]: |
| 121 | + metadata = candidate["handler"].get("metadata", {}) |
| 122 | + if "probeResist" in metadata: |
| 123 | + metadata["probeResist"] = f"host:127.0.0.1:{decoy_port}" |
| 124 | + for chain in config.get("chains") or []: |
| 125 | + chain["hops"][0]["nodes"][0]["dialer"]["tls"]["caFile"] = str(root / "fullchain.pem") |
| 126 | + configs[name] = config |
| 127 | + (root / "index.html").write_text("A local decoy page\n") |
| 128 | + |
| 129 | + def start(name, config, ready): |
| 130 | + path = root / f"{name}.yml" |
| 131 | + with path.open("w") as stream: |
| 132 | + yaml.dump(config, stream) |
| 133 | + log = (root / f"{name}.log").open("w+") |
| 134 | + logs.append(log) |
| 135 | + process = subprocess.Popen([args.gost, "-C", str(path)], stdout=log, stderr=log) |
| 136 | + processes.append(process) |
| 137 | + for _ in range(100): |
| 138 | + assert process.poll() is None, f"{name} stopped" |
| 139 | + try: |
| 140 | + with socket.create_connection(("127.0.0.1", ready), timeout=.1): |
| 141 | + return process |
| 142 | + except OSError: |
| 143 | + time.sleep(.05) |
| 144 | + raise AssertionError(f"{name} did not start") |
| 145 | + |
| 146 | + exit_process = start("exit", configs["exit"], exit_port) |
| 147 | + entry_process = start("entry", configs["entry"], chain_port) |
| 148 | + context = ssl.create_default_context(cafile=str(root / "fullchain.pem")) |
| 149 | + |
| 150 | + def check(proxy_port, *, hostname="localhost", target="127.0.0.1", password=PASSWORD, accepted=True): |
| 151 | + with context.wrap_socket(socket.create_connection(("127.0.0.1", proxy_port), timeout=5), server_hostname=hostname) as sock: |
| 152 | + status = connect(sock, target, echo.server_address[1], password=password) |
| 153 | + if accepted is True: |
| 154 | + assert status == 200, status |
| 155 | + sock.sendall(b"HTTPS transport check") |
| 156 | + assert receive(sock, 21) == b"HTTPS transport check" |
| 157 | + elif accepted is False: |
| 158 | + assert status != 200 |
| 159 | + return status |
| 160 | + |
| 161 | + assert check(entry_port, target="localhost", password=None, accepted=False) == 407 |
| 162 | + assert check(chain_port, hostname="chain.localhost", target="chain.localhost", password=None, accepted=False) == 407 |
| 163 | + assert check(entry_port, password=None, accepted=None) != 407 |
| 164 | + check(entry_port) |
| 165 | + check(chain_port, hostname="chain.localhost") |
| 166 | + with context.wrap_socket(socket.create_connection(("127.0.0.1", entry_port), timeout=5), server_hostname="localhost") as outer: |
| 167 | + assert connect(outer, "localhost", exit_port) == 200 |
| 168 | + inner = NestedTLS(outer, context) |
| 169 | + assert connect(inner, "127.0.0.1", echo.server_address[1]) == 200 |
| 170 | + inner.sendall(b"HTTPS Jump check") |
| 171 | + assert receive(inner, 16) == b"HTTPS Jump check" |
| 172 | + for mutation in ("password", "certificate"): |
| 173 | + entry_process.terminate() |
| 174 | + entry_process.wait(timeout=5) |
| 175 | + bad = deepcopy(configs["entry"]) |
| 176 | + node = bad["chains"][0]["hops"][0]["nodes"][0] |
| 177 | + if mutation == "password": |
| 178 | + node["connector"]["auth"]["password"] = "incorrect-machine-password" |
| 179 | + else: |
| 180 | + node["dialer"]["tls"]["serverName"] = "wrong.example" |
| 181 | + entry_process = start(f"entry-bad-{mutation}", bad, chain_port) |
| 182 | + check(chain_port, hostname="chain.localhost", accepted=False) |
| 183 | + check(entry_port) # Direct route still works, without bypassing a broken chain. |
| 184 | + entry_process.terminate() |
| 185 | + entry_process.wait(timeout=5) |
| 186 | + start("entry-restored", configs["entry"], chain_port) |
| 187 | + exit_process.terminate() |
| 188 | + exit_process.wait(timeout=5) |
| 189 | + check(chain_port, hostname="chain.localhost", accepted=False) |
| 190 | + check(entry_port) |
| 191 | + print("HTTPS passed: masking, knock and override, trusted server chain, nested TLS Jump, rejected machine auth/TLS and unavailable exit without bypass.") |
| 192 | + except Exception: |
| 193 | + for log in logs: |
| 194 | + log.seek(0) |
| 195 | + print(Path(log.name).name, log.read()[-4000:]) |
| 196 | + raise |
| 197 | + finally: |
| 198 | + for process in processes: |
| 199 | + if process.poll() is None: |
| 200 | + process.terminate() |
| 201 | + process.wait(timeout=5) |
| 202 | + for log in logs: |
| 203 | + log.close() |
| 204 | + echo.shutdown() |
| 205 | + echo.server_close() |
| 206 | + |
| 207 | + |
| 208 | +if __name__ == "__main__": |
| 209 | + main() |
0 commit comments