Files
2022-10-07 15:50:49 +02:00

1245 lines
49 KiB
Python

#!/usr/bin/python3
# -*- coding: utf-8 -*-
#
import time
import html
import sys, os
import brotli
import base64
import string
import http.client
import gzip, zlib
import json, re
import traceback
import requests
import urllib3
import socket, ssl, select
import threading
from datetime import datetime
from urllib.parse import urlparse, parse_qsl
from subprocess import Popen, PIPE
from io import StringIO, BytesIO
from html.parser import HTMLParser
import lib.optionsparser
from lib.proxylogger import ProxyLogger
from lib.pluginsloader import PluginsLoader
from lib.sslintercept import SSLInterception
from lib.utils import *
from http.server import BaseHTTPRequestHandler
from tornado.httpclient import AsyncHTTPClient
import tornado.web
import tornado.httpserver
from sqlitedict import SqliteDict
import plugins.malleable_redirector
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
ssl._create_default_https_context = ssl._create_unverified_context
# Global instance that will be passed to every loaded plugin at it's __init__.
logger = None
# For holding loaded plugins' instances
pluginsloaded = None
# SSL Interception setup object
sslintercept = None
options = {}
class RemoveXProxy2HeadersTransform(tornado.web.OutputTransform):
def transform_first_chunk(self, status_code, headers, chunk, finishing):
xhdrs = [x.lower() for x in plugins.IProxyPlugin.proxy2_metadata_headers.values()]
for k, v in headers.items():
if k.lower() in xhdrs:
headers.pop(k)
return status_code, headers, chunk
class ProxyRequestHandler(tornado.web.RequestHandler):
client = AsyncHTTPClient(max_buffer_size=1024*1024*150)
SUPPORTED_METHODS = tornado.web.RequestHandler.SUPPORTED_METHODS + ('PROPFIND', 'CONNECT')
SUPPORTED_ENCODINGS = ('gzip', 'x-gzip', 'identity', 'deflate', 'br')
def __init__(self, *args, **kwargs):
global pluginsloaded
self.logger = logger
self.options = options
self.origverbose = options['verbose']
self.logger.options.update(self.options)
self.plugins = pluginsloaded.get_plugins()
self.server_address, self.all_server_addresses = ProxyRequestHandler.get_ip()
for name, plugin in self.plugins.items():
plugin.logger = logger
super().__init__(*args, **kwargs)
def initialize(self, server_bind, server_port):
self.server_port = server_port
self.server_bind = server_bind
def set_default_headers(self):
if 'Server' in self._headers.keys():
self.set_header('Server', 'nginx')
@staticmethod
def get_ip():
all_addresses = sorted([f[4][0] for f in socket.getaddrinfo(socket.gethostname(), None)] + ['127.0.0.1'])
if '0.0.0.0' not in options['bind']:
out = urlparse(options['bind'])
if len(out.netloc) > 1: return out.netloc
else:
return (out.path.replace('/', ''), all_addresses)
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
# doesn't even have to be reachable
s.connect(('10.255.255.255', 1))
IP = s.getsockname()[0]
except:
IP = '127.0.0.1'
finally:
s.close()
return (IP, all_addresses)
def log_message(self, format, *args):
if (self.options['verbose'] or \
self.options['debug']) or (type(self.options['log']) == str and self.options['log'] != 'none'):
txt = "%s - - [%s] %s\n" % \
(self.address_string(),
self.log_date_time_string(),
format%args)
self.logger.out(txt, self.options['log'], '')
def log_error(self, format, *args):
# Surpress "Request timed out: timeout('timed out',)" if not in debug mode.
if isinstance(args[0], socket.timeout) and not self.options['debug']:
return
if not options['debug']:
return
self.log_message(format, *args)
def connectMethod(self):
if options['no_proxy']: return
self.logger.dbg(str(sslintercept))
if sslintercept.status:
self.connect_intercept()
else:
self.connect_relay()
def compute_etag(self):
return None
@staticmethod
def isValidRequest(req, req_body):
def readable(x):
return all(c in string.printable for c in x)
try:
if not readable(req.method): return False
if not readable(req.uri): return False
for k, v in req.headers.items():
if not readable(k): return False
if not readable(v): return False
except:
return False
return True
@staticmethod
def generate_ssl_certificate(hostname):
certpath = os.path.join(options['certdir'], hostname + '.crt')
stdout = stderr = ''
if not os.path.isfile(certpath):
self.logger.dbg('Generating valid SSL certificate for ({})...'.format(hostname))
epoch = "%d" % (time.time() * 1000)
# Workaround for the Windows' RANDFILE bug
if not 'RANDFILE' in os.environ.keys():
os.environ["RANDFILE"] = os.path.normpath("%s/.rnd" % (options['certdir'].rstrip('/')))
cmd = ["openssl", "req", "-new", "-key", options['certkey'], "-subj", "/CN=%s" % hostname]
cmd2 = ["openssl", "x509", "-req", "-days", "3650", "-CA", options['cacert'], "-CAkey", options['cakey'], "-set_serial", epoch, "-out", certpath]
try:
p1 = Popen(cmd, stdout=PIPE, stderr=PIPE)
p2 = Popen(cmd2, stdin=p1.stdout, stdout=PIPE, stderr=PIPE)
(stdout, stderr) = p2.communicate()
except FileNotFoundError as e:
logger.err("Can't serve HTTPS traffic because there is no 'openssl' tool installed on the system!")
return ''
else:
#logger.dbg('Using supplied SSL certificate: {}'.format(certpath))
pass
if not certpath or not os.path.isfile(certpath):
if stdout or stderr:
logger.err('Openssl x509 crt request failed:\n{}'.format((stdout + stderr).decode()))
logger.fatal('Could not create interception Certificate: "{}"'.format(certpath))
return ''
return certpath
def connect_intercept(self):
hostname = self.request.uri.split(':')[0]
certpath = ''
self.logger.dbg('CONNECT intercepted: "%s"' % self.request.uri)
certpath = ProxyRequestHandler.generate_ssl_certificate(hostname)
if not certpath:
self._set_status(500, 'Internal Server Error')
self._set_status(200, 'Connection Established')
try:
self.request.connection.stream = tornado.iostream.IOStream(ssl.wrap_socket(
self.request.connection.stream,
keyfile=self.options['certkey'],
certfile=certpath,
server_side=True
))
except:
self.logger.err('Connection reset by peer: "{}"'.format(self.request.uri))
self._send_error(502)
return
conntype = self.request.headers.get('Proxy-Connection', '')
if conntype.lower() == 'close':
self.request.connection.no_keep_alive = True
elif (conntype.lower() == 'keep-alive' and self.protocol_version >= "HTTP/1.1"):
self.request.connection.no_keep_alive = False
def connect_relay(self):
address = self.request.uri.split(':', 1)
address[1] = int(address[1]) or 443
self.logger.dbg('CONNECT relaying: "%s"' % self.request.uri)
try:
s = socket.create_connection(address, timeout=self.options['timeout'])
except Exception as e:
self.logger.err("Could not relay connection: ({})".format(str(e)))
self._send_error(502)
return
self._set_status(200, 'Connection Established')
conns = [self.request.connection.stream, s]
self.request.connection.no_keep_alive = False
while not self.close_connection:
rlist, wlist, xlist = select.select(conns, [], conns, self.options['timeout'])
if xlist or not rlist:
break
for r in rlist:
other = conns[1] if r is conns[0] else conns[0]
data = r.recv(8192)
if not data:
self.request.connection.no_keep_alive = True
break
other.sendall(data)
def reverse_proxy_loop_detected(self, command, fetchurl, req_body):
self.logger.err('[Reverse-proxy loop detected from peer {}] {} {}'.format(
self.client_address[0], command, fetchurl
))
self._send_error(500)
return
def my_handle_request(self, *args, **kwargs):
# http://httpd.apache.org/docs/current/logs.html#combined
# [day/month/year:hour:minute:second zone]
# day = 2*digit
# month = 3*letter
# year = 4*digit
# hour = 2*digit
# minute = 2*digit
# second = 2*digit
# zone = (`+' | `-') 4*digit
timestamp = datetime.utcnow()
if hasattr(self, 'request') and hasattr(self.request, 'uri') and type(self.request.uri) == str:
if self.request.uri.startswith('//'):
self.request.uri = self.request.uri[1:]
self._internal_my_handle_request(*args, **kwargs)
remote_host = self.request.client_address
if (type(self.request.client_address) == list or type(self.request.client_address) == tuple) and len(self.request.client_address) > 0:
remote_host = self.request.client_address[0]
user_identity = '-'
http_basic_auth = '-'
if len(self.request.headers.get('Authorization', '')) > 0:
auth = self.request.headers.get('Authorization', '').strip()
if auth.lower().startswith('basic '):
try:
auth_decoded = base64.b64decode(auth[len('Basic '):])
if ':' in auth_decoded:
http_basic_auth = auth_decoded.split(':')[0]
except:
pass
timezone = '+0000'
month = timestamp.strftime('%b')
request_timestamp = f'[{timestamp.day:02}/{month}/{timestamp.year:04}:{timestamp.hour:02}:{timestamp.minute:02}:{timestamp.second:02} {timezone}]'
request_line = f'{self.request.method} {self.request.uri} {self.request_version}'
http_referer = '-'
http_useragent = '-'
if len(self.request.headers.get('Referer', '')) > 0:
http_referer = self.request.headers.get('Referer', '-')
if len(self.request.headers.get('User-Agent', '')) > 0:
http_useragent = self.request.headers.get('User-Agent', '-')
line = ''
override_suppress_log = False
if 'access_log_format' in self.options.keys():
if self.options['access_log_format'] == None:
self.options['access_log_format'] = 'apache2'
if self.options['access_log_format'].lower() == 'apache2':
# src: https://httpd.apache.org/docs/2.4/logs.html
# LogFormat "%h %l %u %t \"%r\" %>s %b \"%{Referer}i\" \"%{User-agent}i\"" combined
line = f'{remote_host} {user_identity} {http_basic_auth} {request_timestamp} "{request_line}" {self.response_status} {self.response_length} "{http_referer}" "{http_useragent}"'
elif self.options['access_log_format'].lower() == 'redelk':
# src: https://github.com/outflanknl/RedELK/wiki/Redirector-installation#Apache%20specifics
# LogFormat "%t %{hostname}e apache[%P]: frontend:%{frontend_name}e/%A:%{local}p backend:%{backend_name}e client:%h:%{remote}p xforwardedfor:%{X-Forwarded-For}i headers:{%{User-Agent}i|%{Host}i|%{X-Forwarded-For}i|%{X-Forwarded-Proto}i|%{X-Host}i|%{Forwarded}i|%{Via}i|} statuscode:%s request:%r" redelklogformat
hostname = socket.gethostname()
pid = os.getpid()
frontend_name = 'http-redwarden'
backend_name_c2 = 'c2'
backend_name_decoy = 'decoy'
if 'redelk_frontend_name' in self.options.keys():
frontend_name = self.options['redelk_frontend_name']
if 'redelk_backend_name_c2' in self.options.keys():
backend_name_c2 = self.options['redelk_backend_name_c2']
if 'redelk_backend_name_decoy' in self.options.keys():
backend_name_decoy = self.options['redelk_backend_name_decoy']
local_address = socket.gethostbyname(socket.gethostname())
local_port = self.request.server_port
x_forwarded_for = self.request.headers.get('X-Forwarded-For', '-')
# %{User-Agent}i|%{Host}i|%{X-Forwarded-For}i|%{X-Forwarded-Proto}i|%{X-Host}i|%{Forwarded}i|%{Via}i|
hdr_host = self.request.headers.get('Host', '-')
hdr_xforwardedproto = self.request.headers.get('X-Forwarded-Proto', '-')
hdr_xhost = self.request.headers.get('X-Host', '-')
hdr_forwarded = self.request.headers.get('Forwarded', '-')
hdr_via = self.request.headers.get('Via', '-')
headers_string = f"{http_useragent}|{hdr_host}|{x_forwarded_for}|{hdr_xforwardedproto}|{hdr_xhost}|{hdr_forwarded}|{hdr_via}|"
try:
remote_host_port = self.request.connection.stream.socket.getpeername()[1]
except:
remote_host_port = 0
backend_name = backend_name_decoy
if self.request.redirected_to_c2:
backend_name = backend_name_c2
line = f'{request_timestamp} {hostname} apache[{pid}]: frontend:{frontend_name}/{local_address}:{local_port} backend:{backend_name} client:{remote_host}:{remote_host_port} xforwardedfor:{x_forwarded_for} headers:{{{headers_string}}} statuscode:{self.response_status} request:{request_line}'
override_suppress_log = True
if 'access_log' in self.options.keys() and self.options['access_log'] != None and len(self.options['access_log']) > 0:
if not self.request.suppress_log_entry or override_suppress_log:
with open(self.options['access_log'], 'a') as f:
f.write(line + '\n')
if not self.request.suppress_log_entry:
print(line)
def _internal_my_handle_request(self, *args, **kwargs):
handler = self._my_handle_request
if self.request.method.lower() == 'connect':
handler = self.connectMethod
self.headers = self.request.headers.copy()
self.request.headers = self.headers
self.request.method = self.request.method
self.request.client_address = [self.request.remote_ip,]
self.request.connection.no_keep_alive = False
self.request_version = 'HTTP/1.1'
self.request.server_port = self.server_port
self.request.server_bind = self.server_bind
self.request.suppress_log_entry = False
self.request.redirected_to_c2 = False
self.options['verbose'] = self.origverbose
self.response_status = 0
self.response_reason = ''
self.response_headers = {}
self.response_length = 0
if self.request.uri.startswith('http://') or self.request.uri.startswith('https://'):
newpath = ''
parsed = urlparse(self.request.uri)
if len(parsed.path) > 0:
newpath += parsed.path
if len(parsed.query) > 0:
newpath += '?' + parsed.query
if len(parsed.fragment) > 0:
newpath += '#' + parsed.fragment
self.request.uri = newpath
output = handler()
self.request.method = self.request.method
self.request.host = self.request.headers['Host']
def _send_error(self, code, msg = ''):
self.response_status = code
self.response_reason = ''
if len(msg) > 0:
self.response_reason = msg
else:
try:
self.response_reason = requests.status_codes._codes[code][0]
except:
pass
self.send_error(code)
def _set_status(self, code, msg = ''):
self.response_status = code
self.response_reason = ''
if len(msg) > 0:
self.response_reason = msg
else:
try:
self.response_reason = requests.status_codes._codes[code][0]
except:
pass
self.set_status(code, self.response_reason)
def on_finish(self):
if self.request.connection.no_keep_alive:
self.request.connection.stream.close()
def _my_handle_request(self):
if self.request.uri == self.options['proxy_self_url']:
self.logger.dbg('Sending CA certificate.')
self.send_cacert()
return
self.is_ssl = isinstance(self.request.connection.stream, tornado.iostream.SSLIOStream)
self.request.is_ssl = self.is_ssl
content_length = int(self.request.headers.get('Content-Length', 0))
if not self.options['allow_invalid']:
if not ProxyRequestHandler.isValidRequest(self.request, self.request.body):
self.logger.dbg('[DROP] Invalid HTTP request from: {}'.format(self.client_address[0]))
return
req_body_modified = ""
dont_fetch_response = False
res = None
res_body_plain = None
content_encoding = 'identity'
inbound_origin = self.request.host
outbound_origin = inbound_origin
origuri = self.request.uri
newuri = self.request.uri
originChanged = False
ignore_response_decompression_errors = False
if 'Host' not in self.request.headers.keys():
self.request.headers['Host'] = self.request.host
if 'throttle_down_peer_logging' in self.options.keys() and len(self.options['throttle_down_peer_logging']) > 0:
with SqliteDict(plugins.malleable_redirector.ProxyPlugin.DynamicWhitelistFile, autocommit=True) as mydict:
if 'peers' not in mydict.keys():
mydict['peers'] = {}
prev = mydict.get('peers', {})
peerIP = plugins.malleable_redirector.ProxyPlugin.get_peer_ip(self.request)
elapsed = 0
if peerIP not in mydict.get('peers', {}):
prev[peerIP] = {
'last': time.time(),
'count': 1
}
else:
last = prev[peerIP]['last']
elapsed = time.time() - last
if elapsed < 0: elapsed = 0
prev[peerIP]['count'] += 1
if int(elapsed) > int(self.options['throttle_down_peer_logging']['log_request_delay']):
prev[peerIP]['count'] = 1
self.request.suppress_log_entry = False
self.options['verbose'] = self.origverbose
if prev[peerIP]['count'] <= self.options['throttle_down_peer_logging']['requests_threshold']:
prev[peerIP]['last'] = time.time()
else:
self.request.suppress_log_entry = True
self.options['verbose'] = False
mydict['peers'] = prev
self.logger.dbg('Logging stats for peer {}: elapsed: {}, count: {}'.format(
peerIP, elapsed, prev[peerIP]['count']
), color = 'yellow')
self.logger.info('[REQUEST] {} {}'.format(self.request.method, self.request.uri), color=ProxyLogger.colors_map['green'])
self.save_handler(self.request, self.request.body, None, None)
try:
(modified, req_body_modified) = self.request_handler(self.request, self.request.body)
newuri = self.request.uri
if modified != None and type(modified) == bool:
modified |= (origuri != newuri)
if 'DropConnectionException' in str(type(req_body_modified)) or \
'DontFetchResponseException' in str(type(req_body_modified)):
raise req_body_modified
elif modified and req_body_modified is not None:
self.request.body = req_body_modified
if self.request.body != None:
reqhdrs = [x.lower() for x in self.request.headers.keys()]
if 'content-length' in reqhdrs: del self.request.headers['Content-Length']
self.request.headers['Content-length'] = str(len(self.request.body))
parsed = urlparse(newuri)
if parsed.netloc != inbound_origin and parsed.netloc != None and len(parsed.netloc) > 1:
self.logger.info('Plugin redirected request from [{}] to [{}]'.format(inbound_origin, parsed.netloc))
outbound_origin = parsed.netloc
originChanged = True
#newuri = (parsed.path + '?' + parsed.query if parsed.query else parsed.path)
except Exception as e:
if 'DropConnectionException' in str(e):
self.logger.err("Plugin demanded to drop the request: ({})".format(str(e)))
self.request.connection.no_keep_alive = True
return
elif 'DontFetchResponseException' in str(e):
dont_fetch_response = True
self.request.connection.no_keep_alive = True
res_body_plain = 'DontFetchResponseException'
class Response(object):
pass
res = Response()
else:
self.logger.err('Exception catched in request_handler: {}'.format(str(e)))
if options['debug']:
raise
req_path_full = ''
if not newuri: newuri = '/'
if newuri[0] == '/':
if self.is_ssl:
req_path_full = "https://%s%s" % (self.request.headers['Host'], newuri)
else:
req_path_full = "http://%s%s" % (self.request.headers['Host'], newuri)
elif newuri.startswith('http://') or newuri.startswith('https://'):
self.logger.dbg(f"Plugin redirected request to a full URL: ({newuri})")
req_path_full = newuri
parsed = urlparse(req_path_full)
newpath = ''
if len(parsed.path) > 0:
newpath = parsed.path
else:
newpath = '/'
if len(parsed.query) > 0:
newpath += '?' + parsed.query
if len(parsed.fragment) > 0:
newpath += '#' + parsed.fragment
req_path_full = parsed.scheme + '://' + parsed.netloc + newpath
if not req_path_full:
req_path_full = newuri
u = urlparse(req_path_full)
scheme, netloc, path = u.scheme, u.netloc, (u.path + '?' + u.query if u.query else u.path)
if netloc == '':
netloc = inbound_origin
origin = (scheme, inbound_origin)
neworigin = (scheme, netloc)
res_msg_hdrs = {}
class MyResponse(http.client.HTTPResponse):
def __init__(self, req, origreq):
self.headers = {}
self.msg = self.headers
self.response_version = origreq.protocol_version
if req != None:
self.status = req.status_code
self.headers = req.headers.copy()
self.reason = req.reason
else:
if hasattr(origreq, 'status'): self.status = origreq.status
else: self.status = 0
if hasattr(origreq, 'reason'): self.reason = origreq.reason
else: self.reason = ''
if hasattr(origreq, 'headers'): self.headers = origreq.headers.copy()
else: self.headers = {}
if not dont_fetch_response:
try:
assert scheme in ('http', 'https')
fetchurl = req_path_full.replace(netloc, outbound_origin)
ip = ''
try:
#ip = socket.gethostbyname(urlparse(fetchurl).netloc)
ip = socket.gethostbyname(outbound_origin)
except:
ip = urlparse(fetchurl).netloc
if not ip:
ip = outbound_origin
if ':' in ip:
ip = ip.split(':')[0]
if plugins.IProxyPlugin.proxy2_metadata_headers['ignore_response_decompression_errors'] in self.request.headers.keys():
ignore_response_decompression_errors = True
#if (ip == self.server_address or ip in self.all_server_addresses) or \
if (outbound_origin != '' and outbound_origin == inbound_origin) and \
(ip == self.server_address or ip in self.all_server_addresses):
self.logger.dbg(f'About to catch reverse-proxy loop detection: outbound_origin: {outbound_origin}, ip: {ip}, server_address: {self.server_address}')
return self.reverse_proxy_loop_detected(self.request.method, fetchurl, self.request.body)
reqhdrskeys = [x.lower() for x in self.request.headers.keys()]
if plugins.IProxyPlugin.proxy2_metadata_headers['override_host_header'].lower() in reqhdrskeys:
if 'Host' not in self.request.headers.keys():
self.request.headers['Host'] = self.request.host
self.set_header('Host', self.request.host)
o = self.request.headers['Host']
n = self.request.headers[plugins.IProxyPlugin.proxy2_metadata_headers['override_host_header']]
self.logger.dbg(f'Plugin overidden host header: [{o}] => [{n}]')
fetchurl = fetchurl.replace(o, n)
while 'Host' in self.request.headers.keys():
del self.request.headers['Host']
self.request.headers['Host'] = n
#ProxyRequestHandler.filter_headers(self.request.headers, self.logger)
req_body = ''
if self.request.body == None: req_body = ''
else:
try:
if type(self.request.body) == bytes:
req_body = self.request.body
else:
req_body = self.request.body.encode()
except UnicodeDecodeError:
pass
except AttributeError:
pass
new_req_headers = self.request.headers.copy()
#del new_req_headers['Host']
if plugins.IProxyPlugin.proxy2_metadata_headers['domain_front_host_header'] in new_req_headers.keys():
if 'Host' in new_req_headers.keys():
del new_req_headers['Host']
self.logger.dbg('Adjusting Host header for Domain-Fronting needs as requested by Plugin')
new_req_headers['Host'] = self.request.headers[plugins.IProxyPlugin.proxy2_metadata_headers['domain_front_host_header']]
fetchurl = req_path_full.replace(netloc, outbound_origin)
self.logger.dbg('DEBUG REQUESTS: request("{}", "{}", "{}", ...'.format(
self.request.method, fetchurl, req_body.decode(errors='ignore')[:30].strip()
))
myreq = None
try:
myreq = requests.request(
method = self.request.method,
url = fetchurl,
data = req_body,
headers = new_req_headers,
timeout = self.options['timeout'],
allow_redirects = False,
stream = ignore_response_decompression_errors,
verify = False
)
except Exception as e:
self.logger.err(f'COULD NOT FETCH RESPONSE FROM REMOTE AGENT: {e}')
if self.options['debug']:
raise
res = MyResponse(myreq, self)
self.response_headers = res.headers
res_msg_hdrs = res.msg.copy()
if len(res.msg) == 0 and len(res.headers) > 0:
res_msg_hdrs = res.headers.copy()
res_body = ""
if ignore_response_decompression_errors:
res_body = myreq.raw.read()
else:
res_body = myreq.content
self.logger.dbg("Response from reverse-proxy fetch came at {} bytes.".format(len(res_body)))
if 'Content-Length' not in res.headers.keys() and len(res_body) != 0:
res.headers['Content-Length'] = str(len(res_body))
self.response_length = len(res_body)
myreq.close()
if type(res_body) == str: res_body = str.encode(res_body)
except requests.exceptions.ConnectionError as e:
self.logger.err("Exception occured while reverse-proxy fetching resource : URL({}).\nException: ({})".format(
fetchurl, str(e)
))
self.request.connection.no_keep_alive = True
if res == None:
res = MyResponse(None, self)
self.response_headers = res.headers
self._send_error(502)
#if options['debug']: raise
return
except Exception as e:
self.logger.err("Could not proxy request: ({})".format(str(e)))
if 'RemoteDisconnected' in str(e) or 'Read timed out' in str(e):
return
if res == None:
res = MyResponse(None, self)
self.request.connection.no_keep_alive = True
self.response_headers = res.headers
self._send_error(502)
if options['debug']: raise
return
setattr(res, 'headers', res_msg_hdrs)
self.response_headers = res_msg_hdrs
content_encoding = res.headers.get('Content-Encoding', 'identity')
if ignore_response_decompression_errors:
content_encoding = 'identity'
res_body_plain = self.decode_content_body(res_body, content_encoding)
else:
self.logger.dbg("Response deliberately not fetched as for plugin's request.")
self.request.uri = origuri
(modified, res_body_modified) = self.response_handler(
self.request, self.request.body, res, res_body_plain
)
newuri = self.request.uri
self.request.uri = origuri
if type(modified) == bool:
modified |= (newuri != origuri)
if modified:
self.logger.dbg('Plugin has modified the response body. Using it instead')
res_body_plain = res_body_modified
res_body = self.encode_content_body(res_body_plain, content_encoding)
reshdrs = [x.lower() for x in res.headers.keys()]
if 'content-length' in reshdrs: del res.headers['Content-Length']
try:
res.headers['Content-Length'] = str(len(res_body))
self.response_length = len(res_body)
except Exception as e:
self.logger.dbg("Could not set proper Content-Length as running len on res_body failed")
if 'Transfer-Encoding' in res.headers.keys() and \
res.headers['Transfer-Encoding'] == 'chunked':
del res.headers['Transfer-Encoding']
if (not ignore_response_decompression_errors) and ('Accept-Encoding' in self.request.headers.keys()):
encToUse = self.request.headers['Accept-Encoding'].split(' ')[0].replace(',','')
override_resp_enc = plugins.IProxyPlugin.proxy2_metadata_headers['override_response_content_encoding']
if override_resp_enc in res.headers.keys():
self.logger.dbg('Plugin asked to override response content encoding without changing header\'s value.')
self.logger.dbg('Yielding content in {} whereas header pointing at: {}'.format(
res.headers[override_resp_enc], self.request.headers['Accept-Encoding']))
enc = res.headers[override_resp_enc]
del res.headers[override_resp_enc]
else:
encs = [x.strip() for x in self.request.headers['Accept-Encoding'].split(',')]
if content_encoding not in encs:
reencoded = False
for enc in encs:
if enc in ProxyRequestHandler.SUPPORTED_ENCODINGS:
encToUse = encs[0]
reencoded = True
break
if not reencoded:
self.logger.err("Server returned response encoded in {} but client expected one of: {} - we couldn't handle any of these. The client will receive INCORRECTLY formatted response!".format(
res.headers.get('Content-Encoding', 'identity'),
','.join(encs)
))
self.logger.dbg('Encoding response body to: {}'.format(encToUse))
res_body = self.encode_content_body(res_body, encToUse)
reskeys = [x.lower() for x in res.headers.keys()]
if 'content-length' in reskeys: del res.headers['Content-Length']
if 'content-encoding' in reskeys: del res.headers['Content-Encoding']
res.headers['Content-Length'] = str(len(res_body))
self.response_length = len(res_body)
res.headers['Content-Encoding'] = encToUse
if 'DropConnectionException' in str(res_body) or 'DontFetchResponseException' in str(res_body):
res_body = ''
if type(res_body) == str: res_body = str.encode(res_body)
#ProxyRequestHandler.filter_headers(res.headers, self.logger)
res_headers = res.headers
self.response_status = res.status
self.response_reason = res.reason
self.response_headers = res.headers
ka = 'no' if self.request.connection.no_keep_alive else 'yes'
if plugins.IProxyPlugin.proxy2_metadata_headers['keep_alive_this_connection'] in res.headers.keys():
self.request.connection.no_keep_alive = False
ka = 'yes'
self.logger.info('[RESPONSE] HTTP {} {}, length: {}, keep-alive: {}'.format(res.status, res.reason, len(res_body), ka), color=ProxyLogger.colors_map['yellow'])
self._set_status(res.status, res.reason)
for k, v in res_headers.items():
if k in plugins.IProxyPlugin.proxy2_metadata_headers.values(): continue
self.set_header(k, v)
try:
self.response_length = len(res_body)
self.write(res_body)
if options['debug']:
if originChanged:
del self.request.headers['Host']
if plugins.IProxyPlugin.proxy2_metadata_headers['override_host_header'] in self.request.headers.keys():
outbound_origin = self.request.headers[plugins.IProxyPlugin.proxy2_metadata_headers['override_host_header']]
self.request.headers['Host'] = outbound_origin
self.save_handler(self.request, self.request.body, res, res_body_plain)
except BrokenPipeError as e:
self.logger.err("Broken pipe. Client must have disconnected/timed-out.")
@staticmethod
def filter_headers(headers, logger):
# http://tools.ietf.org/html/rfc2616#section-13.5.1
hop_by_hop = (
'connection',
'keep-alive',
'proxy-authenticate',
'proxy-authorization',
'te',
'trailers',
'transfer-encoding',
'upgrade'
)
for k in hop_by_hop:
if k in headers.keys():
del headers[k]
return headers
def encode_content_body(self, text, encoding):
logger.dbg('Encoding content to {}'.format(encoding))
data = text
if encoding == 'identity':
pass
elif encoding in ('gzip', 'x-gzip'):
_io = BytesIO()
with gzip.GzipFile(fileobj=_io, mode='wb') as f:
f.write(text)
data = _io.getvalue()
elif encoding == 'deflate':
data = zlib.compress(text)
elif encoding == 'br':
# Brotli algorithm
try:
data = brotli.compress(text)
except Exception as e:
#raise Exception('Could not compress Brotli stream: "{}"'.format(str(e)))
logger.err('Could not compress Brotli stream: "{}"'.format(str(e)))
else:
#raise Exception("Unknown Content-Encoding: %s" % encoding)
logger.err('Unknown Content-Encoding: "{}"'.format(encoding))
return data
def decode_content_body(self, data, encoding):
self.logger.dbg('Decoding content from {}'.format(encoding))
text = data
if encoding == 'identity':
pass
elif encoding in ('gzip', 'x-gzip'):
try:
_io = BytesIO(data)
with gzip.GzipFile(fileobj=_io) as f:
text = f.read()
except:
return data
elif encoding == 'deflate':
try:
text = zlib.decompress(data)
except zlib.error:
text = zlib.decompress(data, -zlib.MAX_WBITS)
elif encoding == 'br':
# Brotli algorithm
try:
text = brotli.decompress(data)
except Exception as e:
#raise Exception('Could not decompress Brotli stream: "{}"'.format(str(e)))
self.logger.err('Could not decompress Brotli stream: "{}"'.format(str(e)))
else:
#raise Exception("Unknown Content-Encoding: %s" % encoding)
self.logger.err('Unknown Content-Encoding: "{}"'.format(encoding))
return text
def send_cacert(self):
with open(self.options['cacert'], 'rb') as f:
data = f.read()
self._set_status(200)
self.set_header('Content-Type', 'application/x-x509-ca-cert')
self.set_header('Content-Length', len(data))
self.set_header('Connection', 'close')
self.response_length = len(data)
self.write(data)
def print_info(self, req, req_body, res, res_body):
def _parse_qsl(s):
return '\n'.join("%-20s %s" % (k, v) for k, v in parse_qsl(s, keep_blank_values=True))
if not options['debug']:
return
req_header_text = "%s %s %s\n%s" % (req.method, req.uri, self.request_version, req.headers)
if res is not None:
reshdrs = res.headers
if type(reshdrs) == dict or 'CaseInsensitiveDict' in str(type(reshdrs)):
reshdrs = ''
for k, v in res.headers.items():
if k in plugins.IProxyPlugin.proxy2_metadata_headers.values(): continue
if k.lower().startswith('x-proxy2-'): continue
reshdrs += '{}: {}\n'.format(k, v)
res_header_text = "%s %d %s\n%s" % (res.response_version, res.status, res.reason, reshdrs)
self.logger.trace("==== REQUEST ====\n%s" % req_header_text, color=ProxyLogger.colors_map['yellow'])
u = urlparse(req.uri)
if u.query:
query_text = _parse_qsl(u.query)
self.logger.trace("==== QUERY PARAMETERS ====\n%s\n" % query_text, color=ProxyLogger.colors_map['green'])
cookie = req.headers.get('Cookie', '')
if cookie:
cookie = _parse_qsl(re.sub(r';\s*', '&', cookie))
self.logger.trace("==== COOKIES ====\n%s\n" % cookie, color=ProxyLogger.colors_map['green'])
auth = req.headers.get('Authorization', '')
if auth.lower().startswith('basic'):
token = auth.split()[1].decode('base64')
self.logger.trace("==== BASIC AUTH ====\n%s\n" % token, color=ProxyLogger.colors_map['red'])
if req_body is not None:
req_body_text = None
content_type = req.headers.get('Content-Type', '')
if content_type.startswith('application/x-www-form-urlencoded'):
req_body_text = _parse_qsl(req_body)
elif content_type.startswith('application/json'):
try:
json_obj = json.loads(req_body)
json_str = json.dumps(json_obj, indent=2)
if json_str.count('\n') < 50:
req_body_text = json_str
else:
lines = json_str.splitlines()
req_body_text = "%s\n(%d lines)" % ('\n'.join(lines[:50]), len(lines))
except ValueError:
req_body_text = req_body
elif len(req_body) < 1024:
req_body_text = req_body
if req_body_text:
self.logger.trace("==== REQUEST BODY ====\n%s\n" % req_body_text.strip(), color=ProxyLogger.colors_map['white'])
if res is not None:
self.logger.trace("\n==== RESPONSE ====\n%s" % res_header_text, color=ProxyLogger.colors_map['cyan'])
cookies = res.headers.get('Set-Cookie')
if cookies:
if type(cookies) == list or type(cookies) == tuple:
cookies = '\n'.join(cookies)
self.logger.trace("==== SET-COOKIE ====\n%s\n" % cookies, color=ProxyLogger.colors_map['yellow'])
if res_body is not None:
res_body_text = res_body
content_type = res.headers.get('Content-Type', '')
if content_type.startswith('application/json'):
try:
json_obj = json.loads(res_body)
json_str = json.dumps(json_obj, indent=2)
if json_str.count('\n') < 50:
res_body_text = json_str
else:
lines = json_str.splitlines()
res_body_text = "%s\n(%d lines)" % ('\n'.join(lines[:50]), len(lines))
except ValueError:
res_body_text = res_body
elif content_type.startswith('text/html'):
if type(res_body) == str: res_body = str.encode(res_body)
m = re.search(r'<title[^>]*>\s*([^<]+?)\s*</title>', res_body.decode(errors='ignore'), re.I)
if m:
self.logger.trace("==== HTML TITLE ====\n%s\n" % html.unescape(m.group(1)), color=ProxyLogger.colors_map['cyan'])
elif content_type.startswith('text/') and len(res_body) < 1024:
res_body_text = res_body
if res_body_text:
res_body_text2 = ''
maxchars = 4096
halfmax = int(maxchars/2)
try:
if type(res_body_text) == bytes:
dec = res_body_text.decode()
else:
dec = res_body_text
if dec != None and len(dec) > maxchars:
res_body_text2 = dec[:halfmax] + ' <<< ... >>> ' + dec[-halfmax:]
else:
res_body_text2 = dec
except UnicodeDecodeError:
if len(res_body_text) > maxchars:
res_body_text2 = hexdump(list(res_body_text[:halfmax]))
res_body_text2 += '\n\t................\n'
res_body_text2 += hexdump(list(res_body_text[-halfmax:]))
else:
res_body_text2 = hexdump(list(res_body_text))
self.logger.trace("==== RESPONSE BODY ====\n%s\n" % res_body_text2, color=ProxyLogger.colors_map['green'])
def request_handler(self, req, req_body):
altered = False
req_body_current = req_body
for plugin_name in self.plugins:
instance = self.plugins[plugin_name]
try:
handler = getattr(instance, 'request_handler')
#self.logger.dbg("Calling `request_handler' from plugin %s" % plugin_name)
origheaders = dict(req.headers).copy()
req_body_current = handler(req, req_body_current)
altered = (req_body != req_body_current and req_body_current is not None)
if req_body_current == None: req_body_current = req_body
for k, v in origheaders.items():
if k not in req.headers.keys():
altered = True
elif origheaders[k] != req.headers[k]:
#self.logger.dbg('Plugin modified request header: "{}", from: "{}" to: "{}"'.format(
# k, origheaders[k], req.headers[k]))
altered = True
except AttributeError as e:
if 'object has no attribute' in str(e):
self.logger.dbg('Plugin "{}" does not implement `request_handler\''.format(plugin_name))
if options['debug']:
raise
else:
self.logger.err("Plugin {} has thrown an exception: '{}'".format(plugin_name, str(e)))
if options['debug']:
raise
return (altered, req_body_current)
def response_handler(self, req, req_body, res, res_body):
res_body_current = res_body
altered = False
for plugin_name in self.plugins:
instance = self.plugins[plugin_name]
try:
handler = getattr(instance, 'response_handler')
#self.logger.dbg("Calling `response_handler' from plugin %s" % plugin_name)
origheaders = {}
try:
origheaders = res.headers.copy()
except: pass
res_body_current = handler(req, req_body, res, res_body_current)
altered = (res_body_current != res_body)
for k, v in origheaders.items():
if origheaders[k] != res.headers[k]:
#self.logger.dbg('Plugin modified response header: "{}", from: "{}" to: "{}"'.format(
# k, origheaders[k], res.headers[k]))
altered = True
if len(origheaders.keys()) != len(res.headers.keys()):
#self.logger.dbg('Plugin modified response headers.')
altered = True
if altered:
self.logger.dbg('Plugin has altered the response.')
except AttributeError as e:
raise
if 'object has no attribute' in str(e):
self.logger.dbg('Plugin "{}" does not implement `response_handler\''.format(plugin_name))
else:
self.logger.err("Plugin {} has thrown an exception: '{}'".format(plugin_name, str(e)))
if options['debug']:
raise
if not altered:
return (False, res_body)
return (True, res_body_current)
def save_handler(self, req, req_body, res, res_body):
self.print_info(req, req_body, res, res_body)
async def get(self, *args, **kwargs):
self.my_handle_request()
async def post(self, *args, **kwargs):
self.my_handle_request()
async def head(self, *args, **kwargs):
self.my_handle_request()
async def options(self, *args, **kwargs):
self.my_handle_request()
async def put(self, *args, **kwargs):
self.my_handle_request()
async def delete(self, *args, **kwargs):
self.my_handle_request()
async def patch(self, *args, **kwargs):
self.my_handle_request()
async def propfind(self, *args, **kwargs):
self.my_handle_request()
async def connect(self, *args, **kwargs):
self.my_handle_request()
def init(opts, VERSION):
global options
global pluginsloaded
global logger
global sslintercept
options = opts.copy()
lib.optionsparser.parse_options(options, VERSION)
logger = ProxyLogger(options)
pluginsloaded = PluginsLoader(logger, options)
sslintercept = SSLInterception(logger, options)
if options['log'] and options['log'] != None and options['log'] != sys.stdout:
if options['tee']:
logger.info("Teeing stdout output to {} log file.".format(options['log']))
else:
logger.info("Writing output to {} log file.".format(options['log']))
monkeypatching(logger)
for name, plugin in pluginsloaded.get_plugins().items():
plugin.logger = logger
plugin.help(None)
return (options, logger)
def cleanup():
global options
# Close logging file descriptor unless it's stdout
#if options['log'] and options['log'] not in (sys.stdout, 'none'):
# options['log'].close()
# options['log'] = None
if sslintercept:
sslintercept.cleanup()