├── .gitignore ├── examples ├── ua_changer.py ├── example.py └── ssl_strip.py ├── Justfile ├── test.sh ├── pyproject.toml ├── LICENSE ├── README.md └── proxy3.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pem 2 | certs/ 3 | __pycache__/ 4 | proxy3.egg-info/ 5 | dist/ 6 | build/ 7 | -------------------------------------------------------------------------------- /examples/ua_changer.py: -------------------------------------------------------------------------------- 1 | def request_handler(req, req_body): 2 | req.headers["User-Agent"] = "Mozilla/4.0 (compatible; MSIE 5.01; Windows 98)" 3 | -------------------------------------------------------------------------------- /Justfile: -------------------------------------------------------------------------------- 1 | build: 2 | rm -rf dist/ 3 | python -m build 4 | 5 | publish-test: 6 | twine upload --repository testpypi dist/* 7 | 8 | publish: 9 | twine upload dist/* 10 | 11 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | trap 'kill $(jobs -p)' EXIT 4 | export PYTHONPATH=. 5 | 6 | python proxy3.py --request-handler examples.example:request_handler \ 7 | --response-handler examples.example:response_handler \ 8 | --save-handler off & 9 | sleep 2 10 | export http_proxy=localhost:7777 11 | export https_proxy=localhost:7777 12 | curl http://httpbin.org/get 13 | curl https://httpbin.org/get -k 14 | -------------------------------------------------------------------------------- /examples/example.py: -------------------------------------------------------------------------------- 1 | from http.client import HTTPResponse 2 | from http.server import BaseHTTPRequestHandler 3 | 4 | 5 | def request_handler( 6 | req: BaseHTTPRequestHandler, 7 | req_body: str, 8 | ) -> str | bool | None: 9 | print("Request handler invoked, the url: ", req.address_string()) 10 | ... 11 | 12 | 13 | def response_handler( 14 | req: BaseHTTPRequestHandler, 15 | req_body: str, 16 | res: HTTPResponse, 17 | res_body: str, 18 | ) -> str | bool | None: 19 | print("Response handler invoked, status code: ", res.status) 20 | ... 21 | 22 | 23 | def save_handler( 24 | req: BaseHTTPRequestHandler, 25 | req_body: str, 26 | res: HTTPResponse, 27 | res_body: str, 28 | ): 29 | ... 30 | -------------------------------------------------------------------------------- /examples/ssl_strip.py: -------------------------------------------------------------------------------- 1 | import re 2 | from collections import deque 3 | 4 | replaced_urls = deque(maxlen=1024) 5 | 6 | 7 | def request_handler(req, req_body): 8 | if req.path in replaced_urls: 9 | req.path = req.path.replace("http://", "https://") 10 | 11 | 12 | def response_handler(req, req_body, res, res_body): 13 | def replacefunc(m): 14 | http_url = "http://" + m.group(1) 15 | replaced_urls.append(http_url) 16 | return http_url 17 | 18 | re_https_url = r"https://([-_.!~*'()a-zA-Z0-9;/?:@&=+$,%]+)" 19 | 20 | if "Location" in res.headers: 21 | res.headers["Location"] = re.sub( 22 | re_https_url, replacefunc, res.headers["Location"] 23 | ) 24 | return re.sub(re_https_url, replacefunc, res_body) 25 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "proxy3" 3 | version = "0.2.1" 4 | authors = [{ name = "Yifei Kong", email = "kong@yifei.me" }] 5 | description = "Proxy3 - Man-in-the-middle http/https proxy in a single python script" 6 | license = { file = "LICENSE" } 7 | dependencies = [] 8 | readme = "README.md" 9 | requires-python = ">=3.8" 10 | urls = { "repository" = "https://github.com/yifeikong/proxy3" } 11 | classifiers = [ 12 | "Development Status :: 5 - Production/Stable", 13 | "Intended Audience :: Developers", 14 | "License :: OSI Approved :: BSD License", 15 | "Programming Language :: Python :: 3", 16 | "Programming Language :: Python :: 3.9", 17 | "Programming Language :: Python :: 3.10", 18 | "Programming Language :: Python :: 3.11", 19 | ] 20 | 21 | [project.scripts] 22 | proxy3 = "proxy3:main" 23 | 24 | [tool.setuptools] 25 | py-modules = ["proxy3"] 26 | 27 | [build-system] 28 | requires = ["setuptools"] 29 | build-backend = "setuptools.build_meta" 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015, inaz2 2 | Copyright (c) 2023, yifeikong 3 | All rights reserved. 4 | 5 | Redistribution and use in source and binary forms, with or without 6 | modification, are permitted provided that the following conditions are met: 7 | 8 | * Redistributions of source code must retain the above copyright notice, this 9 | list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above copyright notice, 12 | this list of conditions and the following disclaimer in the documentation 13 | and/or other materials provided with the distribution. 14 | 15 | * Neither the name of proxy2 nor the names of its 16 | contributors may be used to endorse or promote products derived from 17 | this software without specific prior written permission. 18 | 19 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 20 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 21 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 22 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 23 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 24 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 25 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 26 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 27 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 28 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # proxy3 2 | 3 | Man-in-the-middle http/https proxy in a single python script 4 | 5 | ## Features 6 | 7 | * easy to customize 8 | * require no external modules 9 | * support both of IPv4 and IPv6 10 | * support HTTP/1.1 Persistent Connection 11 | * support dynamic certificate generation for HTTPS intercept 12 | 13 | This script works on Python 3.10+. 14 | You need to install `openssl` to intercept HTTPS connections. 15 | 16 | 17 | ## Usage 18 | 19 | Just clone and run as a script: 20 | 21 | $ python proxy3.py 22 | 23 | Or, install using pip: 24 | 25 | $ pip install proxy3 26 | $ proxy3 27 | 28 | Above command runs the proxy on localhost:7777. Verify it works by typing the below 29 | command in another terminal of the same host. 30 | 31 | # test http proxy 32 | $ http_proxy=localhost:7777 curl http://www.example.com/ 33 | 34 | To bind to another host or port: 35 | 36 | $ python proxy3.py --host 0.0.0.0 --port 3128 37 | 38 | 39 | ## Enable HTTPS intercept 40 | 41 | To intercept HTTPS connections, generate private keys and a private CA certificate: 42 | 43 | $ python proxy3.py --make-certs 44 | $ https_proxy=localhost:7777 curl https://www.example.com/ 45 | 46 | Through the proxy, you can access http://proxy3.test/ and install the CA certificate in the browsers. 47 | 48 | 49 | ## Detailed Usage 50 | 51 | $ python proxy3.py --help 52 | 53 | usage: proxy3.py [-h] [-H HOST] [-p PORT] [--timeout TIMEOUT] [--ca-key CA_KEY] [--c 54 | a-cert CA_CERT] [--ca-signing-key CA_SIGNING_KEY] [--cert-dir CERT_DIR] [--request-h 55 | andler REQUEST_HANDLER] [--response-handler RESPONSE_HANDLER] [--save-handler SAVE_H 56 | ANDLER] [--make-certs] 57 | 58 | options: 59 | -h, --help show this help message and exit 60 | -H HOST, --host HOST Host to bind (default: localhost) 61 | -p PORT, --port PORT Port to bind (default: 7777) 62 | --timeout TIMEOUT Timeout (default: 5) 63 | --ca-key CA_KEY CA key file (default: ./ca-key.pem) 64 | --ca-cert CA_CERT CA cert file (default: ./ca-cert.pem) 65 | --ca-signing-key CA_SIGNING_KEY 66 | CA cert key file (default: ./ca-signing-key.pem) 67 | --cert-dir CERT_DIR Site certs files (default: ./certs) 68 | --request-handler REQUEST_HANDLER 69 | Request handler function (default: None) 70 | --response-handler RESPONSE_HANDLER 71 | Response handler function (default: None) 72 | --save-handler SAVE_HANDLER 73 | Save handler function, use 'off' to turn off (default: None) 74 | 75 | ## Customization 76 | 77 | `proxy3` can be customized by providing handler functions via commandline options. It's 78 | not possible to use `proxy3` as a python library for now, but PRs are welcomed. 79 | 80 | You can easily customize the proxy and modify the requests/responses or save something to the files. 81 | The ProxyRequestHandler class has 3 methods to customize: 82 | 83 | * request_handler: called before accessing the upstream server 84 | * response_handler: called before responding to the client 85 | * save_handler: called after responding to the client with the exclusive lock, so you can safely write out to the terminal or the file system 86 | 87 | By default, only save_handler is implemented which outputs HTTP(S) headers and some useful data to the standard output. 88 | 89 | ## TODO 90 | 91 | [ ] check `openssl` availability when starting 92 | [ ] use `faketime` with `openssl` to ensure the certs date range are valid. 93 | -------------------------------------------------------------------------------- /proxy3.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import base64 3 | import glob 4 | import gzip 5 | import http.client 6 | import http.server 7 | import importlib 8 | import json 9 | import os 10 | import re 11 | import select 12 | import socket 13 | import ssl 14 | import sys 15 | import threading 16 | import time 17 | import urllib.parse 18 | import zlib 19 | from http.client import HTTPMessage 20 | from http.server import BaseHTTPRequestHandler, HTTPServer 21 | from socketserver import ThreadingMixIn 22 | from subprocess import PIPE, Popen 23 | 24 | RED = 31 25 | GREEN = 32 26 | YELLOW = 33 27 | BLUE = 34 28 | MAGENTA = 35 29 | CYAN = 36 30 | 31 | 32 | def with_color(c: int, s: str): 33 | return "\x1b[%dm%s\x1b[0m" % (c, s) 34 | 35 | 36 | class ThreadingHTTPServer(ThreadingMixIn, HTTPServer): 37 | address_family = socket.AF_INET6 38 | daemon_threads = True 39 | 40 | def handle_error(self, request, client_address): 41 | # surpress socket/ssl related errors 42 | cls, e = sys.exc_info()[:2] 43 | if cls is socket.error or cls is ssl.SSLError: 44 | pass 45 | else: 46 | return HTTPServer.handle_error(self, request, client_address) 47 | 48 | 49 | class ProxyRequestHandler(BaseHTTPRequestHandler): 50 | lock = threading.Lock() 51 | 52 | def __init__(self, *args, **kwargs): 53 | self.tls = threading.local() 54 | self.tls.conns = {} 55 | 56 | super().__init__(*args, **kwargs) 57 | 58 | def log_error(self, format, *args): 59 | # surpress "Request timed out: timeout('timed out',)" 60 | if isinstance(args[0], socket.timeout): 61 | return 62 | 63 | self.log_message(format, *args) 64 | 65 | def do_CONNECT(self): 66 | host, _ = self.path.split(":", 1) 67 | # if args.userpass: 68 | # auth = self.headers.get("Proxy-Authorization") 69 | # print("Proxy-Authorization: ", dict(self.headers.items())) 70 | # if not auth: 71 | # print("Client does not provide userpass as '%s'" % args.userpass) 72 | # self.send_header("Proxy-Authenticate", 'Basic realm="%s"' % host) 73 | # self.send_error(407) 74 | # return 75 | # client_userpass = base64.b64decode(auth[6:]) 76 | # if args.userpass != client_userpass: 77 | # print("Client userpass '%s' != '%s'" % (client_userpass, args.userpass)) 78 | # self.send_error(403) 79 | # return 80 | 81 | # print("args.domain", args.domain, "host", host, "equal", args.domain == host) 82 | if ( 83 | os.path.isfile(args.ca_key) 84 | and os.path.isfile(args.ca_cert) 85 | and os.path.isfile(args.cert_key) 86 | and os.path.isdir(args.cert_dir) 87 | and (args.domain == "*" or args.domain == host) 88 | ): 89 | print("HTTPS mitm enabled, Intercepting...") 90 | self.connect_intercept() 91 | else: 92 | print("HTTPS relay only, NOT Intercepting...") 93 | self.connect_relay() 94 | 95 | def connect_intercept(self): 96 | hostname = self.path.split(":")[0] 97 | certpath = os.path.join(args.cert_dir, hostname + ".pem") 98 | confpath = os.path.join(args.cert_dir, hostname + ".conf") 99 | 100 | with self.lock: 101 | # stupid requirements from Apple: https://support.apple.com/en-us/HT210176 102 | if not os.path.isfile(certpath): 103 | if re.match(r"^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$", hostname): 104 | category = "IP" 105 | else: 106 | category = "DNS" 107 | with open(confpath, "w") as f: 108 | f.write( 109 | "subjectAltName=%s:%s\nextendedKeyUsage=serverAuth\n" 110 | % (category, hostname) 111 | ) 112 | epoch = "%d" % (time.time() * 1000) 113 | # CSR 114 | p1 = Popen( 115 | [ 116 | "openssl", 117 | "req", 118 | "-sha256", 119 | "-new", 120 | "-key", 121 | args.cert_key, 122 | "-subj", 123 | "/CN=%s" % hostname, 124 | "-addext", 125 | "subjectAltName=DNS:%s" % hostname, 126 | ], 127 | stdout=PIPE, 128 | ) 129 | # Sign 130 | p2 = Popen( 131 | [ 132 | "openssl", 133 | "x509", 134 | "-req", 135 | "-sha256", 136 | "-days", 137 | "365", 138 | "-CA", 139 | args.ca_cert, 140 | "-CAkey", 141 | args.ca_key, 142 | "-set_serial", 143 | epoch, 144 | "-out", 145 | certpath, 146 | "-extfile", 147 | confpath, 148 | ], 149 | stdin=p1.stdout, 150 | stderr=PIPE, 151 | ) 152 | p2.communicate() 153 | 154 | self.send_response(200, "Connection Established") 155 | self.end_headers() 156 | 157 | context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) 158 | context.verify_mode = ssl.CERT_NONE 159 | # print(args.cert_key) 160 | context.load_cert_chain(certpath, args.cert_key) 161 | try: 162 | self.connection = context.wrap_socket(self.connection, server_side=True) 163 | except ssl.SSLEOFError: 164 | print("Handshake refused by client, maybe SSL pinning?") 165 | return 166 | self.rfile = self.connection.makefile("rb", self.rbufsize) 167 | self.wfile = self.connection.makefile("wb", self.wbufsize) 168 | 169 | conntype = self.headers.get("Proxy-Connection", "") 170 | if self.protocol_version == "HTTP/1.1" and conntype.lower() != "close": 171 | self.close_connection = False 172 | else: 173 | self.close_connection = True 174 | 175 | def connect_relay(self): 176 | address = self.path.split(":", 1) 177 | address = (address[0], int(address[1]) or 443) 178 | try: 179 | s = socket.create_connection(address, timeout=self.timeout) 180 | except Exception: 181 | self.send_error(502) 182 | return 183 | self.send_response(200, "Connection Established") 184 | self.end_headers() 185 | 186 | conns = [self.connection, s] 187 | self.close_connection = False 188 | while not self.close_connection: 189 | rlist, wlist, xlist = select.select(conns, [], conns, self.timeout) 190 | if xlist or not rlist: 191 | break 192 | for r in rlist: 193 | other = conns[1] if r is conns[0] else conns[0] 194 | data = r.recv(8192) 195 | if not data: 196 | self.close_connection = True 197 | break 198 | other.sendall(data) 199 | 200 | def do_GET(self): 201 | if self.path == "http://proxy3.test/": 202 | self.send_cacert() 203 | return 204 | 205 | req = self 206 | content_length = int(req.headers.get("Content-Length", 0)) 207 | req_body = self.rfile.read(content_length) if content_length else b"" 208 | 209 | if req.path[0] == "/": 210 | if isinstance(self.connection, ssl.SSLSocket): 211 | req.path = "https://%s%s" % (req.headers["Host"], req.path) 212 | else: 213 | req.path = "http://%s%s" % (req.headers["Host"], req.path) 214 | 215 | if request_handler is not None: 216 | # convert to str and back to bytes 217 | req_body_modified = request_handler(req, req_body.decode()) 218 | if req_body_modified is False: 219 | self.send_error(403) 220 | return 221 | if req_body_modified is not None: 222 | req_body = req_body_modified.encode() 223 | req.headers["Content-Length"] = str(len(req_body)) 224 | 225 | u = urllib.parse.urlsplit(req.path) 226 | scheme = u.scheme 227 | netloc = u.netloc 228 | path = u.path + "?" + u.query if u.query else u.path 229 | assert scheme in ("http", "https") 230 | if netloc: 231 | req.headers["Host"] = netloc 232 | req.headers = self.filter_headers(req.headers) # type: ignore 233 | 234 | origin = (scheme, netloc) 235 | try: 236 | if origin not in self.tls.conns: 237 | if scheme == "https": 238 | self.tls.conns[origin] = http.client.HTTPSConnection( 239 | netloc, timeout=self.timeout 240 | ) 241 | else: 242 | self.tls.conns[origin] = http.client.HTTPConnection( 243 | netloc, timeout=self.timeout 244 | ) 245 | conn = self.tls.conns[origin] 246 | conn.request(self.command, path, req_body, dict(req.headers)) 247 | res = conn.getresponse() 248 | 249 | # support streaming 250 | cache_control = res.headers.get("Cache-Control", "") 251 | if "Content-Length" not in res.headers and "no-store" in cache_control: 252 | if response_handler is not None: 253 | response_handler(req, req_body, res, "") 254 | res.headers = self.filter_headers(res.headers) 255 | self.relay_streaming(res) 256 | if save_handler is not None: 257 | with self.lock: 258 | save_handler(req, req_body, res, "") 259 | return 260 | 261 | res_body = res.read() 262 | except Exception: 263 | if origin in self.tls.conns: 264 | del self.tls.conns[origin] 265 | self.send_error(502) 266 | return 267 | 268 | if response_handler is not None: 269 | content_encoding = res.headers.get("Content-Encoding", "identity") 270 | res_body_plain = self.decode_content_body(res_body, content_encoding) 271 | res_body_modified = response_handler(req, req_body, res, res_body_plain) 272 | if res_body_modified is False: 273 | self.send_error(403) 274 | return 275 | if res_body_modified is not None: 276 | res_body = self.encode_content_body(res_body_modified, content_encoding) 277 | res.headers["Content-Length"] = str(len(res_body)) 278 | 279 | res.headers = self.filter_headers(res.headers) 280 | 281 | self.send_response_only(res.status, res.reason) 282 | for k, v in res.headers.items(): 283 | self.send_header(k, v) 284 | self.end_headers() 285 | self.wfile.write(res_body) 286 | self.wfile.flush() 287 | 288 | if save_handler is not None: 289 | content_encoding = res.headers.get("Content-Encoding", "identity") 290 | res_body_plain = self.decode_content_body(res_body, content_encoding) 291 | with self.lock: 292 | save_handler(req, req_body, res, res_body_plain) 293 | 294 | def relay_streaming(self, res): 295 | self.send_response_only(res.status, res.reason) 296 | for k, v in res.headers.items(): 297 | self.send_header(k, v) 298 | self.end_headers() 299 | try: 300 | while True: 301 | chunk = res.read(8192) 302 | if not chunk: 303 | break 304 | self.wfile.write(chunk) 305 | self.wfile.flush() 306 | except socket.error: 307 | # connection closed by client 308 | pass 309 | 310 | do_HEAD = do_GET 311 | do_POST = do_GET 312 | do_PUT = do_GET 313 | do_DELETE = do_GET 314 | do_OPTIONS = do_GET 315 | 316 | def filter_headers(self, headers: HTTPMessage) -> HTTPMessage: 317 | # http://tools.ietf.org/html/rfc2616#section-13.5.1 318 | hop_by_hop = ( 319 | "connection", 320 | "keep-alive", 321 | "proxy-authenticate", 322 | "proxy-authorization", 323 | "te", 324 | "trailers", 325 | "transfer-encoding", 326 | "upgrade", 327 | ) 328 | for k in hop_by_hop: 329 | del headers[k] 330 | 331 | # accept only supported encodings 332 | if "Accept-Encoding" in headers: 333 | ae = headers["Accept-Encoding"] 334 | filtered_encodings = [ 335 | x 336 | for x in re.split(r",\s*", ae) 337 | if x in ("identity", "gzip", "x-gzip", "deflate") 338 | ] 339 | headers["Accept-Encoding"] = ", ".join(filtered_encodings) 340 | 341 | return headers 342 | 343 | def encode_content_body(self, text: bytes, encoding: str) -> bytes: 344 | if encoding == "identity": 345 | data = text 346 | elif encoding in ("gzip", "x-gzip"): 347 | data = gzip.compress(text) 348 | elif encoding == "deflate": 349 | data = zlib.compress(text) 350 | else: 351 | raise Exception("Unknown Content-Encoding: %s" % encoding) 352 | return data 353 | 354 | def decode_content_body(self, data: bytes, encoding: str) -> bytes: 355 | if encoding == "identity": 356 | text = data 357 | elif encoding in ("gzip", "x-gzip"): 358 | text = gzip.decompress(data) 359 | elif encoding == "deflate": 360 | try: 361 | text = zlib.decompress(data) 362 | except zlib.error: 363 | text = zlib.decompress(data, -zlib.MAX_WBITS) 364 | else: 365 | raise Exception("Unknown Content-Encoding: %s" % encoding) 366 | return text 367 | 368 | def send_cacert(self): 369 | with open(args.ca_cert, "rb") as f: 370 | data = f.read() 371 | 372 | self.send_response(200, "OK") 373 | self.send_header("Content-Type", "application/x-x509-ca-cert") 374 | self.send_header("Content-Length", str(len(data))) 375 | self.send_header("Connection", "close") 376 | self.end_headers() 377 | self.wfile.write(data) 378 | 379 | 380 | def parse_qsl(s): 381 | return "\n".join( 382 | "%-20s %s" % (k, v) 383 | for k, v in urllib.parse.parse_qsl(s, keep_blank_values=True) 384 | ) 385 | 386 | 387 | def print_info(req, req_body, res, res_body): 388 | req_header_text = "%s %s %s\n%s" % ( 389 | req.command, 390 | req.path, 391 | req.request_version, 392 | req.headers, 393 | ) 394 | version_table = {10: "HTTP/1.0", 11: "HTTP/1.1"} 395 | res_header_text = "%s %d %s\n%s" % ( 396 | version_table[res.version], 397 | res.status, 398 | res.reason, 399 | res.headers, 400 | ) 401 | 402 | print(with_color(YELLOW, req_header_text)) 403 | 404 | u = urllib.parse.urlsplit(req.path) 405 | if u.query: 406 | query_text = parse_qsl(u.query) 407 | print(with_color(GREEN, "==== QUERY PARAMETERS ====\n%s\n" % query_text)) 408 | 409 | cookie = req.headers.get("Cookie", "") 410 | if cookie: 411 | cookie = parse_qsl(re.sub(r";\s*", "&", cookie)) 412 | print(with_color(GREEN, "==== COOKIE ====\n%s\n" % cookie)) 413 | 414 | auth = req.headers.get("Authorization", "") 415 | if auth.lower().startswith("basic"): 416 | token = auth.split()[1].decode("base64") 417 | print(with_color(RED, "==== BASIC AUTH ====\n%s\n" % token)) 418 | 419 | if req_body is not None: 420 | req_body_text = None 421 | content_type = req.headers.get("Content-Type", "") 422 | 423 | if content_type.startswith("application/x-www-form-urlencoded"): 424 | req_body_text = parse_qsl(req_body) 425 | elif content_type.startswith("application/json"): 426 | try: 427 | json_obj = json.loads(req_body) 428 | json_str = json.dumps(json_obj, indent=2) 429 | if json_str.count("\n") < 50: 430 | req_body_text = json_str 431 | else: 432 | lines = json_str.splitlines() 433 | req_body_text = "%s\n(%d lines)" % ( 434 | "\n".join(lines[:50]), 435 | len(lines), 436 | ) 437 | except ValueError: 438 | req_body_text = req_body 439 | elif len(req_body) < 1024: 440 | req_body_text = req_body 441 | 442 | if req_body_text: 443 | print(with_color(GREEN, "==== REQUEST BODY ====\n%s\n" % req_body_text)) 444 | 445 | print(with_color(CYAN, res_header_text)) 446 | 447 | cookies = res.headers.get("Set-Cookie") 448 | if cookies: 449 | print(with_color(RED, "==== SET-COOKIE ====\n%s\n" % cookies)) 450 | 451 | if res_body is not None: 452 | res_body_text = None 453 | content_type = res.headers.get("Content-Type", "") 454 | 455 | if content_type.startswith("application/json"): 456 | try: 457 | json_obj = json.loads(res_body) 458 | json_str = json.dumps(json_obj, indent=2) 459 | if json_str.count("\n") < 50: 460 | res_body_text = json_str 461 | else: 462 | lines = json_str.splitlines() 463 | res_body_text = "%s\n(%d lines)" % ( 464 | "\n".join(lines[:50]), 465 | len(lines), 466 | ) 467 | except ValueError: 468 | res_body_text = res_body 469 | elif content_type.startswith("text/html"): 470 | m = re.search(rb"]*>\s*([^<]+?)\s*", res_body, re.I) 471 | if m: 472 | print( 473 | with_color( 474 | GREEN, "==== HTML TITLE ====\n%s\n" % m.group(1).decode() 475 | ) 476 | ) 477 | elif content_type.startswith("text/") and len(res_body) < 1024: 478 | res_body_text = res_body 479 | 480 | if res_body_text: 481 | print(with_color(GREEN, "==== RESPONSE BODY ====\n%s\n" % res_body_text)) 482 | 483 | 484 | def main(): 485 | """place holder, no action, but do not delete.""" 486 | 487 | 488 | parser = argparse.ArgumentParser(formatter_class=argparse.ArgumentDefaultsHelpFormatter) 489 | parser.add_argument("-b", "--bind", default="localhost", help="Host to bind") 490 | parser.add_argument("-p", "--port", type=int, default=7777, help="Port to bind") 491 | parser.add_argument( 492 | "-d", 493 | "--domain", 494 | default="*", 495 | help="Domain to intercept, if not set, intercept all.", 496 | ) 497 | parser.add_argument( 498 | "-u", 499 | "--userpass", 500 | help="Username and password for proxy authentication, format: 'user:pass'", 501 | ) 502 | parser.add_argument("--timeout", type=int, default=5, help="Timeout") 503 | parser.add_argument("--ca-key", default="./ca-key.pem", help="CA key file") 504 | parser.add_argument("--ca-cert", default="./ca-cert.pem", help="CA cert file") 505 | parser.add_argument("--cert-key", default="./cert-key.pem", help="site cert key file") 506 | parser.add_argument("--cert-dir", default="./certs", help="Site certs files") 507 | parser.add_argument( 508 | "--request-handler", 509 | help="Request handler function, example: foo.bar:handle_request", 510 | ) 511 | parser.add_argument( 512 | "--response-handler", 513 | help="Response handler function, example: foo.bar:handle_response", 514 | ) 515 | parser.add_argument( 516 | "--save-handler", 517 | help="Save handler function, use 'off' to turn off, example: foo.bar:handle_save", 518 | ) 519 | parser.add_argument( 520 | "--make-certs", action="store_true", help="Create https intercept certs" 521 | ) 522 | parser.add_argument( 523 | "--make-example", 524 | action="store_true", 525 | help="Create an intercept handlers example python file", 526 | ) 527 | args = parser.parse_args() 528 | 529 | if args.make_certs: 530 | Popen(["openssl", "genrsa", "-out", args.ca_key, "2048"]).communicate() 531 | Popen( 532 | [ 533 | "openssl", 534 | "req", 535 | "-new", 536 | "-x509", 537 | "-days", 538 | "3650", 539 | "-key", 540 | args.ca_key, 541 | "-sha256", 542 | "-out", 543 | args.ca_cert, 544 | "-subj", 545 | "/CN=Proxy3 CA", 546 | ] 547 | ).communicate() 548 | Popen(["openssl", "genrsa", "-out", args.cert_key, "2048"]).communicate() 549 | os.makedirs(args.cert_dir, exist_ok=True) 550 | for old_cert in glob.glob(os.path.join(args.cert_dir, "*.pem")): 551 | os.remove(old_cert) 552 | sys.exit(0) 553 | 554 | if args.make_example: 555 | import shutil 556 | 557 | example_file = os.path.join(os.path.dirname(__file__), "examples/example.py") 558 | shutil.copy(example_file, "proxy3_handlers_example.py") 559 | sys.exit(0) 560 | 561 | if args.request_handler: 562 | module, func = args.request_handler.split(":") 563 | m = importlib.import_module(module) 564 | request_handler = getattr(m, func) 565 | else: 566 | request_handler = None 567 | if args.response_handler: 568 | module, func = args.response_handler.split(":") 569 | m = importlib.import_module(module) 570 | response_handler = getattr(m, func) 571 | else: 572 | response_handler = None 573 | if args.save_handler: 574 | if args.save_handler == "off": 575 | save_handler = None 576 | else: 577 | module, func = args.save_handler.split(":") 578 | m = importlib.import_module(module) 579 | save_handler = getattr(m, func) 580 | else: 581 | save_handler = print_info 582 | 583 | protocol = "HTTP/1.1" 584 | http.server.test( 585 | HandlerClass=ProxyRequestHandler, 586 | ServerClass=ThreadingHTTPServer, 587 | protocol=protocol, 588 | port=args.port, 589 | bind=args.bind, 590 | ) 591 | --------------------------------------------------------------------------------