├── README.md ├── certs ├── ca.crt ├── ca.key └── cert.key ├── setup_https_intercept.sh └── websocket_http_proxy.py /README.md: -------------------------------------------------------------------------------- 1 | I needed to be able to intercept secure websocket traffic in python while also properly proxying normal HTTP and HTTPS traffic. 2 | I could not find any such python tool to do that, so I made one myself. 3 | -------------------------------------------------------------------------------- /certs/ca.crt: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDHzCCAgegAwIBAgIUK/LlbgZ96scOJfikW712tNI5i2YwDQYJKoZIhvcNAQEL 3 | BQAwHzEdMBsGA1UEAwwUQ3VzdG9tIFdTLUhUVFAgcHJveHkwHhcNMjAxMjE3MTMy 4 | ODI0WhcNMzAxMjE1MTMyODI0WjAfMR0wGwYDVQQDDBRDdXN0b20gV1MtSFRUUCBw 5 | cm94eTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANPwVJ4DI90EWM3E 6 | KTVdtJ2r+/ctIM/PRz4Bt1JIhxcf1tDkW8Ki0FAIsdOe6IFZpUVeKMaA19Po2EM2 7 | ALuTfUihIec41JQyWntycFPHgCpjTf/fZl8BSOIgoeXtPhyR8nFrt8iKFvu0ErT3 8 | HxU6JsBnxUaML1Xn8y7L+sLZxCsanCdyjw3dJcx8gjQxS3tD6lJukbwtasIOhm42 9 | tQ55fz3sccSSsajrIPEZ+eVquuyI+VEtLGrppMyeKyueS8txw+JGTZ3YO9CGlQZg 10 | Yir+dB/gAqxsryfZdhvotr7w71QGv3mSMYT1GvQhZDno8tK2HR1lGJjimog2zHtw 11 | VZT9N90CAwEAAaNTMFEwHQYDVR0OBBYEFMpJV6LrLFBYQH5r+G3cDbq82sjEMB8G 12 | A1UdIwQYMBaAFMpJV6LrLFBYQH5r+G3cDbq82sjEMA8GA1UdEwEB/wQFMAMBAf8w 13 | DQYJKoZIhvcNAQELBQADggEBAEIei65TCp8mbIJiPOTGhUgkrv3c0CIR2tzdRrOT 14 | x2r0KgP4hVjnWi/2D+Q0lgowRVwlMAgxcjphhLaRVj0KDHw5jI/RfZjt3uEpb9As 15 | bWZthaMYU3aPhQY+JWDuH732utDmX8xlj1pANazardrPuBCykizYXyIylVV4weYT 16 | ase0OB/ibsnScAC7d9q6SPte1+62vfGmP5F3qQWhpRDKCstpdu8TvDMFABM5LBCb 17 | 4vXgvw8qUb/2OS0/CTAf6lSjWVdmKKe39rhjNpnlqFiL9Lce7VpfMFTJLMn3kjG4 18 | gjypg+s7uEk4YoT/BpSlAvVf6qIj2KxYWtU72G4Dt8cTB+g= 19 | -----END CERTIFICATE----- 20 | -------------------------------------------------------------------------------- /certs/ca.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpAIBAAKCAQEA0/BUngMj3QRYzcQpNV20nav79y0gz89HPgG3UkiHFx/W0ORb 3 | wqLQUAix057ogVmlRV4oxoDX0+jYQzYAu5N9SKEh5zjUlDJae3JwU8eAKmNN/99m 4 | XwFI4iCh5e0+HJHycWu3yIoW+7QStPcfFTomwGfFRowvVefzLsv6wtnEKxqcJ3KP 5 | Dd0lzHyCNDFLe0PqUm6RvC1qwg6Gbja1Dnl/PexxxJKxqOsg8Rn55Wq67Ij5US0s 6 | aumkzJ4rK55Ly3HD4kZNndg70IaVBmBiKv50H+ACrGyvJ9l2G+i2vvDvVAa/eZIx 7 | hPUa9CFkOejy0rYdHWUYmOKaiDbMe3BVlP033QIDAQABAoIBAA3F6YnXRGZhmO/O 8 | Vqs8KrewbJB0o1Q98TBLZkF3qyfKjuhGXtw4PndlCUFqa0u0qrPmWZoE14HS+PLt 9 | OID4JcUpi41+OPpkh1LMbhZTubWHfJMrTnjQGY9wdXT+xPGQXoQWbvweVT4IRsrx 10 | 4Fg9zjTkyYI7K/xWNYyN/v7YrhHq63d26VQqwNHFbAdCLuLThE6PHFRwS7PT8Puj 11 | PijMePKqLk2CObpM24j/I/KGJRRoRAyjaWYM1XaEJZAREHi7L7QU8VMgLVtILeY2 12 | HBJvnWpsk2yUGWwPV3yYclejLdZ5jS+C0/Fw0yqqzI5GWFqk+Yt6CiZBkiaiR9HG 13 | ZM6ftsECgYEA9cB04QSPEDp/UyObgJU7Qp2RHnwFtMp3Qo7HaUIcbVcQj/9JJGAq 14 | ZR/b/Cn0bFZwMm1LM2OJIDOn8vW/1GDbMBLy2/WSgWeYlHowwhKuHqtvnodGmHA5 15 | u0TuXv9GOR7WYGdlmQe6hEef32f0PFz4s+A5/+xDVqpg5YJ5utBTCu0CgYEA3Mbm 16 | jPjj/pPWfIDLR66mbj0a08BEjd4gYgMgFf/HLQCT0HprzJs++Go8jUpVVN1N0yhw 17 | L6Ok1HXDEBsF7NHQb22r9Wjyz2uK0CMd4GCz4a85pFTIcL/feLmHa4To9IAex7cc 18 | GUjUIMXA2/jpb0rsWBS9miERciMsnGAJJJhGErECgYAyz13bAERMCKw2llAaX8Bm 19 | 34kXknDjllDeFAMqwh56hNvJyfBncvKRAetL0ajVlXGRG4PG1jeNzuBUnXbSBEyN 20 | Pf20eKuX0cF8QV8/YPlbIfrr/fAcqGdnTrMyNPlh6fxM5lPdNfncS6rEWAvpRxes 21 | qRmUzlaF3qg2C6n7dAMXaQKBgQCkrWgC9xN5LOTr8VWvnkJeMA32rI92Ep2s/g/M 22 | 2QDEPI4FD4uhDpulx/hqm2uS5Y/LVXp4zmOAZmadeMqunsIOm4uMfj+/H8RnBAqg 23 | 9wC18nHYdbUdBMG31wt/05+/4GEVLywyX/R6jYcRjVTxwr0P5kiW0tuke0AnVCr9 24 | tOdvwQKBgQDjXd8a43D16HbIthlSyJW+43dg/pTGiu9tHqJZooMFVxnyMKuXU8v3 25 | NjgOed11tYEm5VW7G8wQiztzHfDp/FKY0lnCJw+T4N+CiBDeBTej7Jg+uaMG1vyU 26 | BB1M8fkFdPF5DxsiGuuBsBMIgCYDcxX2TXeKhw2Hf9dNvrQcAIJ4BQ== 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /certs/cert.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEpQIBAAKCAQEA0UPVlP/9/RbOIyWJ+ZdnKhjudPK9uJ8OVj+YoosxV88isgXU 3 | TW2aOjEAgcFWZ9YpsfbdDbdH8cSiUQTlg/BB/WqTF33zKQ+j9zO2EEjxtsgfpEvg 4 | Gb5USbCegyHGkmjZNFlR9EMWP6HEC1FfXtThwiWH4LQDuEeFHws6OewwzmCC5bk1 5 | aI/DdIOkQ5z3juIzxQzkyUotGPcghJq1vGh9JL0ppBX8t97rjRhPJP5Ta/WHnSGQ 6 | O3MNtI9E6YK8mqb9UkGkddm8zQV/yVZDvP84KAyg2Xtal0ybhvPRvOgv+wMZ04TG 7 | /mf2fT5GTlUv/OuXgyjU2Y3O3RlbUydpK6q45QIDAQABAoIBAQDPqXwqhzp5zAa1 8 | tAvOhiNXEDSaE0SYECb/Cc8jBfPqSmAIv2Yli+0vb+8r1Ds1gb4Qn4RPlyCq19Gn 9 | iq9kFai2nOrothD2H1I7/rHeSTSsiL11oeH6SfiEw+MZCAxwv+FDZJvCREywyous 10 | G40Do0eBDRNgteK3HSoKW27lifAI6strQc42Teb8miw0ClzDW+99qEcH3g62HzqO 11 | cF1hwnWfNgr4BIPG5iGt2B0Ohd9lJQKTVNgMrWU4+0/dUUZKRRwT0EgP37hcYzqq 12 | 4R+zffu3om2S8wvABFGB4m7Ruvx63v9AINlqcAoYPDODgETwoSEzLSugvKQMXXmR 13 | ryTOB+XBAoGBAPAnJc6aPOTpeyb7DjEc8Fb2GiFNBmw1Io5SxCjNwA37mZXkvLn4 14 | DzUCeDHi/8DmB5qtaA0py4hAkCqEJ/BMWpaU35jEiy6NxNCJZceeRhjHmellScMj 15 | ul8cXqAuFZBeSkJS+pyyRG2bCT7ixDCtIrF1QuNCBgq4Q5hH3ImqX6uVAoGBAN8S 16 | 5zVZbPta6ikfpCJxQ9Vg0HWp0dFV1b3OhUMG5ghUOTrxfuql+0Es9gR42gAIOYMk 17 | VxAE+kR9fCM/C6/gHV5ZCU4mNY4V1AaLq+dS6JEEOhdtpCXxXltc8Y+evfj8xlfN 18 | gK8y8cX3eVpiOe986yhFLcJVIo9LZrbJJLRRmgQRAoGBAOgP3D/N2WQWnjOnzCn3 19 | XeOagtuFE9zCZ7cCEZ2gXKLmap5m31wRcZh233DNeviLD/QO9wopRg1O3kDHXdSd 20 | 47e4+mwkGJ6Ozg35h0mjDvdpAbiAcQvJXZIE1weQILRV+QooJxX+SZNkikWjWZPz 21 | 6h9zQYRbS31WW7MuVdUNts8VAoGAQuGHAp7CJwZWCGhdLJpq7RGuzmhQ6QNkJxlB 22 | KRxrYXnnAr4fADktgJf7VtHpAnN00tXVaI8lfd1ll6eyWFPIWl41hQG9stDmlePQ 23 | cXWRFtF+nUGZImsgkCHoptfAO2OGEBMkDuMmS+Vrs+aZWi1Iz/UVyBsAVpgTvp/F 24 | 6m5A0BECgYEAwkqPSPQZ7Eguj7VLY+6BwEKD0nlKGBxiDprx+9LStryW2a7JlRKV 25 | hPPIAiYao7lNf2CYvX+OqobAIJXIBo+YskNLkN4EeiXQUG+/0k7KVDRsr3cn5xhK 26 | 399Juf+AkdI3NRHdNw8etwC1IYttQz3Kpx0MJqe+oHkyGQJcjdyEytU= 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /setup_https_intercept.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Save original dir 4 | pushd . >/dev/null 5 | # Go to script location 6 | cd "${0%/*}" 7 | 8 | mkdir certs 9 | cd certs 10 | openssl genrsa -out ca.key 2048 11 | openssl req -new -x509 -days 3650 -key ca.key -out ca.crt -subj "/CN=Custom WS-HTTP proxy" 12 | openssl genrsa -out cert.key 2048 13 | mkdir specific_certs 14 | 15 | # Back to orginal dir 16 | popd 17 | -------------------------------------------------------------------------------- /websocket_http_proxy.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | 3 | 4 | import struct 5 | import pathlib 6 | import sys 7 | import threading 8 | import socket 9 | import errno 10 | import ssl 11 | import time 12 | import select 13 | import json 14 | import re 15 | import gzip 16 | import brotli 17 | import http.client 18 | import html 19 | import queue 20 | from urllib.parse import urlparse, urlsplit, parse_qsl 21 | from base64 import b64encode 22 | from hashlib import sha1 23 | from io import StringIO, BytesIO 24 | from http.server import BaseHTTPRequestHandler 25 | from http.server import HTTPServer 26 | from socketserver import ThreadingMixIn 27 | from subprocess import Popen, PIPE 28 | 29 | # This is the websocket client we use to connect to the remote 30 | import websocket 31 | 32 | ########## 33 | # Config # 34 | ########## 35 | # Our local port we want to listen on 36 | proxy_port = 9999 37 | # Do we have SSL certs? 38 | secure = True 39 | 40 | # Nr of characters after which messages are truncated (0 to turn off message printing) 41 | trunc = 125 42 | #trunc = 0 43 | 44 | ################## 45 | # Implementation # 46 | ################## 47 | 48 | off='\033[0m' 49 | red='\033[0;91m' 50 | grn='\033[0;32m' 51 | yel='\033[0;33m' 52 | cya='\033[0;36m' 53 | 54 | class WebSocketError(Exception): 55 | pass 56 | 57 | class ThreadedHTTPServer(ThreadingMixIn, HTTPServer): 58 | # Handle requests in a separate thread 59 | daemon_threads = True 60 | 61 | class HttpProxy(BaseHTTPRequestHandler): 62 | cakey = None 63 | cacert = None 64 | certkey = None 65 | certdir = None 66 | 67 | timeout = 100 68 | lock = threading.Lock() 69 | protocol_version = "HTTP/1.1" 70 | 71 | def __init__(self, *args, **kwargs): 72 | if secure: 73 | self.cakey = pathlib.Path(__file__).parent.joinpath("certs/ca.key") 74 | self.cacert = pathlib.Path(__file__).parent.joinpath("certs/ca.crt") 75 | self.certkey = pathlib.Path(__file__).parent.joinpath("certs/cert.key") 76 | self.certdir = pathlib.Path(__file__).parent.joinpath("certs/specific_certs/") 77 | self.extensionfile = pathlib.Path(__file__).parent.joinpath("certs/extfile.tmp") 78 | self.tls = threading.local() 79 | self.tls.conns = {} 80 | 81 | BaseHTTPRequestHandler.__init__(self, *args, **kwargs) 82 | 83 | def do_CONNECT(self): 84 | if secure: 85 | self.connect_intercept() 86 | else: 87 | self.connect_relay() 88 | 89 | def connect_intercept(self): 90 | hostname = self.path.split(':')[0] 91 | specific_cert = self.certdir.joinpath(f"{hostname}.crt") 92 | 93 | with self.lock: 94 | if not specific_cert.is_file(): 95 | epoch = "%d" % (time.time() * 1000) 96 | self.extensionfile.write_text("subjectAltName=DNS:%s" % hostname) 97 | p1 = Popen(["openssl", "req", "-new", "-key", self.certkey, "-subj", "/CN=%s" % hostname, "-addext", "subjectAltName = DNS:%s" % hostname], stdout=PIPE) 98 | p2 = Popen(["openssl", "x509", "-req", "-extfile", self.extensionfile, "-days", "365", "-CA", self.cacert, "-CAkey", self.cakey, "-set_serial", epoch, "-out", specific_cert], stdin=p1.stdout, stderr=PIPE) 99 | p2.communicate() 100 | 101 | self.wfile.write(("%s %d %s\r\n" % (self.protocol_version, 200, 'Connection Established')).encode()) 102 | if not hasattr(self, '_headers_buffer'): 103 | self._headers_buffer = [] 104 | self.end_headers() 105 | 106 | ssl_settings = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) 107 | ssl_settings.check_hostname = False 108 | ssl_settings.load_cert_chain(certfile=specific_cert, keyfile=self.certkey) 109 | self.connection = ssl_settings.wrap_socket(self.connection, server_side=True) 110 | self.rfile = self.connection.makefile("rb", self.rbufsize) 111 | self.wfile = self.connection.makefile("wb", self.wbufsize) 112 | 113 | conntype = self.headers.get('Proxy-Connection', '') 114 | if self.protocol_version == "HTTP/1.1" and conntype.lower() != 'close': 115 | self.close_connection = 0 116 | else: 117 | self.close_connection = 1 118 | 119 | def connect_relay(self): 120 | address = self.path.split(':', 1) 121 | address[1] = int(address[1]) or 443 122 | try: 123 | s = socket.create_connection(address, timeout=self.timeout) 124 | except Exception as e: 125 | self.send_error(502) 126 | return 127 | self.send_response(200, 'Connection Established') 128 | self.end_headers() 129 | 130 | conns = [self.connection, s] 131 | self.close_connection = 0 132 | while not self.close_connection: 133 | rlist, wlist, xlist = select.select(conns, [], conns, self.timeout) 134 | if xlist or not rlist: 135 | break 136 | for r in rlist: 137 | other = conns[1] if r is conns[0] else conns[0] 138 | data = r.recv(8192) 139 | if not data: 140 | self.close_connection = 1 141 | break 142 | other.sendall(data) 143 | 144 | def do_GET(self): 145 | if self.path == 'http://ca.crt/': 146 | self.send_cacert() 147 | return 148 | 149 | req = self 150 | content_length = int(req.headers.get('Content-Length', 0)) 151 | req_body = self.rfile.read(content_length) if content_length else None 152 | 153 | if req.path[0] == '/': 154 | if isinstance(self.connection, ssl.SSLSocket): 155 | req.path = f"https://{req.headers['Host']}{req.path}" 156 | else: 157 | req.path = f"http://{req.headers['Host']}{req.path}" 158 | 159 | #print(f"REQ for {req.path}") 160 | req_body_modified = self.request_handler(req, req_body) 161 | if req_body_modified is False: 162 | self.send_error(403) 163 | return 164 | elif req_body_modified is not None: 165 | req_body = req_body_modified 166 | req.headers['Content-length'] = str(len(req_body)) 167 | 168 | u = urlsplit(req.path) 169 | scheme, netloc, path = u.scheme, u.netloc, (u.path + '?' + u.query if u.query else u.path) 170 | assert scheme in ('http', 'https') 171 | if netloc: 172 | req.headers['Host'] = netloc 173 | setattr(req, 'headers', self.filter_headers(req.headers)) 174 | 175 | try: 176 | origin = (scheme, netloc) 177 | if not origin in self.tls.conns: 178 | if scheme == 'https': 179 | self.tls.conns[origin] = http.client.HTTPSConnection(netloc, timeout=self.timeout) 180 | else: 181 | self.tls.conns[origin] = http.client.HTTPConnection(netloc, timeout=self.timeout) 182 | conn = self.tls.conns[origin] 183 | conn.request(self.command, path, req_body, dict(req.headers)) 184 | res = conn.getresponse() 185 | 186 | version_table = {10: 'HTTP/1.0', 11: 'HTTP/1.1'} 187 | setattr(res, 'headers', res.msg) 188 | setattr(res, 'response_version', version_table[res.version]) 189 | 190 | # support streaming 191 | if not 'Content-Length' in res.headers and 'no-store' in res.headers.get('Cache-Control', ''): 192 | self.response_handler(req, req_body, res, '') 193 | setattr(res, 'headers', self.filter_headers(res.headers)) 194 | self.relay_streaming(res) 195 | with self.lock: 196 | self.save_handler(req, req_body, res, '') 197 | return 198 | 199 | res_body = res.read() 200 | except Exception as e: 201 | print(e) 202 | if origin in self.tls.conns: 203 | del self.tls.conns[origin] 204 | self.send_error(502) 205 | return 206 | 207 | content_encoding = res.headers.get('Content-Encoding', 'identity') 208 | res_body_plain = self.decode_content_body(res_body, content_encoding) 209 | 210 | res_body_modified = self.response_handler(req, req_body, res, res_body_plain) 211 | if res_body_modified is False: 212 | self.send_error(403) 213 | return 214 | elif res_body_modified is not None: 215 | res_body_plain = res_body_modified 216 | res_body = self.encode_content_body(res_body_plain, content_encoding) 217 | res.headers['Content-Length'] = str(len(res_body)) 218 | 219 | setattr(res, 'headers', self.filter_headers(res.headers)) 220 | 221 | self.wfile.write(("%s %d %s\r\n" % (self.protocol_version, res.status, res.reason)).encode()) 222 | self.wfile.write(str(res.headers).encode()) 223 | if len(res_body) != 0: 224 | self.wfile.write(res_body) 225 | self.wfile.flush() 226 | 227 | with self.lock: 228 | self.save_handler(req, req_body, res, res_body_plain) 229 | 230 | do_HEAD = do_GET 231 | do_POST = do_GET 232 | do_PUT = do_GET 233 | do_DELETE = do_GET 234 | do_OPTIONS = do_GET 235 | 236 | def relay_streaming(self, res): 237 | self.wfile.write(("%s %d %s\r\n" % (self.protocol_version, res.status, res.reason)).encode()) 238 | self.wfile.write(str(res.headers).encode()) 239 | self.end_headers() 240 | try: 241 | while True: 242 | chunk = res.read(8192) 243 | if not chunk: 244 | break 245 | self.wfile.write(chunk) 246 | self.wfile.flush() 247 | except socket.error: 248 | # connection closed by client 249 | pass 250 | 251 | def filter_headers(self, headers): 252 | # http://tools.ietf.org/html/rfc2616#section-13.5.1 253 | hop_by_hop = ('connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailers', 'transfer-encoding', 'upgrade') 254 | #hop_by_hop = ('keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailers', 'transfer-encoding') 255 | for k in hop_by_hop: 256 | del headers[k] 257 | 258 | # accept only supported encodings 259 | if 'Accept-Encoding' in headers: 260 | ae = headers['Accept-Encoding'] 261 | filtered_encodings = [x for x in re.split(r',\s*', ae) if x in ('identity', 'gzip', 'x-gzip', 'deflate', 'br')] 262 | headers['Accept-Encoding'] = ', '.join(filtered_encodings) 263 | 264 | return headers 265 | 266 | def encode_content_body(self, text, encoding): 267 | if encoding == 'identity': 268 | data = text 269 | elif encoding in ('gzip', 'x-gzip'): 270 | io = BytesIO() 271 | with gzip.GzipFile(fileobj=io, mode='wb') as f: 272 | f.write(text) 273 | data = io.getvalue() 274 | elif encoding == 'deflate': 275 | data = zlib.compress(text) 276 | elif encoding == 'br': 277 | data = brotli.compress(text) 278 | else: 279 | raise Exception("Unknown Content-Encoding: %s" % encoding) 280 | return data 281 | 282 | def decode_content_body(self, data, encoding): 283 | if encoding == 'identity': 284 | text = data 285 | elif encoding in ('gzip', 'x-gzip'): 286 | io = BytesIO(data) 287 | with gzip.GzipFile(fileobj=io) as f: 288 | text = f.read() 289 | elif encoding == 'deflate': 290 | try: 291 | text = zlib.decompress(data) 292 | except zlib.error: 293 | text = zlib.decompress(data, -zlib.MAX_WBITS) 294 | elif encoding == 'br': 295 | text = brotli.compress(data) 296 | else: 297 | raise Exception("Unknown Content-Encoding: %s" % encoding) 298 | return text 299 | 300 | def send_cacert(self): 301 | with open(self.cacert, 'rb') as f: 302 | data = f.read() 303 | 304 | self.wfile.write(("%s %d %s\r\n" % (self.protocol_version, 200, 'OK')).encode()) 305 | self.send_header('Content-Type', 'application/x-x509-ca-cert') 306 | self.send_header('Content-Length', len(data)) 307 | self.send_header('Connection', 'close') 308 | self.end_headers() 309 | self.wfile.write(data) 310 | 311 | def log_error(self, *args, **kwargs): 312 | # Hacky way to suppress timeout errors 313 | #if 'Request timed out' in args[0]: 314 | # return 315 | BaseHTTPRequestHandler.log_error(self, *args, **kwargs) 316 | 317 | 318 | def print_info(self, req, req_body, res, res_body): 319 | def _parse_qsl(s): 320 | return '\n'.join("%-20s %s" % (k, v) for k, v in parse_qsl(s, keep_blank_values=True)) 321 | 322 | req_header_text = "%s %s %s\n%s" % (req.command, req.path, req.request_version, req.headers) 323 | res_header_text = "%s %d %s\n%s" % (res.response_version, res.status, res.reason, res.headers) 324 | 325 | print(f"{yel}{req_header_text}{off}") 326 | 327 | u = urlsplit(req.path) 328 | if u.query: 329 | query_text = _parse_qsl(u.query) 330 | print(f"{grn}==== QUERY PARAMETERS ====\n{query_text}\n{off}") 331 | 332 | cookie = req.headers.get('Cookie', '') 333 | if cookie: 334 | cookie = _parse_qsl(re.sub(r';\s*', '&', cookie)) 335 | print(f"{grn}==== COOKIE ====\n{cookie}\n{off}") 336 | 337 | auth = req.headers.get('Authorization', '') 338 | if auth.lower().startswith('basic'): 339 | token = auth.split()[1].decode('base64') 340 | print(f"{red}==== BASIC AUTH ====\n{token}\n{off}") 341 | 342 | if req_body is not None: 343 | req_body_text = None 344 | content_type = req.headers.get('Content-Type', '') 345 | 346 | if content_type.startswith('application/x-www-form-urlencoded'): 347 | req_body_text = _parse_qsl(req_body) 348 | elif content_type.startswith('application/json'): 349 | try: 350 | json_obj = json.loads(req_body) 351 | json_str = json.dumps(json_obj, indent=2) 352 | if json_str.count('\n') < 50: 353 | req_body_text = json_str 354 | else: 355 | lines = json_str.splitlines() 356 | req_body_text = "%s\n(%d lines)" % ('\n'.join(lines[:50]), len(lines)) 357 | except ValueError: 358 | req_body_text = req_body 359 | elif len(req_body) < 1024: 360 | req_body_text = req_body 361 | 362 | if req_body_text: 363 | print(f"{grn}==== REQUEST BODY ====\n{req_body_text}\n{off}") 364 | 365 | print(f"{cya}{res_header_text}{off}") 366 | 367 | cookies = res.headers.get_all('Set-Cookie') 368 | if cookies: 369 | cookies = '\n'.join(cookies) 370 | print(f"{red}==== SET-COOKIE ====\n{cookies}\n{off}") 371 | 372 | if res_body is not None: 373 | res_body_text = None 374 | content_type = res.headers.get('Content-Type', '') 375 | 376 | if content_type.startswith('application/json'): 377 | try: 378 | json_obj = json.loads(res_body) 379 | json_str = json.dumps(json_obj, indent=2) 380 | if json_str.count('\n') < 50: 381 | res_body_text = json_str 382 | else: 383 | lines = json_str.splitlines() 384 | res_body_text = "%s\n(%d lines)" % ('\n'.join(lines[:50]), len(lines)) 385 | except ValueError: 386 | res_body_text = res_body 387 | elif content_type.startswith('text/html'): 388 | m = re.search(r']*>\s*([^<]+?)\s*', res_body.decode(), re.I) 389 | if m: 390 | print(f"{grn}==== HTML TITLE ====\n{html.unescape(m.group(1))}\n{off}") 391 | elif content_type.startswith('text/') and len(res_body) < 1024: 392 | res_body_text = res_body 393 | 394 | if res_body_text: 395 | print(f"{grn}==== RESPONSE BODY ====\n{res_body_text}\n{off}") 396 | 397 | def request_handler(self, req, req_body): 398 | """Override this handler to process incoming HTTP requests. (Return the modified body)""" 399 | pass 400 | 401 | def response_handler(self, req, req_body, res, res_body): 402 | """Override this handler to process outgoing HTTP responses. (Return the modified body)""" 403 | pass 404 | 405 | def save_handler(self, req, req_body, res, res_body): 406 | """Override this handler to log full HTTP REQ/RES pairs. Default action: print to console.""" 407 | self.print_info(req, req_body, res, res_body) 408 | pass 409 | 410 | 411 | 412 | class WsHttpProxy(HttpProxy): 413 | _ws_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11' 414 | _opcode_continu = 0x0 415 | _opcode_text = 0x1 416 | _opcode_binary = 0x2 417 | _opcode_close = 0x8 418 | _opcode_ping = 0x9 419 | _opcode_pong = 0xa 420 | 421 | mutex = threading.Lock() 422 | 423 | 424 | def do_GET(self): 425 | if self.headers.get("Upgrade", None) == "websocket": 426 | print("Initiating websocket handshake") 427 | self._handshake() 428 | #This handler is in websocket mode now. 429 | #do_GET only returns after client close or socket error. 430 | self._read_messages() 431 | else: 432 | HttpProxy.do_GET(self) 433 | 434 | def send_message(self, message): 435 | self._send_message(self._opcode_text, message) 436 | 437 | def _read_messages(self): 438 | while self.connected == True: 439 | try: 440 | self._read_next_message() 441 | except (socket.error, WebSocketError) as e: 442 | #websocket content error, time-out or disconnect. 443 | self.log_message("RCV: Close connection: Socket Error %s" % str(e.args)) 444 | self._ws_close() 445 | except Exception as err: 446 | #unexpected error in websocket connection. 447 | self.log_error("RCV: Exception: in _read_messages: %s" % str(err.args)) 448 | self._ws_close() 449 | 450 | def _read_next_message(self): 451 | try: 452 | self.opcode = ord(self.rfile.read(1)) & 0x0F 453 | length = ord(self.rfile.read(1)) & 0x7F 454 | if length == 126: 455 | length = struct.unpack(">H", self.rfile.read(2))[0] 456 | elif length == 127: 457 | length = struct.unpack(">Q", self.rfile.read(8))[0] 458 | masks = [byte for byte in self.rfile.read(4)] 459 | decoded = "" 460 | for char in self.rfile.read(length): 461 | decoded += chr(char ^ masks[len(decoded) % 4]) 462 | self._on_message(decoded) 463 | except (struct.error, TypeError) as e: 464 | #catch exceptions from ord() and struct.unpack() 465 | print(f"debug, {e}") 466 | if self.connected: 467 | raise WebSocketError("Websocket read aborted while listening") 468 | else: 469 | #the socket was closed while waiting for input 470 | self.log_error("RCV: _read_next_message aborted after closed connection") 471 | pass 472 | 473 | def _send_message(self, opcode, message): 474 | try: 475 | self.connection.send(bytes([0x80 + opcode])) 476 | length = len(message.encode()) 477 | if length <= 125: 478 | self.connection.send(chr(length).encode()) 479 | elif length >= 126 and length <= 65535: 480 | self.connection.send(chr(126).encode()) 481 | self.connection.send(struct.pack(">H", length)) 482 | else: 483 | self.connection.send(chr(127).encode()) 484 | self.connection.send(struct.pack(">Q", length)) 485 | if length > 0: 486 | self.connection.send(message.encode()) 487 | except socket.error as e: 488 | #websocket content error, time-out or disconnect. 489 | self.log_message("SND: Close connection: Socket Error %s" % str(e.args)) 490 | self._ws_close() 491 | except Exception as err: 492 | #unexpected error in websocket connection. 493 | self.log_error("SND: Exception: in _send_message: %s" % str(err.args)) 494 | self._ws_close() 495 | 496 | def _handshake(self): 497 | headers=self.headers 498 | if headers.get("Upgrade", None) != "websocket": 499 | return 500 | key = headers['Sec-WebSocket-Key'] 501 | protocol = headers.get('Sec-WebSocket-Protocol') 502 | digest = b64encode(sha1((key + self._ws_GUID).encode('utf-8')).digest()).strip().decode() 503 | 504 | self.send_response(101, 'Switching Protocols') 505 | self.send_header('Connection', 'Upgrade') 506 | self.send_header('Upgrade', 'websocket') 507 | self.send_header('Sec-WebSocket-Accept', digest) 508 | if protocol: 509 | self.send_header('Sec-WebSocket-Protocol', protocol) 510 | self.end_headers() 511 | self.connected = True 512 | #self.close_connection = 0 # INTERESTING, DO WE NEED TO UNCOMMENT THIS? 513 | self.on_ws_connected() 514 | 515 | def _ws_close(self): 516 | #avoid closing a single socket two time for send and receive. 517 | self.mutex.acquire() 518 | try: 519 | if self.connected: 520 | self.connected = False 521 | #Terminate BaseHTTPRequestHandler.handle() loop: 522 | self.close_connection = 1 523 | #send close and ignore exceptions. An error may already have occurred. 524 | try: 525 | self._send_close() 526 | except: 527 | pass 528 | self.on_ws_closed() 529 | else: 530 | self.log_message("_ws_close websocket in closed state. Ignore.") 531 | pass 532 | finally: 533 | self.mutex.release() 534 | 535 | def _on_message(self, message): 536 | #self.log_message("_on_message: opcode: %02X msg: %s" % (self.opcode, message)) 537 | # close 538 | if self.opcode == self._opcode_close: 539 | self.connected = False 540 | #Terminate BaseHTTPRequestHandler.handle() loop: 541 | self.close_connection = 1 542 | try: 543 | self._send_close() 544 | except: 545 | pass 546 | self.on_ws_closed() 547 | # ping 548 | elif self.opcode == self._opcode_ping: 549 | self._send_message(self._opcode_pong, message) 550 | # pong 551 | elif self.opcode == self._opcode_pong: 552 | pass 553 | # data 554 | elif (self.opcode == self._opcode_continu or self.opcode == self._opcode_text or self.opcode == self._opcode_binary): 555 | self.on_ws_message(message) 556 | 557 | def _send_close(self): 558 | #Dedicated _send_close allows for catch all exception handling 559 | msg = bytearray() 560 | msg.append(0x80 + self._opcode_close) 561 | msg.append(0x00) 562 | self.connection.send(msg) 563 | 564 | def request_handler(self, req, req_body): 565 | """Override this handler to process incoming HTTP requests. (Return the modified body)""" 566 | pass 567 | 568 | def response_handler(self, req, req_body, res, res_body): 569 | """Override this handler to process outgoing HTTP responses. (Return the modified body)""" 570 | pass 571 | 572 | def save_handler(self, req, req_body, res, res_body): 573 | """Override this handler to log full HTTP REQ/RES pairs. Default action: print to console.""" 574 | #self.print_info(req, req_body, res, res_body) 575 | pass 576 | 577 | def on_ws_message(self, message): 578 | """Override this handler to process incoming websocket messages.""" 579 | pass 580 | 581 | def on_ws_connected(self): 582 | """Override this handler.""" 583 | pass 584 | 585 | def on_ws_closed(self): 586 | """Override this handler.""" 587 | pass 588 | 589 | 590 | class WSProxy(WsHttpProxy): 591 | _closed = False 592 | 593 | # These 2 variables allow us to clone the recv functionality 594 | clone_recv = False 595 | recv_queue = queue.Queue() 596 | 597 | def on_ws_message(self, message): 598 | if message is None: 599 | message = '' 600 | if trunc != 0: 601 | print(f"{red}CLIENT: '{message[:trunc] + (message[trunc:] and '..')}'{off}") 602 | # Send client message to remote 603 | self._remote_websocket.send(str(message)) 604 | 605 | def on_ws_connected(self): 606 | global first_ws_connection 607 | self.log_message('%s','websocket connected') 608 | 609 | if first_ws_connection is None: 610 | first_ws_connection = self 611 | 612 | # Called whenever a new connection is made to the server 613 | if secure: 614 | remote_url = "wss://" + self.headers['Host'] + self.path 615 | else: 616 | remote_url = "ws://" + self.headers['Host'] + self.path 617 | self.log_message('%s',f"Connecting to remote websocket {remote_url}") 618 | self._remote_websocket = websocket.WebSocket(sslopt={"cert_reqs": ssl.CERT_NONE}) 619 | self._remote_websocket.connect(remote_url) 620 | def forward_to_client(proxy_obj): 621 | # Send responses to client 622 | self.log_message('%s',"Starting thread to forward server messages to the client") 623 | while not proxy_obj._closed: 624 | message = str(proxy_obj._remote_websocket.recv()) 625 | if trunc != 0: 626 | print(f"{grn}SERVER: '{message[:trunc] + (message[trunc:] and '..')}'{off}") 627 | proxy_obj.send_message(message) 628 | if self.clone_recv: 629 | self.recv_queue.put(message) 630 | proxy_obj._remote_websocket.close() 631 | proxy_obj.log_message('%s',"Server websocket closed") 632 | threading.Thread(target=forward_to_client, args=(self,)).start() 633 | 634 | def on_ws_closed(self): 635 | self._closed = True 636 | self.log_message('%s','Client websocket closed') 637 | 638 | 639 | # Class to mimic websocket-client api 640 | class WsClientApiWrapper: 641 | proxy = None 642 | def __init__(self, proxy): 643 | self.proxy = proxy 644 | self.proxy.clone_recv = True 645 | 646 | def send(self, msg): 647 | self.proxy.on_ws_message(msg) 648 | 649 | def recv(self, block=True): 650 | return self.proxy.recv_queue.get(block) 651 | 652 | 653 | first_ws_connection = None 654 | def start_and_grab_first_websocket(): 655 | threading.Thread(target=main).start() 656 | while first_ws_connection is None: 657 | print("Waiting for websocket connection") 658 | time.sleep(1) 659 | print("Websocket connection established") 660 | print("Wrapping connection to mimic 'websocket-client' api") 661 | return WsClientApiWrapper(first_ws_connection) 662 | 663 | 664 | 665 | def main(): 666 | try: 667 | handler = WSProxy 668 | server = ThreadedHTTPServer(('127.0.0.1', proxy_port), handler) 669 | sockname = server.socket.getsockname() 670 | if secure: 671 | print(f"started https intercept proxy server at {sockname[0]} (port {proxy_port})") 672 | else: 673 | print(f"started https relay proxy server at {sockname[0]} (port {proxy_port})") 674 | server.serve_forever() 675 | except KeyboardInterrupt: 676 | print('^C received, shutting down server') 677 | server.socket.close() 678 | 679 | if __name__ == '__main__': 680 | main() 681 | --------------------------------------------------------------------------------