aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMaximilian Hils <git@maximilianhils.com>2016-11-23 22:47:50 +0100
committerMaximilian Hils <git@maximilianhils.com>2016-11-23 22:47:50 +0100
commit222106916e8c57ed9ab83f7c0c8d8a76d53ab298 (patch)
tree743898632b9f2b1d83d8a05a6725ab3311ae3ca9
parentd15ddfad14052b7fd971f3b82bbb86e7ec39c40b (diff)
parent45332006a3da246679e6043b4abee06cd3ba0636 (diff)
downloadmitmproxy-222106916e8c57ed9ab83f7c0c8d8a76d53ab298.tar.gz
mitmproxy-222106916e8c57ed9ab83f7c0c8d8a76d53ab298.tar.bz2
mitmproxy-222106916e8c57ed9ab83f7c0c8d8a76d53ab298.zip
Merge branch 'mitmweb-eventlog'
-rw-r--r--examples/complex/dup_and_replay.py3
-rw-r--r--mitmproxy/addons/eventstore.py19
-rw-r--r--mitmproxy/addons/view.py7
-rw-r--r--mitmproxy/contentviews.py2
-rw-r--r--mitmproxy/io.py10
-rw-r--r--mitmproxy/master.py10
-rw-r--r--mitmproxy/tools/console/flowlist.py6
-rw-r--r--mitmproxy/tools/console/flowview.py6
-rw-r--r--mitmproxy/tools/console/master.py2
-rw-r--r--mitmproxy/tools/main.py1
-rw-r--r--mitmproxy/tools/web/app.py266
-rw-r--r--mitmproxy/tools/web/master.py125
-rw-r--r--test/mitmproxy/addons/test_evenstore.py32
-rw-r--r--test/mitmproxy/data/addonscripts/duplicate_flow.py6
-rw-r--r--test/mitmproxy/test_web_app.py251
-rw-r--r--web/src/js/components/ContentView/ContentViewOptions.jsx8
-rw-r--r--web/src/js/components/ContentView/ContentViews.jsx4
-rw-r--r--web/src/js/components/ContentView/ViewSelector.jsx2
-rw-r--r--web/src/js/components/EventLog.jsx2
-rw-r--r--web/src/js/components/Footer.jsx6
-rw-r--r--web/src/js/components/Header/OptionMenu.jsx8
-rw-r--r--web/src/js/ducks/eventLog.js4
22 files changed, 505 insertions, 275 deletions
diff --git a/examples/complex/dup_and_replay.py b/examples/complex/dup_and_replay.py
index bf7c2a4e..2baa1ea6 100644
--- a/examples/complex/dup_and_replay.py
+++ b/examples/complex/dup_and_replay.py
@@ -2,6 +2,7 @@ from mitmproxy import ctx
def request(flow):
- f = ctx.master.state.duplicate_flow(flow)
+ f = flow.copy()
+ ctx.master.view.add(f)
f.request.path = "/changed"
ctx.master.replay_request(f, block=True)
diff --git a/mitmproxy/addons/eventstore.py b/mitmproxy/addons/eventstore.py
new file mode 100644
index 00000000..4e410c98
--- /dev/null
+++ b/mitmproxy/addons/eventstore.py
@@ -0,0 +1,19 @@
+from typing import List # noqa
+
+import blinker
+from mitmproxy.log import LogEntry
+
+
+class EventStore:
+ def __init__(self):
+ self.data = [] # type: List[LogEntry]
+ self.sig_add = blinker.Signal()
+ self.sig_refresh = blinker.Signal()
+
+ def log(self, entry: LogEntry):
+ self.data.append(entry)
+ self.sig_add.send(self, entry=entry)
+
+ def clear(self):
+ self.data.clear()
+ self.sig_refresh.send(self)
diff --git a/mitmproxy/addons/view.py b/mitmproxy/addons/view.py
index e151e081..b8b6093f 100644
--- a/mitmproxy/addons/view.py
+++ b/mitmproxy/addons/view.py
@@ -280,6 +280,13 @@ class View(collections.Sequence):
# The value was not in the view
pass
+ def get_by_id(self, flow_id: str) -> typing.Optional[mitmproxy.flow.Flow]:
+ """
+ Get flow with the given id from the store.
+ Returns None if the flow is not found.
+ """
+ return self._store.get(flow_id)
+
# Event handlers
def configure(self, opts, updated):
if "filter" in updated:
diff --git a/mitmproxy/contentviews.py b/mitmproxy/contentviews.py
index f6a2262c..ef0c80e0 100644
--- a/mitmproxy/contentviews.py
+++ b/mitmproxy/contentviews.py
@@ -609,7 +609,7 @@ def get_message_content_view(viewname, message):
"""
viewmode = get(viewname)
if not viewmode:
- get("auto")
+ viewmode = get("auto")
try:
content = message.content
except ValueError:
diff --git a/mitmproxy/io.py b/mitmproxy/io.py
index ad2f00c4..780955a4 100644
--- a/mitmproxy/io.py
+++ b/mitmproxy/io.py
@@ -1,6 +1,8 @@
import os
+from typing import Iterable
from mitmproxy import exceptions
+from mitmproxy import flow
from mitmproxy import flowfilter
from mitmproxy import http
from mitmproxy import tcp
@@ -29,7 +31,7 @@ class FlowReader:
def __init__(self, fo):
self.fo = fo
- def stream(self):
+ def stream(self) -> Iterable[flow.Flow]:
"""
Yields Flow objects from the dump.
"""
@@ -54,10 +56,10 @@ class FilteredFlowWriter:
self.fo = fo
self.flt = flt
- def add(self, flow):
- if self.flt and not flowfilter.match(self.flt, flow):
+ def add(self, f: flow.Flow):
+ if self.flt and not flowfilter.match(self.flt, f):
return
- d = flow.get_state()
+ d = f.get_state()
tnetstring.dump(d, self.fo)
diff --git a/mitmproxy/master.py b/mitmproxy/master.py
index 7f114096..7581d816 100644
--- a/mitmproxy/master.py
+++ b/mitmproxy/master.py
@@ -156,7 +156,7 @@ class Master:
for e, o in events.event_sequence(f):
getattr(self, e)(o)
- def load_flows(self, fr):
+ def load_flows(self, fr: io.FlowReader) -> int:
"""
Load flows from a FlowReader object.
"""
@@ -166,7 +166,7 @@ class Master:
self.load_flow(i)
return cnt
- def load_flows_file(self, path):
+ def load_flows_file(self, path: str) -> int:
path = os.path.expanduser(path)
try:
if path == "-":
@@ -180,7 +180,11 @@ class Master:
except IOError as v:
raise exceptions.FlowReadException(v.strerror)
- def replay_request(self, f, block=False):
+ def replay_request(
+ self,
+ f: http.HTTPFlow,
+ block: bool=False
+ ) -> http_replay.RequestReplayThread:
"""
Replay a HTTP request to receive a new response from the server.
diff --git a/mitmproxy/tools/console/flowlist.py b/mitmproxy/tools/console/flowlist.py
index 395a9d52..d7c312e5 100644
--- a/mitmproxy/tools/console/flowlist.py
+++ b/mitmproxy/tools/console/flowlist.py
@@ -303,8 +303,8 @@ class FlowListWalker(urwid.ListWalker):
class FlowListBox(urwid.ListBox):
- def __init__(self, master):
- self.master = master
+ def __init__(self, master: "mitmproxy.tools.console.master.ConsoleMaster"):
+ self.master = master # type: "mitmproxy.tools.console.master.ConsoleMaster"
super().__init__(FlowListWalker(master))
def get_method_raw(self, k):
@@ -348,7 +348,7 @@ class FlowListBox(urwid.ListBox):
if key == "A":
for f in self.master.view:
if f.intercepted:
- f.resume()
+ f.resume(self.master)
signals.flowlist_change.send(self)
elif key == "z":
self.master.view.clear()
diff --git a/mitmproxy/tools/console/flowview.py b/mitmproxy/tools/console/flowview.py
index e2b24fab..ecb070d8 100644
--- a/mitmproxy/tools/console/flowview.py
+++ b/mitmproxy/tools/console/flowview.py
@@ -510,8 +510,10 @@ class FlowView(tabs.Tabs):
self.flow.resume(self.master)
signals.flow_change.send(self, flow = self.flow)
elif key == "A":
- self.master.accept_all()
- signals.flow_change.send(self, flow = self.flow)
+ for f in self.view:
+ if f.intercepted:
+ f.resume(self.master)
+ signals.flow_change.send(self, flow=f)
elif key == "d":
if self.flow.killable:
self.flow.kill(self.master)
diff --git a/mitmproxy/tools/console/master.py b/mitmproxy/tools/console/master.py
index 184038ef..5d0e0ef4 100644
--- a/mitmproxy/tools/console/master.py
+++ b/mitmproxy/tools/console/master.py
@@ -71,7 +71,7 @@ class ConsoleMaster(master.Master):
def __init__(self, options, server):
super().__init__(options, server)
- self.view = view.View()
+ self.view = view.View() # type: view.View
self.stream_path = None
# This line is just for type hinting
self.options = self.options # type: Options
diff --git a/mitmproxy/tools/main.py b/mitmproxy/tools/main.py
index 69dd3791..478690eb 100644
--- a/mitmproxy/tools/main.py
+++ b/mitmproxy/tools/main.py
@@ -135,7 +135,6 @@ def mitmweb(args=None): # pragma: no cover
web_options.wdebug = args.wdebug
web_options.wiface = args.wiface
web_options.wport = args.wport
- web_options.process_web_options(parser)
server = process_options(parser, web_options, args)
m = web.master.WebMaster(web_options, server)
diff --git a/mitmproxy/tools/web/app.py b/mitmproxy/tools/web/app.py
index 25a46169..f617bd08 100644
--- a/mitmproxy/tools/web/app.py
+++ b/mitmproxy/tools/web/app.py
@@ -1,5 +1,3 @@
-
-import base64
import hashlib
import json
import logging
@@ -7,19 +5,21 @@ import os.path
import re
from io import BytesIO
+import mitmproxy.addons.view
+import mitmproxy.flow
+import tornado.escape
import tornado.web
import tornado.websocket
-import tornado.escape
from mitmproxy import contentviews
+from mitmproxy import exceptions
from mitmproxy import flowfilter
from mitmproxy import http
from mitmproxy import io
+from mitmproxy import log
from mitmproxy import version
-import mitmproxy.addons.view
-import mitmproxy.flow
-def convert_flow_to_json_dict(flow: mitmproxy.flow.Flow) -> dict:
+def flow_to_json(flow: mitmproxy.flow.Flow) -> dict:
"""
Remove flow message content and cert to save transmission space.
@@ -46,8 +46,10 @@ def convert_flow_to_json_dict(flow: mitmproxy.flow.Flow) -> dict:
"path": flow.request.path,
"http_version": flow.request.http_version,
"headers": tuple(flow.request.headers.items(True)),
- "contentLength": len(flow.request.raw_content) if flow.request.raw_content is not None else None,
- "contentHash": hashlib.sha256(flow.request.raw_content).hexdigest() if flow.request.raw_content is not None else None,
+ "contentLength": len(
+ flow.request.raw_content) if flow.request.raw_content is not None else None,
+ "contentHash": hashlib.sha256(
+ flow.request.raw_content).hexdigest() if flow.request.raw_content is not None else None,
"timestamp_start": flow.request.timestamp_start,
"timestamp_end": flow.request.timestamp_end,
"is_replay": flow.request.is_replay,
@@ -58,8 +60,10 @@ def convert_flow_to_json_dict(flow: mitmproxy.flow.Flow) -> dict:
"status_code": flow.response.status_code,
"reason": flow.response.reason,
"headers": tuple(flow.response.headers.items(True)),
- "contentLength": len(flow.response.raw_content) if flow.response.raw_content is not None else None,
- "contentHash": hashlib.sha256(flow.response.raw_content).hexdigest() if flow.response.raw_content is not None else None,
+ "contentLength": len(
+ flow.response.raw_content) if flow.response.raw_content is not None else None,
+ "contentHash": hashlib.sha256(
+ flow.response.raw_content).hexdigest() if flow.response.raw_content is not None else None,
"timestamp_start": flow.response.timestamp_start,
"timestamp_end": flow.response.timestamp_end,
"is_replay": flow.response.is_replay,
@@ -69,34 +73,19 @@ def convert_flow_to_json_dict(flow: mitmproxy.flow.Flow) -> dict:
return f
-class APIError(tornado.web.HTTPError):
- pass
-
-
-class BasicAuth:
-
- def set_auth_headers(self):
- self.set_status(401)
- self.set_header('WWW-Authenticate', 'Basic realm=MITMWeb')
- self._transforms = []
- self.finish()
+def logentry_to_json(e: log.LogEntry) -> dict:
+ return {
+ "id": id(e), # we just need some kind of id.
+ "message": e.msg,
+ "level": e.level
+ }
- def prepare(self):
- wauthenticator = self.application.settings['wauthenticator']
- if wauthenticator:
- auth_header = self.request.headers.get('Authorization')
- if auth_header is None or not auth_header.startswith('Basic '):
- self.set_auth_headers()
- else:
- auth_decoded = base64.decodebytes(auth_header[6:])
- username, password = auth_decoded.split(':', 2)
- if not wauthenticator.test(username, password):
- self.set_auth_headers()
- raise APIError(401, "Invalid username or password.")
+class APIError(tornado.web.HTTPError):
+ pass
-class RequestHandler(BasicAuth, tornado.web.RequestHandler):
+class RequestHandler(tornado.web.RequestHandler):
def write(self, chunk):
# Writing arrays on the top level is ok nowadays.
# http://flask.pocoo.org/docs/0.11/security/#json-security
@@ -120,9 +109,23 @@ class RequestHandler(BasicAuth, tornado.web.RequestHandler):
@property
def json(self):
- if not self.request.headers.get("Content-Type").startswith("application/json"):
- return None
- return json.loads(self.request.body.decode())
+ if not self.request.headers.get("Content-Type", "").startswith("application/json"):
+ raise APIError(400, "Invalid Content-Type, expected application/json.")
+ try:
+ return json.loads(self.request.body.decode())
+ except Exception as e:
+ raise APIError(400, "Malformed JSON: {}".format(str(e)))
+
+ @property
+ def filecontents(self):
+ """
+ Accept either a multipart/form file upload or just take the plain request body.
+
+ """
+ if self.request.files:
+ return next(iter(self.request.files.values()))[0].body
+ else:
+ return self.request.body
@property
def view(self) -> mitmproxy.addons.view.View:
@@ -136,11 +139,11 @@ class RequestHandler(BasicAuth, tornado.web.RequestHandler):
def flow(self) -> mitmproxy.flow.Flow:
flow_id = str(self.path_kwargs["flow_id"])
# FIXME: Add a facility to addon.view to safely access the store
- flow = self.view._store.get(flow_id)
+ flow = self.view.get_by_id(flow_id)
if flow:
return flow
else:
- raise APIError(400, "Flow not found.")
+ raise APIError(404, "Flow not found.")
def write_error(self, status_code: int, **kwargs):
if "exc_info" in kwargs and isinstance(kwargs["exc_info"][1], APIError):
@@ -150,7 +153,6 @@ class RequestHandler(BasicAuth, tornado.web.RequestHandler):
class IndexHandler(RequestHandler):
-
def get(self):
token = self.xsrf_token # https://github.com/tornadoweb/tornado/issues/645
assert token
@@ -158,14 +160,13 @@ class IndexHandler(RequestHandler):
class FilterHelp(RequestHandler):
-
def get(self):
self.write(dict(
commands=flowfilter.help
))
-class WebSocketEventBroadcaster(BasicAuth, tornado.websocket.WebSocketHandler):
+class WebSocketEventBroadcaster(tornado.websocket.WebSocketHandler):
# raise an error if inherited class doesn't specify its own instance.
connections = None # type: set
@@ -182,7 +183,7 @@ class WebSocketEventBroadcaster(BasicAuth, tornado.websocket.WebSocketHandler):
for conn in cls.connections:
try:
conn.write_message(message)
- except Exception:
+ except Exception: # pragma: no cover
logging.error("Error sending message", exc_info=True)
@@ -191,9 +192,8 @@ class ClientConnection(WebSocketEventBroadcaster):
class Flows(RequestHandler):
-
def get(self):
- self.write([convert_flow_to_json_dict(f) for f in self.view])
+ self.write([flow_to_json(f) for f in self.view])
class DumpFlows(RequestHandler):
@@ -211,33 +211,29 @@ class DumpFlows(RequestHandler):
def post(self):
self.view.clear()
-
- content = self.request.files.values()[0][0].body
- bio = BytesIO(content)
- self.master.load_flows(io.FlowReader(bio).stream())
+ bio = BytesIO(self.filecontents)
+ self.master.load_flows(io.FlowReader(bio))
bio.close()
class ClearAll(RequestHandler):
-
def post(self):
self.view.clear()
+ self.master.events.clear()
class AcceptFlows(RequestHandler):
-
def post(self):
- self.master.accept_all(self.master)
+ for f in self.view:
+ f.resume(self.master)
class AcceptFlow(RequestHandler):
-
def post(self, flow_id):
self.flow.resume(self.master)
class FlowHandler(RequestHandler):
-
def delete(self, flow_id):
if self.flow.killable:
self.flow.kill(self.master)
@@ -246,75 +242,78 @@ class FlowHandler(RequestHandler):
def put(self, flow_id):
flow = self.flow
flow.backup()
- for a, b in self.json.items():
- if a == "request" and hasattr(flow, "request"):
- request = flow.request
- for k, v in b.items():
- if k in ["method", "scheme", "host", "path", "http_version"]:
- setattr(request, k, str(v))
- elif k == "port":
- request.port = int(v)
- elif k == "headers":
- request.headers.clear()
- for header in v:
- request.headers.add(*header)
- elif k == "content":
- request.text = v
- else:
- print("Warning: Unknown update {}.{}: {}".format(a, k, v))
-
- elif a == "response" and hasattr(flow, "response"):
- response = flow.response
- for k, v in b.items():
- if k == "msg":
- response.msg = str(v)
- elif k == "code":
- response.status_code = int(v)
- elif k == "http_version":
- response.http_version = str(v)
- elif k == "headers":
- response.headers.clear()
- for header in v:
- response.headers.add(*header)
- elif k == "content":
- response.text = v
- else:
- print("Warning: Unknown update {}.{}: {}".format(a, k, v))
- else:
- print("Warning: Unknown update {}: {}".format(a, b))
+ try:
+ for a, b in self.json.items():
+ if a == "request" and hasattr(flow, "request"):
+ request = flow.request
+ for k, v in b.items():
+ if k in ["method", "scheme", "host", "path", "http_version"]:
+ setattr(request, k, str(v))
+ elif k == "port":
+ request.port = int(v)
+ elif k == "headers":
+ request.headers.clear()
+ for header in v:
+ request.headers.add(*header)
+ elif k == "content":
+ request.text = v
+ else:
+ raise APIError(400, "Unknown update request.{}: {}".format(k, v))
+
+ elif a == "response" and hasattr(flow, "response"):
+ response = flow.response
+ for k, v in b.items():
+ if k in ["msg", "http_version"]:
+ setattr(response, k, str(v))
+ elif k == "code":
+ response.status_code = int(v)
+ elif k == "headers":
+ response.headers.clear()
+ for header in v:
+ response.headers.add(*header)
+ elif k == "content":
+ response.text = v
+ else:
+ raise APIError(400, "Unknown update response.{}: {}".format(k, v))
+ else:
+ raise APIError(400, "Unknown update {}: {}".format(a, b))
+ except APIError:
+ flow.revert()
+ raise
self.view.update(flow)
class DuplicateFlow(RequestHandler):
-
def post(self, flow_id):
- self.master.view.duplicate_flow(self.flow)
+ f = self.flow.copy()
+ self.view.add(f)
+ self.write(f.id)
class RevertFlow(RequestHandler):
-
def post(self, flow_id):
- self.flow.revert()
+ if self.flow.modified():
+ self.flow.revert()
+ self.view.update(self.flow)
class ReplayFlow(RequestHandler):
-
def post(self, flow_id):
self.flow.backup()
self.flow.response = None
self.view.update(self.flow)
- r = self.master.replay_request(self.flow)
- if r:
- raise APIError(400, r)
+ try:
+ self.master.replay_request(self.flow)
+ except exceptions.ReplayException as e:
+ raise APIError(400, str(e))
class FlowContent(RequestHandler):
-
def post(self, flow_id, message):
self.flow.backup()
message = getattr(self.flow, message)
- message.content = self.request.files.values()[0][0].body
+ message.content = self.filecontents
self.view.update(self.flow)
def get(self, flow_id, message):
@@ -347,15 +346,14 @@ class FlowContent(RequestHandler):
class FlowContentView(RequestHandler):
-
def get(self, flow_id, message, content_view):
message = getattr(self.flow, message)
description, lines, error = contentviews.get_message_content_view(
content_view.replace('_', ' '), message
)
-# if error:
-# add event log
+ # if error:
+ # add event log
self.write(dict(
lines=list(lines),
@@ -364,13 +362,11 @@ class FlowContentView(RequestHandler):
class Events(RequestHandler):
-
def get(self):
- self.write([]) # FIXME
+ self.write([logentry_to_json(e) for e in self.master.events.data])
class Settings(RequestHandler):
-
def get(self):
self.write(dict(
version=version.VERSION,
@@ -389,51 +385,20 @@ class Settings(RequestHandler):
))
def put(self):
- update = {}
- for k, v in self.json.items():
- if k == "intercept":
- self.master.options.intercept = v
- update[k] = v
- elif k == "showhost":
- self.master.options.showhost = v
- update[k] = v
- elif k == "no_upstream_cert":
- self.master.options.no_upstream_cert = v
- update[k] = v
- elif k == "rawtcp":
- self.master.options.rawtcp = v
- update[k] = v
- elif k == "http2":
- self.master.options.http2 = v
- update[k] = v
- elif k == "anticache":
- self.master.options.anticache = v
- update[k] = v
- elif k == "anticomp":
- self.master.options.anticomp = v
- update[k] = v
- elif k == "stickycookie":
- self.master.options.stickycookie = v
- update[k] = v
- elif k == "stickyauth":
- self.master.options.stickyauth = v
- update[k] = v
- elif k == "stream":
- self.master.options.stream_large_bodies = v
- update[k] = v
- else:
- print("Warning: Unknown setting {}: {}".format(k, v))
-
- ClientConnection.broadcast(
- resource="settings",
- cmd="update",
- data=update
- )
+ update = self.json
+ option_whitelist = {
+ "intercept", "showhost", "no_upstream_cert",
+ "rawtcp", "http2", "anticache", "anticomp",
+ "stickycookie", "stickyauth", "stream_large_bodies"
+ }
+ for k in update:
+ if k not in option_whitelist:
+ raise APIError(400, "Unknown setting {}".format(k))
+ self.master.options.update(**update)
class Application(tornado.web.Application):
-
- def __init__(self, master, debug, wauthenticator):
+ def __init__(self, master, debug):
self.master = master
handlers = [
(r"/", IndexHandler),
@@ -449,7 +414,9 @@ class Application(tornado.web.Application):
(r"/flows/(?P<flow_id>[0-9a-f\-]+)/replay", ReplayFlow),
(r"/flows/(?P<flow_id>[0-9a-f\-]+)/revert", RevertFlow),
(r"/flows/(?P<flow_id>[0-9a-f\-]+)/(?P<message>request|response)/content", FlowContent),
- (r"/flows/(?P<flow_id>[0-9a-f\-]+)/(?P<message>request|response)/content/(?P<content_view>[0-9a-zA-Z\-\_]+)", FlowContentView),
+ (
+ r"/flows/(?P<flow_id>[0-9a-f\-]+)/(?P<message>request|response)/content/(?P<content_view>[0-9a-zA-Z\-\_]+)",
+ FlowContentView),
(r"/settings", Settings),
(r"/clear", ClearAll),
]
@@ -460,6 +427,5 @@ class Application(tornado.web.Application):
cookie_secret=os.urandom(256),
debug=debug,
autoreload=False,
- wauthenticator=wauthenticator,
)
super().__init__(handlers, **settings)
diff --git a/mitmproxy/tools/web/master.py b/mitmproxy/tools/web/master.py
index d2203f10..5457fb40 100644
--- a/mitmproxy/tools/web/master.py
+++ b/mitmproxy/tools/web/master.py
@@ -1,49 +1,20 @@
import sys
import webbrowser
+from typing import Optional
import tornado.httpserver
import tornado.ioloop
-
-from typing import Optional
-
from mitmproxy import addons
from mitmproxy import exceptions
-from mitmproxy.addons import view
-from mitmproxy.addons import intercept
-from mitmproxy import options
+from mitmproxy import log
from mitmproxy import master
+from mitmproxy import options
+from mitmproxy.addons import eventstore
+from mitmproxy.addons import intercept
+from mitmproxy.addons import view
from mitmproxy.tools.web import app
-class Stop(Exception):
- pass
-
-
-class _WebState():
- def add_log(self, e, level):
- # server-side log ids are odd
- self._last_event_id += 2
- entry = {
- "id": self._last_event_id,
- "message": e,
- "level": level
- }
- self.events.append(entry)
- app.ClientConnection.broadcast(
- resource="events",
- cmd="add",
- data=entry
- )
-
- def clear(self):
- super().clear()
- self.events.clear()
- app.ClientConnection.broadcast(
- resource="events",
- cmd="reset"
- )
-
-
class Options(options.Options):
def __init__(
self,
@@ -52,54 +23,34 @@ class Options(options.Options):
wdebug: bool = False,
wport: int = 8081,
wiface: str = "127.0.0.1",
- # wauthenticator: Optional[authentication.PassMan] = None,
- wsingleuser: Optional[str] = None,
- whtpasswd: Optional[str] = None,
**kwargs
) -> None:
+ self.intercept = intercept
self.wdebug = wdebug
self.wport = wport
self.wiface = wiface
- # self.wauthenticator = wauthenticator
- # self.wsingleuser = wsingleuser
- # self.whtpasswd = whtpasswd
- self.intercept = intercept
super().__init__(**kwargs)
- # TODO: This doesn't belong here.
- def process_web_options(self, parser):
- # if self.wsingleuser or self.whtpasswd:
- # if self.wsingleuser:
- # if len(self.wsingleuser.split(':')) != 2:
- # return parser.error(
- # "Invalid single-user specification. Please use the format username:password"
- # )
- # username, password = self.wsingleuser.split(':')
- # # self.wauthenticator = authentication.PassManSingleUser(username, password)
- # elif self.whtpasswd:
- # try:
- # self.wauthenticator = authentication.PassManHtpasswd(self.whtpasswd)
- # except ValueError as v:
- # return parser.error(v.message)
- # else:
- # self.wauthenticator = None
- pass
-
class WebMaster(master.Master):
-
def __init__(self, options, server):
super().__init__(options, server)
self.view = view.View()
- self.view.sig_view_add.connect(self._sig_add)
- self.view.sig_view_remove.connect(self._sig_remove)
- self.view.sig_view_update.connect(self._sig_update)
- self.view.sig_view_refresh.connect(self._sig_refresh)
+ self.view.sig_view_add.connect(self._sig_view_add)
+ self.view.sig_view_remove.connect(self._sig_view_remove)
+ self.view.sig_view_update.connect(self._sig_view_update)
+ self.view.sig_view_refresh.connect(self._sig_view_refresh)
+
+ self.events = eventstore.EventStore()
+ self.events.sig_add.connect(self._sig_events_add)
+ self.events.sig_refresh.connect(self._sig_events_refresh)
+
+ self.options.changed.connect(self._sig_options_update)
self.addons.add(*addons.default_addons())
- self.addons.add(self.view, intercept.Intercept())
+ self.addons.add(self.view, self.events, intercept.Intercept())
self.app = app.Application(
- self, self.options.wdebug, False
+ self, self.options.wdebug
)
# This line is just for type hinting
self.options = self.options # type: Options
@@ -112,33 +63,53 @@ class WebMaster(master.Master):
"error"
)
- def _sig_add(self, view, flow):
+ def _sig_view_add(self, view, flow):
app.ClientConnection.broadcast(
resource="flows",
cmd="add",
- data=app.convert_flow_to_json_dict(flow)
+ data=app.flow_to_json(flow)
)
- def _sig_update(self, view, flow):
+ def _sig_view_update(self, view, flow):
app.ClientConnection.broadcast(
resource="flows",
cmd="update",
- data=app.convert_flow_to_json_dict(flow)
+ data=app.flow_to_json(flow)
)
- def _sig_remove(self, view, flow):
+ def _sig_view_remove(self, view, flow):
app.ClientConnection.broadcast(
resource="flows",
cmd="remove",
data=dict(id=flow.id)
)
- def _sig_refresh(self, view):
+ def _sig_view_refresh(self, view):
app.ClientConnection.broadcast(
resource="flows",
cmd="reset"
)
+ def _sig_events_add(self, event_store, entry: log.LogEntry):
+ app.ClientConnection.broadcast(
+ resource="events",
+ cmd="add",
+ data=app.logentry_to_json(entry)
+ )
+
+ def _sig_events_refresh(self, event_store):
+ app.ClientConnection.broadcast(
+ resource="events",
+ cmd="reset"
+ )
+
+ def _sig_options_update(self, options, updated):
+ app.ClientConnection.broadcast(
+ resource="settings",
+ cmd="update",
+ data={k: getattr(options, k) for k in updated}
+ )
+
def run(self): # pragma: no cover
iol = tornado.ioloop.IOLoop.instance()
@@ -155,13 +126,9 @@ class WebMaster(master.Master):
print("No webbrowser found. Please open a browser and point it to {}".format(url))
iol.start()
- except (Stop, KeyboardInterrupt):
+ except (KeyboardInterrupt):
self.shutdown()
- # def add_log(self, e, level="info"):
- # super().add_log(e, level)
- # return self.state.add_log(e, level)
-
def open_browser(url: str) -> bool:
"""
diff --git a/test/mitmproxy/addons/test_evenstore.py b/test/mitmproxy/addons/test_evenstore.py
new file mode 100644
index 00000000..78eb3287
--- /dev/null
+++ b/test/mitmproxy/addons/test_evenstore.py
@@ -0,0 +1,32 @@
+import mock
+from mitmproxy import log
+from mitmproxy.addons import eventstore
+
+
+def test_simple():
+ store = eventstore.EventStore()
+ assert not store.data
+
+ sig_add = mock.Mock(spec=lambda: 42)
+ sig_refresh = mock.Mock(spec=lambda: 42)
+ store.sig_add.connect(sig_add)
+ store.sig_refresh.connect(sig_refresh)
+
+ assert not sig_add.called
+ assert not sig_refresh.called
+
+ # test .log()
+ store.log(log.LogEntry("test", "info"))
+ assert store.data
+
+ assert sig_add.called
+ assert not sig_refresh.called
+
+ # test .clear()
+ sig_add.reset_mock()
+
+ store.clear()
+ assert not store.data
+
+ assert not sig_add.called
+ assert sig_refresh.called
diff --git a/test/mitmproxy/data/addonscripts/duplicate_flow.py b/test/mitmproxy/data/addonscripts/duplicate_flow.py
deleted file mode 100644
index 02fb8dce..00000000
--- a/test/mitmproxy/data/addonscripts/duplicate_flow.py
+++ /dev/null
@@ -1,6 +0,0 @@
-from mitmproxy import ctx
-
-
-def request(flow):
- f = ctx.master.state.duplicate_flow(flow)
- ctx.master.replay_request(f, block=True)
diff --git a/test/mitmproxy/test_web_app.py b/test/mitmproxy/test_web_app.py
index 8fc3378a..be195528 100644
--- a/test/mitmproxy/test_web_app.py
+++ b/test/mitmproxy/test_web_app.py
@@ -1,15 +1,47 @@
-import tornado.testing
+import json as _json
+import mock
+import tornado.testing
+from mitmproxy import exceptions
from mitmproxy import proxy
+from mitmproxy.test import tflow
from mitmproxy.tools.web import app
from mitmproxy.tools.web import master as webmaster
+from tornado import httpclient
+from tornado import websocket
+
+
+def json(resp: httpclient.HTTPResponse):
+ return _json.loads(resp.body.decode())
class TestApp(tornado.testing.AsyncHTTPTestCase):
def get_app(self):
o = webmaster.Options()
m = webmaster.WebMaster(o, proxy.DummyServer())
- return app.Application(m, None, None)
+ f = tflow.tflow(resp=True)
+ f.id = "42"
+ m.view.add(f)
+ m.view.add(tflow.tflow(err=True))
+ m.add_log("test log", "info")
+ self.master = m
+ self.view = m.view
+ self.events = m.events
+ webapp = app.Application(m, None)
+ webapp.settings["xsrf_cookies"] = False
+ return webapp
+
+ def fetch(self, *args, **kwargs) -> httpclient.HTTPResponse:
+ # tornado disallows POST without content by default.
+ return super().fetch(*args, **kwargs, allow_nonstandard_methods=True)
+
+ def put_json(self, url, data: dict) -> httpclient.HTTPResponse:
+ return self.fetch(
+ url,
+ method="PUT",
+ body=_json.dumps(data),
+ headers={"Content-Type": "application/json"},
+ )
def test_index(self):
assert self.fetch("/").code == 200
@@ -17,8 +49,217 @@ class TestApp(tornado.testing.AsyncHTTPTestCase):
def test_filter_help(self):
assert self.fetch("/filter-help").code == 200
+ def test_flows(self):
+ resp = self.fetch("/flows")
+ assert resp.code == 200
+ assert json(resp)[0]["request"]["contentHash"]
+ assert json(resp)[1]["error"]
+
+ def test_flows_dump(self):
+ resp = self.fetch("/flows/dump")
+ assert b"address" in resp.body
+
+ self.view.clear()
+ assert not len(self.view)
+
+ assert self.fetch("/flows/dump", method="POST", body=resp.body).code == 200
+ assert len(self.view)
+
+ def test_clear(self):
+ events = self.events.data.copy()
+ flows = list(self.view)
+
+ assert self.fetch("/clear", method="POST").code == 200
+
+ assert not len(self.view)
+ assert not len(self.events.data)
+
+ # restore
+ for f in flows:
+ self.view.add(f)
+ self.events.data = events
+
+ def test_accept(self):
+ for f in self.view:
+ f.reply.handle()
+ f.intercept(self.master)
+
+ assert self.fetch(
+ "/flows/42/accept", method="POST").code == 200
+ assert sum(f.intercepted for f in self.view) == 1
+ assert self.fetch("/flows/accept", method="POST").code == 200
+ assert all(not f.intercepted for f in self.view)
+
+ def test_flow_delete(self):
+ f = self.view.get_by_id("42")
+ assert f
+
+ f.reply.handle()
+ assert self.fetch("/flows/42", method="DELETE").code == 200
+
+ assert not self.view.get_by_id("42")
+ self.view.add(f)
+
+ assert self.fetch("/flows/1234", method="DELETE").code == 404
+
+ def test_flow_update(self):
+ f = self.view.get_by_id("42")
+ assert f.request.method == "GET"
+ f.backup()
+
+ upd = {
+ "request": {
+ "method": "PATCH",
+ "port": 123,
+ "headers": [("foo", "bar")],
+ "content": "req",
+ },
+ "response": {
+ "msg": "Not Found",
+ "code": 404,
+ "headers": [("bar", "baz")],
+ "content": "resp",
+ }
+ }
+ assert self.put_json("/flows/42", upd).code == 200
+ assert f.request.method == "PATCH"
+ assert f.request.port == 123
+ assert f.request.headers["foo"] == "bar"
+ assert f.request.text == "req"
+ assert f.response.msg == "Not Found"
+ assert f.response.status_code == 404
+ assert f.response.headers["bar"] == "baz"
+ assert f.response.text == "resp"
+
+ f.revert()
+
+ assert self.put_json("/flows/42", {"foo": 42}).code == 400
+ assert self.put_json("/flows/42", {"request": {"foo": 42}}).code == 400
+ assert self.put_json("/flows/42", {"response": {"foo": 42}}).code == 400
+ assert self.fetch("/flows/42", method="PUT", body="{}").code == 400
+ assert self.fetch(
+ "/flows/42",
+ method="PUT",
+ headers={"Content-Type": "application/json"},
+ body="!!"
+ ).code == 400
+
+ def test_flow_duplicate(self):
+ resp = self.fetch("/flows/42/duplicate", method="POST")
+ assert resp.code == 200
+ f = self.view.get_by_id(resp.body.decode())
+ assert f
+ assert f.id != "42"
+ self.view.remove(f)
+
+ def test_flow_revert(self):
+ f = self.view.get_by_id("42")
+ f.backup()
+ f.request.method = "PATCH"
+ self.fetch("/flows/42/revert", method="POST")
+ assert not f._backup
+
+ def test_flow_replay(self):
+ with mock.patch("mitmproxy.master.Master.replay_request") as replay_request:
+ assert self.fetch("/flows/42/replay", method="POST").code == 200
+ assert replay_request.called
+ replay_request.side_effect = exceptions.ReplayException(
+ "out of replays"
+ )
+ assert self.fetch("/flows/42/replay", method="POST").code == 400
+
+ def test_flow_content(self):
+ f = self.view.get_by_id("42")
+ f.backup()
+ f.response.headers["Content-Encoding"] = "ran\x00dom"
+ f.response.headers["Content-Disposition"] = 'inline; filename="filename.jpg"'
+
+ r = self.fetch("/flows/42/response/content")
+ assert r.body == b"message"
+ assert r.headers["Content-Encoding"] == "random"
+ assert r.headers["Content-Disposition"] == 'attachment; filename="filename.jpg"'
+
+ del f.response.headers["Content-Disposition"]
+ f.request.path = "/foo/bar.jpg"
+ assert self.fetch(
+ "/flows/42/response/content"
+ ).headers["Content-Disposition"] == 'attachment; filename=bar.jpg'
+
+ f.response.content = b""
+ assert self.fetch("/flows/42/response/content").code == 400
+
+ f.revert()
+
+ def test_update_flow_content(self):
+ assert self.fetch(
+ "/flows/42/request/content",
+ method="POST",
+ body="new"
+ ).code == 200
+ f = self.view.get_by_id("42")
+ assert f.request.content == b"new"
+ assert f.modified()
+ f.revert()
+
+ def test_update_flow_content_multipart(self):
+ body = (
+ b'--somefancyboundary\r\n'
+ b'Content-Disposition: form-data; name="a"; filename="a.txt"\r\n'
+ b'\r\n'
+ b'such multipart. very wow.\r\n'
+ b'--somefancyboundary--\r\n'
+ )
+ assert self.fetch(
+ "/flows/42/request/content",
+ method="POST",
+ headers={"Content-Type": 'multipart/form-data; boundary="somefancyboundary"'},
+ body=body
+ ).code == 200
+ f = self.view.get_by_id("42")
+ assert f.request.content == b"such multipart. very wow."
+ assert f.modified()
+ f.revert()
+
+ def test_flow_content_view(self):
+ assert json(self.fetch("/flows/42/request/content/raw")) == {
+ "lines": [
+ [["text", "content"]]
+ ],
+ "description": "Raw"
+ }
+
def test_events(self):
- assert self.fetch("/events").code == 200
+ resp = self.fetch("/events")
+ assert resp.code == 200
+ assert json(resp)[0]["level"] == "info"
- def test_flows(self):
- assert self.fetch("/flows").code == 200
+ def test_settings(self):
+ assert json(self.fetch("/settings"))["mode"] == "regular"
+
+ def test_settings_update(self):
+ assert self.put_json("/settings", {"anticache": True}).code == 200
+ assert self.put_json("/settings", {"wtf": True}).code == 400
+
+ def test_err(self):
+ with mock.patch("mitmproxy.tools.web.app.IndexHandler.get") as f:
+ f.side_effect = RuntimeError
+ assert self.fetch("/").code == 500
+
+ @tornado.testing.gen_test
+ def test_websocket(self):
+ ws_url = "ws://localhost:{}/updates".format(self.get_http_port())
+
+ ws_client = yield websocket.websocket_connect(ws_url)
+ self.master.options.anticomp = True
+
+ response = yield ws_client.read_message()
+ assert _json.loads(response) == {
+ "resource": "settings",
+ "cmd": "update",
+ "data": {"anticomp": True},
+ }
+ ws_client.close()
+
+ # trigger on_close by opening a second connection.
+ ws_client2 = yield websocket.websocket_connect(ws_url)
+ ws_client2.close()
diff --git a/web/src/js/components/ContentView/ContentViewOptions.jsx b/web/src/js/components/ContentView/ContentViewOptions.jsx
index fed3a088..6bc66db2 100644
--- a/web/src/js/components/ContentView/ContentViewOptions.jsx
+++ b/web/src/js/components/ContentView/ContentViewOptions.jsx
@@ -9,15 +9,14 @@ ContentViewOptions.propTypes = {
message: React.PropTypes.object.isRequired,
}
-function ContentViewOptions(props) {
- const { flow, message, uploadContent, readonly, contentViewDescription } = props
+function ContentViewOptions({ flow, message, uploadContent, readonly, contentViewDescription }) {
return (
<div className="view-options">
<ViewSelector message={message}/>
&nbsp;
<DownloadContentButton flow={flow} message={message}/>
&nbsp;
- <UploadContentButton uploadContent={uploadContent}/>
+ {!readonly && <UploadContentButton uploadContent={uploadContent}/> }
&nbsp;
<span>{contentViewDescription}</span>
</div>
@@ -26,6 +25,7 @@ function ContentViewOptions(props) {
export default connect(
state => ({
- contentViewDescription: state.ui.flow.viewDescription
+ contentViewDescription: state.ui.flow.viewDescription,
+ readonly: !state.ui.flow.modifiedFlow,
})
)(ContentViewOptions)
diff --git a/web/src/js/components/ContentView/ContentViews.jsx b/web/src/js/components/ContentView/ContentViews.jsx
index 32a07564..db239195 100644
--- a/web/src/js/components/ContentView/ContentViews.jsx
+++ b/web/src/js/components/ContentView/ContentViews.jsx
@@ -63,6 +63,7 @@ class ViewServer extends Component {
let lines = this.props.showFullContent ? this.data.lines : this.data.lines.slice(0, maxLines)
return (
<div>
+ {ViewImage.matches(message) && <ViewImage {...this.props} />}
<pre>
{lines.map((line, i) =>
<div key={`line${i}`}>
@@ -77,9 +78,6 @@ class ViewServer extends Component {
</div>
)}
</pre>
- {ViewImage.matches(message) &&
- <ViewImage {...this.props} />
- }
</div>
)
}
diff --git a/web/src/js/components/ContentView/ViewSelector.jsx b/web/src/js/components/ContentView/ViewSelector.jsx
index ab433ea3..fcdc3ee3 100644
--- a/web/src/js/components/ContentView/ViewSelector.jsx
+++ b/web/src/js/components/ContentView/ViewSelector.jsx
@@ -14,7 +14,7 @@ ViewSelector.propTypes = {
function ViewSelector ({contentViews, activeView, isEdit, setContentView}){
let edit = ContentViews.Edit.displayName
- let inner = <span> <b>View:</b> {activeView}<span className="caret"></span> </span>
+ let inner = <span> <b>View:</b> {activeView} <span className="caret"></span> </span>
return (
<Dropdown dropup className="pull-left" btnClass="btn btn-default btn-xs" text={inner}>
diff --git a/web/src/js/components/EventLog.jsx b/web/src/js/components/EventLog.jsx
index 636e3e9a..1a449511 100644
--- a/web/src/js/components/EventLog.jsx
+++ b/web/src/js/components/EventLog.jsx
@@ -70,7 +70,7 @@ class EventLog extends Component {
export default connect(
state => ({
filters: state.eventLog.filters,
- events: state.eventLog.view.data,
+ events: state.eventLog.view,
}),
{
close: toggleVisibility,
diff --git a/web/src/js/components/Footer.jsx b/web/src/js/components/Footer.jsx
index 96e7b7db..1ae4ee73 100644
--- a/web/src/js/components/Footer.jsx
+++ b/web/src/js/components/Footer.jsx
@@ -7,7 +7,7 @@ Footer.propTypes = {
}
function Footer({ settings }) {
- let {mode, intercept, showhost, no_upstream_cert, rawtcp, http2, anticache, anticomp, stickyauth, stickycookie, stream} = settings;
+ let {mode, intercept, showhost, no_upstream_cert, rawtcp, http2, anticache, anticomp, stickyauth, stickycookie, stream_large_bodies} = settings;
return (
<footer>
{mode && mode != "regular" && (
@@ -40,8 +40,8 @@ function Footer({ settings }) {
{stickycookie && (
<span className="label label-success">stickycookie: {stickycookie}</span>
)}
- {stream && (
- <span className="label label-success">stream: {formatSize(stream)}</span>
+ {stream_large_bodies && (
+ <span className="label label-success">stream: {formatSize(stream_large_bodies)}</span>
)}
</footer>
)
diff --git a/web/src/js/components/Header/OptionMenu.jsx b/web/src/js/components/Header/OptionMenu.jsx
index a11062f2..186a9c6a 100644
--- a/web/src/js/components/Header/OptionMenu.jsx
+++ b/web/src/js/components/Header/OptionMenu.jsx
@@ -49,11 +49,11 @@ function OptionMenu({ settings, updateSettings }) {
txt={settings.stickycookie}
onToggleChanged={txt => updateSettings({ stickycookie: !settings.stickycookie ? txt : null })}
/>
- <ToggleInputButton name="stream" placeholder="stream..."
- checked={!!settings.stream}
- txt={settings.stream}
+ <ToggleInputButton name="stream_large_bodies" placeholder="stream..."
+ checked={!!settings.stream_large_bodies}
+ txt={settings.stream_large_bodies}
inputType="number"
- onToggleChanged={txt => updateSettings({ stream: !settings.stream ? txt : null })}
+ onToggleChanged={txt => updateSettings({ stream_large_bodies: !settings.stream_large_bodies ? txt : null })}
/>
</div>
<div className="clearfix"/>
diff --git a/web/src/js/ducks/eventLog.js b/web/src/js/ducks/eventLog.js
index 776e4b08..73eaf2e8 100644
--- a/web/src/js/ducks/eventLog.js
+++ b/web/src/js/ducks/eventLog.js
@@ -49,14 +49,12 @@ export function toggleVisibility() {
return { type: TOGGLE_VISIBILITY }
}
-let logId = 1 // client-side log ids are odd
export function add(message, level = 'web') {
let data = {
- id: logId,
+ id: Math.random().toString(),
message,
level,
}
- logId += 2
return {
type: ADD,
cmd: "add",