├── LICENSE ├── README.md ├── examples ├── proxy2.py ├── sslstrip.py └── uachanger.py ├── https_trasparent.py ├── proxy2.py └── setup_https_intercept.sh /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2015, inaz2 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions are met: 6 | 7 | * Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | * Redistributions in binary form must reproduce the above copyright notice, 11 | this list of conditions and the following disclaimer in the documentation 12 | and/or other materials provided with the distribution. 13 | 14 | * Neither the name of proxy2 nor the names of its 15 | contributors may be used to endorse or promote products derived from 16 | this software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 19 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 20 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 22 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 23 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 24 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 25 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 26 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 27 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # proxy2 2 | 3 | HTTP/HTTPS proxy in a single python script 4 | 5 | 6 | ## Features 7 | 8 | * easy to customize 9 | * require no external modules 10 | * support both of IPv4 and IPv6 11 | * support HTTP/1.1 Persistent Connection 12 | * support dynamic certificate generation for HTTPS intercept 13 | 14 | This script works on Python 2.7. 15 | You need to install OpenSSL to intercept HTTPS connections. 16 | 17 | 18 | ## Usage 19 | 20 | Just run as a script: 21 | 22 | ``` 23 | $ python proxy2.py 24 | ``` 25 | 26 | Above command runs the proxy on localhost:8080. 27 | Verify it works by typing the below command on another terminal of the same host. 28 | 29 | ``` 30 | $ http_proxy=localhost:8080 curl http://www.example.com/ 31 | ``` 32 | 33 | proxy2 is made for debugging/testing, so it only accepts connections from localhost. 34 | 35 | To use another port, specify the port number as the first argument. 36 | 37 | ``` 38 | $ python proxy2.py 3128 39 | ``` 40 | 41 | 42 | ## Enable HTTPS intercept 43 | 44 | To intercept HTTPS connections, generate private keys and a private CA certificate: 45 | 46 | ``` 47 | $ ./setup_https_intercept.sh 48 | ``` 49 | 50 | Through the proxy, you can access http://proxy2.test/ and install the CA certificate in the browsers. 51 | 52 | 53 | ## Customization 54 | 55 | You can easily customize the proxy and modify the requests/responses or save something to the files. 56 | The ProxyRequestHandler class has 3 methods to customize: 57 | 58 | * request_handler: called before accessing the upstream server 59 | * response_handler: called before responding to the client 60 | * 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 61 | 62 | By default, only save_handler is implemented which outputs HTTP(S) headers and some useful data to the standard output. 63 | -------------------------------------------------------------------------------- /examples/proxy2.py: -------------------------------------------------------------------------------- 1 | ../proxy2.py -------------------------------------------------------------------------------- /examples/sslstrip.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from proxy2 import * 3 | from collections import deque 4 | 5 | class SSLStripRequestHandler(ProxyRequestHandler): 6 | replaced_urls = deque(maxlen=1024) 7 | 8 | def request_handler(self, req, req_body): 9 | if req.path in self.replaced_urls: 10 | req.path = req.path.replace('http://', 'https://') 11 | 12 | def response_handler(self, req, req_body, res, res_body): 13 | def replacefunc(m): 14 | http_url = "http://" + m.group(1) 15 | self.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(re_https_url, replacefunc, res.headers['Location']) 22 | return re.sub(re_https_url, replacefunc, res_body) 23 | 24 | 25 | if __name__ == '__main__': 26 | test(HandlerClass=SSLStripRequestHandler) 27 | -------------------------------------------------------------------------------- /examples/uachanger.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from proxy2 import * 3 | 4 | class UAChangerRequestHandler(ProxyRequestHandler): 5 | def request_handler(self, req, req_body): 6 | req.headers['User-Agent'] = 'Mozilla/4.0 (compatible; MSIE 5.01; Windows 98)' 7 | 8 | 9 | if __name__ == '__main__': 10 | test(HandlerClass=UAChangerRequestHandler) 11 | -------------------------------------------------------------------------------- /https_trasparent.py: -------------------------------------------------------------------------------- 1 | from proxy2 import * 2 | 3 | class ThreadingHTTPSServer(ThreadingHTTPServer): 4 | address_family = socket.AF_INET6 5 | daemon_threads = True 6 | 7 | cakey = 'ca.key' 8 | cacert = 'ca.crt' 9 | 10 | def get_request(self): 11 | request, client_address = self.socket.accept() 12 | request = ssl.wrap_socket(request, keyfile=self.cakey, certfile=self.cacert, server_side=True) 13 | return request, client_address 14 | 15 | def handle_error(self, request, client_address): 16 | # surpress socket/ssl related errors 17 | cls, e = sys.exc_info()[:2] 18 | if cls is socket.error or cls is ssl.SSLError: 19 | pass 20 | else: 21 | return HTTPServer.handle_error(self, request, client_address) 22 | 23 | 24 | def test(HandlerClass=ProxyRequestHandler, ServerClass=ThreadingHTTPSServer, protocol="HTTP/1.1"): 25 | if sys.argv[1:]: 26 | port = int(sys.argv[1]) 27 | else: 28 | port = 3129 29 | server_address = ('', port) 30 | 31 | HandlerClass.protocol_version = protocol 32 | httpd = ServerClass(server_address, HandlerClass) 33 | 34 | sa = httpd.socket.getsockname() 35 | print "Serving HTTPS Proxy on", sa[0], "port", sa[1], "..." 36 | httpd.serve_forever() 37 | 38 | 39 | if __name__ == '__main__': 40 | test() 41 | -------------------------------------------------------------------------------- /proxy2.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import sys 3 | import os 4 | import socket 5 | import ssl 6 | import select 7 | import httplib 8 | import urlparse 9 | import threading 10 | import gzip 11 | import zlib 12 | import time 13 | import json 14 | import re 15 | from BaseHTTPServer import HTTPServer, BaseHTTPRequestHandler 16 | from SocketServer import ThreadingMixIn 17 | from cStringIO import StringIO 18 | from subprocess import Popen, PIPE 19 | from HTMLParser import HTMLParser 20 | 21 | 22 | def with_color(c, s): 23 | return "\x1b[%dm%s\x1b[0m" % (c, s) 24 | 25 | def join_with_script_dir(path): 26 | return os.path.join(os.path.dirname(os.path.abspath(__file__)), path) 27 | 28 | 29 | class ThreadingHTTPServer(ThreadingMixIn, HTTPServer): 30 | address_family = socket.AF_INET6 31 | daemon_threads = True 32 | 33 | def handle_error(self, request, client_address): 34 | # surpress socket/ssl related errors 35 | cls, e = sys.exc_info()[:2] 36 | if cls is socket.error or cls is ssl.SSLError: 37 | pass 38 | else: 39 | return HTTPServer.handle_error(self, request, client_address) 40 | 41 | 42 | class ProxyRequestHandler(BaseHTTPRequestHandler): 43 | cakey = join_with_script_dir('ca.key') 44 | cacert = join_with_script_dir('ca.crt') 45 | certkey = join_with_script_dir('cert.key') 46 | certdir = join_with_script_dir('certs/') 47 | timeout = 5 48 | lock = threading.Lock() 49 | 50 | def __init__(self, *args, **kwargs): 51 | self.tls = threading.local() 52 | self.tls.conns = {} 53 | 54 | BaseHTTPRequestHandler.__init__(self, *args, **kwargs) 55 | 56 | def log_error(self, format, *args): 57 | # surpress "Request timed out: timeout('timed out',)" 58 | if isinstance(args[0], socket.timeout): 59 | return 60 | 61 | self.log_message(format, *args) 62 | 63 | def do_CONNECT(self): 64 | if os.path.isfile(self.cakey) and os.path.isfile(self.cacert) and os.path.isfile(self.certkey) and os.path.isdir(self.certdir): 65 | self.connect_intercept() 66 | else: 67 | self.connect_relay() 68 | 69 | def connect_intercept(self): 70 | hostname = self.path.split(':')[0] 71 | certpath = "%s/%s.crt" % (self.certdir.rstrip('/'), hostname) 72 | 73 | with self.lock: 74 | if not os.path.isfile(certpath): 75 | epoch = "%d" % (time.time() * 1000) 76 | p1 = Popen(["openssl", "req", "-new", "-key", self.certkey, "-subj", "/CN=%s" % hostname], stdout=PIPE) 77 | p2 = Popen(["openssl", "x509", "-req", "-days", "3650", "-CA", self.cacert, "-CAkey", self.cakey, "-set_serial", epoch, "-out", certpath], stdin=p1.stdout, stderr=PIPE) 78 | p2.communicate() 79 | 80 | self.wfile.write("%s %d %s\r\n" % (self.protocol_version, 200, 'Connection Established')) 81 | self.end_headers() 82 | 83 | self.connection = ssl.wrap_socket(self.connection, keyfile=self.certkey, certfile=certpath, server_side=True) 84 | self.rfile = self.connection.makefile("rb", self.rbufsize) 85 | self.wfile = self.connection.makefile("wb", self.wbufsize) 86 | 87 | conntype = self.headers.get('Proxy-Connection', '') 88 | if self.protocol_version == "HTTP/1.1" and conntype.lower() != 'close': 89 | self.close_connection = 0 90 | else: 91 | self.close_connection = 1 92 | 93 | def connect_relay(self): 94 | address = self.path.split(':', 1) 95 | address[1] = int(address[1]) or 443 96 | try: 97 | s = socket.create_connection(address, timeout=self.timeout) 98 | except Exception as e: 99 | self.send_error(502) 100 | return 101 | self.send_response(200, 'Connection Established') 102 | self.end_headers() 103 | 104 | conns = [self.connection, s] 105 | self.close_connection = 0 106 | while not self.close_connection: 107 | rlist, wlist, xlist = select.select(conns, [], conns, self.timeout) 108 | if xlist or not rlist: 109 | break 110 | for r in rlist: 111 | other = conns[1] if r is conns[0] else conns[0] 112 | data = r.recv(8192) 113 | if not data: 114 | self.close_connection = 1 115 | break 116 | other.sendall(data) 117 | 118 | def do_GET(self): 119 | if self.path == 'http://proxy2.test/': 120 | self.send_cacert() 121 | return 122 | 123 | req = self 124 | content_length = int(req.headers.get('Content-Length', 0)) 125 | req_body = self.rfile.read(content_length) if content_length else None 126 | 127 | if req.path[0] == '/': 128 | if isinstance(self.connection, ssl.SSLSocket): 129 | req.path = "https://%s%s" % (req.headers['Host'], req.path) 130 | else: 131 | req.path = "http://%s%s" % (req.headers['Host'], req.path) 132 | 133 | req_body_modified = self.request_handler(req, req_body) 134 | if req_body_modified is False: 135 | self.send_error(403) 136 | return 137 | elif req_body_modified is not None: 138 | req_body = req_body_modified 139 | req.headers['Content-length'] = str(len(req_body)) 140 | 141 | u = urlparse.urlsplit(req.path) 142 | scheme, netloc, path = u.scheme, u.netloc, (u.path + '?' + u.query if u.query else u.path) 143 | assert scheme in ('http', 'https') 144 | if netloc: 145 | req.headers['Host'] = netloc 146 | setattr(req, 'headers', self.filter_headers(req.headers)) 147 | 148 | try: 149 | origin = (scheme, netloc) 150 | if not origin in self.tls.conns: 151 | if scheme == 'https': 152 | self.tls.conns[origin] = httplib.HTTPSConnection(netloc, timeout=self.timeout) 153 | else: 154 | self.tls.conns[origin] = httplib.HTTPConnection(netloc, timeout=self.timeout) 155 | conn = self.tls.conns[origin] 156 | conn.request(self.command, path, req_body, dict(req.headers)) 157 | res = conn.getresponse() 158 | 159 | version_table = {10: 'HTTP/1.0', 11: 'HTTP/1.1'} 160 | setattr(res, 'headers', res.msg) 161 | setattr(res, 'response_version', version_table[res.version]) 162 | 163 | # support streaming 164 | if not 'Content-Length' in res.headers and 'no-store' in res.headers.get('Cache-Control', ''): 165 | self.response_handler(req, req_body, res, '') 166 | setattr(res, 'headers', self.filter_headers(res.headers)) 167 | self.relay_streaming(res) 168 | with self.lock: 169 | self.save_handler(req, req_body, res, '') 170 | return 171 | 172 | res_body = res.read() 173 | except Exception as e: 174 | if origin in self.tls.conns: 175 | del self.tls.conns[origin] 176 | self.send_error(502) 177 | return 178 | 179 | content_encoding = res.headers.get('Content-Encoding', 'identity') 180 | res_body_plain = self.decode_content_body(res_body, content_encoding) 181 | 182 | res_body_modified = self.response_handler(req, req_body, res, res_body_plain) 183 | if res_body_modified is False: 184 | self.send_error(403) 185 | return 186 | elif res_body_modified is not None: 187 | res_body_plain = res_body_modified 188 | res_body = self.encode_content_body(res_body_plain, content_encoding) 189 | res.headers['Content-Length'] = str(len(res_body)) 190 | 191 | setattr(res, 'headers', self.filter_headers(res.headers)) 192 | 193 | self.wfile.write("%s %d %s\r\n" % (self.protocol_version, res.status, res.reason)) 194 | for line in res.headers.headers: 195 | self.wfile.write(line) 196 | self.end_headers() 197 | self.wfile.write(res_body) 198 | self.wfile.flush() 199 | 200 | with self.lock: 201 | self.save_handler(req, req_body, res, res_body_plain) 202 | 203 | def relay_streaming(self, res): 204 | self.wfile.write("%s %d %s\r\n" % (self.protocol_version, res.status, res.reason)) 205 | for line in res.headers.headers: 206 | self.wfile.write(line) 207 | self.end_headers() 208 | try: 209 | while True: 210 | chunk = res.read(8192) 211 | if not chunk: 212 | break 213 | self.wfile.write(chunk) 214 | self.wfile.flush() 215 | except socket.error: 216 | # connection closed by client 217 | pass 218 | 219 | do_HEAD = do_GET 220 | do_POST = do_GET 221 | do_PUT = do_GET 222 | do_DELETE = do_GET 223 | do_OPTIONS = do_GET 224 | 225 | def filter_headers(self, headers): 226 | # http://tools.ietf.org/html/rfc2616#section-13.5.1 227 | hop_by_hop = ('connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailers', 'transfer-encoding', 'upgrade') 228 | for k in hop_by_hop: 229 | del headers[k] 230 | 231 | # accept only supported encodings 232 | if 'Accept-Encoding' in headers: 233 | ae = headers['Accept-Encoding'] 234 | filtered_encodings = [x for x in re.split(r',\s*', ae) if x in ('identity', 'gzip', 'x-gzip', 'deflate')] 235 | headers['Accept-Encoding'] = ', '.join(filtered_encodings) 236 | 237 | return headers 238 | 239 | def encode_content_body(self, text, encoding): 240 | if encoding == 'identity': 241 | data = text 242 | elif encoding in ('gzip', 'x-gzip'): 243 | io = StringIO() 244 | with gzip.GzipFile(fileobj=io, mode='wb') as f: 245 | f.write(text) 246 | data = io.getvalue() 247 | elif encoding == 'deflate': 248 | data = zlib.compress(text) 249 | else: 250 | raise Exception("Unknown Content-Encoding: %s" % encoding) 251 | return data 252 | 253 | def decode_content_body(self, data, encoding): 254 | if encoding == 'identity': 255 | text = data 256 | elif encoding in ('gzip', 'x-gzip'): 257 | io = StringIO(data) 258 | with gzip.GzipFile(fileobj=io) as f: 259 | text = f.read() 260 | elif encoding == 'deflate': 261 | try: 262 | text = zlib.decompress(data) 263 | except zlib.error: 264 | text = zlib.decompress(data, -zlib.MAX_WBITS) 265 | else: 266 | raise Exception("Unknown Content-Encoding: %s" % encoding) 267 | return text 268 | 269 | def send_cacert(self): 270 | with open(self.cacert, 'rb') as f: 271 | data = f.read() 272 | 273 | self.wfile.write("%s %d %s\r\n" % (self.protocol_version, 200, 'OK')) 274 | self.send_header('Content-Type', 'application/x-x509-ca-cert') 275 | self.send_header('Content-Length', len(data)) 276 | self.send_header('Connection', 'close') 277 | self.end_headers() 278 | self.wfile.write(data) 279 | 280 | def print_info(self, req, req_body, res, res_body): 281 | def parse_qsl(s): 282 | return '\n'.join("%-20s %s" % (k, v) for k, v in urlparse.parse_qsl(s, keep_blank_values=True)) 283 | 284 | req_header_text = "%s %s %s\n%s" % (req.command, req.path, req.request_version, req.headers) 285 | res_header_text = "%s %d %s\n%s" % (res.response_version, res.status, res.reason, res.headers) 286 | 287 | print with_color(33, req_header_text) 288 | 289 | u = urlparse.urlsplit(req.path) 290 | if u.query: 291 | query_text = parse_qsl(u.query) 292 | print with_color(32, "==== QUERY PARAMETERS ====\n%s\n" % query_text) 293 | 294 | cookie = req.headers.get('Cookie', '') 295 | if cookie: 296 | cookie = parse_qsl(re.sub(r';\s*', '&', cookie)) 297 | print with_color(32, "==== COOKIE ====\n%s\n" % cookie) 298 | 299 | auth = req.headers.get('Authorization', '') 300 | if auth.lower().startswith('basic'): 301 | token = auth.split()[1].decode('base64') 302 | print with_color(31, "==== BASIC AUTH ====\n%s\n" % token) 303 | 304 | if req_body is not None: 305 | req_body_text = None 306 | content_type = req.headers.get('Content-Type', '') 307 | 308 | if content_type.startswith('application/x-www-form-urlencoded'): 309 | req_body_text = parse_qsl(req_body) 310 | elif content_type.startswith('application/json'): 311 | try: 312 | json_obj = json.loads(req_body) 313 | json_str = json.dumps(json_obj, indent=2) 314 | if json_str.count('\n') < 50: 315 | req_body_text = json_str 316 | else: 317 | lines = json_str.splitlines() 318 | req_body_text = "%s\n(%d lines)" % ('\n'.join(lines[:50]), len(lines)) 319 | except ValueError: 320 | req_body_text = req_body 321 | elif len(req_body) < 1024: 322 | req_body_text = req_body 323 | 324 | if req_body_text: 325 | print with_color(32, "==== REQUEST BODY ====\n%s\n" % req_body_text) 326 | 327 | print with_color(36, res_header_text) 328 | 329 | cookies = res.headers.getheaders('Set-Cookie') 330 | if cookies: 331 | cookies = '\n'.join(cookies) 332 | print with_color(31, "==== SET-COOKIE ====\n%s\n" % cookies) 333 | 334 | if res_body is not None: 335 | res_body_text = None 336 | content_type = res.headers.get('Content-Type', '') 337 | 338 | if content_type.startswith('application/json'): 339 | try: 340 | json_obj = json.loads(res_body) 341 | json_str = json.dumps(json_obj, indent=2) 342 | if json_str.count('\n') < 50: 343 | res_body_text = json_str 344 | else: 345 | lines = json_str.splitlines() 346 | res_body_text = "%s\n(%d lines)" % ('\n'.join(lines[:50]), len(lines)) 347 | except ValueError: 348 | res_body_text = res_body 349 | elif content_type.startswith('text/html'): 350 | m = re.search(r']*>\s*([^<]+?)\s*', res_body, re.I) 351 | if m: 352 | h = HTMLParser() 353 | print with_color(32, "==== HTML TITLE ====\n%s\n" % h.unescape(m.group(1).decode('utf-8'))) 354 | elif content_type.startswith('text/') and len(res_body) < 1024: 355 | res_body_text = res_body 356 | 357 | if res_body_text: 358 | print with_color(32, "==== RESPONSE BODY ====\n%s\n" % res_body_text) 359 | 360 | def request_handler(self, req, req_body): 361 | pass 362 | 363 | def response_handler(self, req, req_body, res, res_body): 364 | pass 365 | 366 | def save_handler(self, req, req_body, res, res_body): 367 | self.print_info(req, req_body, res, res_body) 368 | 369 | 370 | def test(HandlerClass=ProxyRequestHandler, ServerClass=ThreadingHTTPServer, protocol="HTTP/1.1"): 371 | if sys.argv[1:]: 372 | port = int(sys.argv[1]) 373 | else: 374 | port = 8080 375 | server_address = ('::1', port) 376 | 377 | HandlerClass.protocol_version = protocol 378 | httpd = ServerClass(server_address, HandlerClass) 379 | 380 | sa = httpd.socket.getsockname() 381 | print "Serving HTTP Proxy on", sa[0], "port", sa[1], "..." 382 | httpd.serve_forever() 383 | 384 | 385 | if __name__ == '__main__': 386 | test() 387 | -------------------------------------------------------------------------------- /setup_https_intercept.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | openssl genrsa -out ca.key 2048 4 | openssl req -new -x509 -days 3650 -key ca.key -out ca.crt -subj "/CN=proxy2 CA" 5 | openssl genrsa -out cert.key 2048 6 | mkdir certs/ 7 | --------------------------------------------------------------------------------