mitmweb: 100% app test coverage, numerous fixes

This commit is contained in:
Maximilian Hils 2016-11-23 22:35:07 +01:00
parent dc75605e46
commit 45332006a3
14 changed files with 377 additions and 126 deletions

View File

@ -2,6 +2,7 @@ from mitmproxy import ctx
def request(flow): def request(flow):
f = ctx.master.state.duplicate_flow(flow) f = flow.copy()
ctx.master.view.add(f)
f.request.path = "/changed" f.request.path = "/changed"
ctx.master.replay_request(f, block=True) ctx.master.replay_request(f, block=True)

View File

@ -280,6 +280,13 @@ class View(collections.Sequence):
# The value was not in the view # The value was not in the view
pass 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 # Event handlers
def configure(self, opts, updated): def configure(self, opts, updated):
if "filter" in updated: if "filter" in updated:

View File

@ -609,7 +609,7 @@ def get_message_content_view(viewname, message):
""" """
viewmode = get(viewname) viewmode = get(viewname)
if not viewmode: if not viewmode:
get("auto") viewmode = get("auto")
try: try:
content = message.content content = message.content
except ValueError: except ValueError:

View File

@ -1,6 +1,8 @@
import os import os
from typing import Iterable
from mitmproxy import exceptions from mitmproxy import exceptions
from mitmproxy import flow
from mitmproxy import flowfilter from mitmproxy import flowfilter
from mitmproxy import http from mitmproxy import http
from mitmproxy import tcp from mitmproxy import tcp
@ -27,7 +29,7 @@ class FlowReader:
def __init__(self, fo): def __init__(self, fo):
self.fo = fo self.fo = fo
def stream(self): def stream(self) -> Iterable[flow.Flow]:
""" """
Yields Flow objects from the dump. Yields Flow objects from the dump.
""" """
@ -52,10 +54,10 @@ class FilteredFlowWriter:
self.fo = fo self.fo = fo
self.flt = flt self.flt = flt
def add(self, flow): def add(self, f: flow.Flow):
if self.flt and not flowfilter.match(self.flt, flow): if self.flt and not flowfilter.match(self.flt, f):
return return
d = flow.get_state() d = f.get_state()
tnetstring.dump(d, self.fo) tnetstring.dump(d, self.fo)

View File

@ -156,7 +156,7 @@ class Master:
for e, o in events.event_sequence(f): for e, o in events.event_sequence(f):
getattr(self, e)(o) getattr(self, e)(o)
def load_flows(self, fr): def load_flows(self, fr: io.FlowReader) -> int:
""" """
Load flows from a FlowReader object. Load flows from a FlowReader object.
""" """
@ -166,7 +166,7 @@ class Master:
self.load_flow(i) self.load_flow(i)
return cnt return cnt
def load_flows_file(self, path): def load_flows_file(self, path: str) -> int:
path = os.path.expanduser(path) path = os.path.expanduser(path)
try: try:
if path == "-": if path == "-":
@ -180,7 +180,11 @@ class Master:
except IOError as v: except IOError as v:
raise exceptions.FlowReadException(v.strerror) 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. Replay a HTTP request to receive a new response from the server.

View File

@ -303,8 +303,8 @@ class FlowListWalker(urwid.ListWalker):
class FlowListBox(urwid.ListBox): class FlowListBox(urwid.ListBox):
def __init__(self, master): def __init__(self, master: "mitmproxy.tools.console.master.ConsoleMaster"):
self.master = master self.master = master # type: "mitmproxy.tools.console.master.ConsoleMaster"
super().__init__(FlowListWalker(master)) super().__init__(FlowListWalker(master))
def get_method_raw(self, k): def get_method_raw(self, k):
@ -348,7 +348,7 @@ class FlowListBox(urwid.ListBox):
if key == "A": if key == "A":
for f in self.master.view: for f in self.master.view:
if f.intercepted: if f.intercepted:
f.resume() f.resume(self.master)
signals.flowlist_change.send(self) signals.flowlist_change.send(self)
elif key == "z": elif key == "z":
self.master.view.clear() self.master.view.clear()

View File

@ -510,8 +510,10 @@ class FlowView(tabs.Tabs):
self.flow.resume(self.master) self.flow.resume(self.master)
signals.flow_change.send(self, flow = self.flow) signals.flow_change.send(self, flow = self.flow)
elif key == "A": elif key == "A":
self.master.accept_all() for f in self.view:
signals.flow_change.send(self, flow = self.flow) if f.intercepted:
f.resume(self.master)
signals.flow_change.send(self, flow=f)
elif key == "d": elif key == "d":
if self.flow.killable: if self.flow.killable:
self.flow.kill(self.master) self.flow.kill(self.master)

View File

@ -71,7 +71,7 @@ class ConsoleMaster(master.Master):
def __init__(self, options, server): def __init__(self, options, server):
super().__init__(options, server) super().__init__(options, server)
self.view = view.View() self.view = view.View() # type: view.View
self.stream_path = None self.stream_path = None
# This line is just for type hinting # This line is just for type hinting
self.options = self.options # type: Options self.options = self.options # type: Options

View File

@ -11,6 +11,7 @@ import tornado.escape
import tornado.web import tornado.web
import tornado.websocket import tornado.websocket
from mitmproxy import contentviews from mitmproxy import contentviews
from mitmproxy import exceptions
from mitmproxy import flowfilter from mitmproxy import flowfilter
from mitmproxy import http from mitmproxy import http
from mitmproxy import io from mitmproxy import io
@ -108,9 +109,23 @@ class RequestHandler(tornado.web.RequestHandler):
@property @property
def json(self): def json(self):
if not self.request.headers.get("Content-Type").startswith("application/json"): if not self.request.headers.get("Content-Type", "").startswith("application/json"):
return None raise APIError(400, "Invalid Content-Type, expected application/json.")
return json.loads(self.request.body.decode()) 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 @property
def view(self) -> mitmproxy.addons.view.View: def view(self) -> mitmproxy.addons.view.View:
@ -124,11 +139,11 @@ class RequestHandler(tornado.web.RequestHandler):
def flow(self) -> mitmproxy.flow.Flow: def flow(self) -> mitmproxy.flow.Flow:
flow_id = str(self.path_kwargs["flow_id"]) flow_id = str(self.path_kwargs["flow_id"])
# FIXME: Add a facility to addon.view to safely access the store # 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: if flow:
return flow return flow
else: else:
raise APIError(400, "Flow not found.") raise APIError(404, "Flow not found.")
def write_error(self, status_code: int, **kwargs): def write_error(self, status_code: int, **kwargs):
if "exc_info" in kwargs and isinstance(kwargs["exc_info"][1], APIError): if "exc_info" in kwargs and isinstance(kwargs["exc_info"][1], APIError):
@ -168,7 +183,7 @@ class WebSocketEventBroadcaster(tornado.websocket.WebSocketHandler):
for conn in cls.connections: for conn in cls.connections:
try: try:
conn.write_message(message) conn.write_message(message)
except Exception: except Exception: # pragma: no cover
logging.error("Error sending message", exc_info=True) logging.error("Error sending message", exc_info=True)
@ -196,10 +211,8 @@ class DumpFlows(RequestHandler):
def post(self): def post(self):
self.view.clear() self.view.clear()
bio = BytesIO(self.filecontents)
content = self.request.files.values()[0][0].body self.master.load_flows(io.FlowReader(bio))
bio = BytesIO(content)
self.master.load_flows(io.FlowReader(bio).stream())
bio.close() bio.close()
@ -211,7 +224,8 @@ class ClearAll(RequestHandler):
class AcceptFlows(RequestHandler): class AcceptFlows(RequestHandler):
def post(self): def post(self):
self.master.accept_all(self.master) for f in self.view:
f.resume(self.master)
class AcceptFlow(RequestHandler): class AcceptFlow(RequestHandler):
@ -228,53 +242,59 @@ class FlowHandler(RequestHandler):
def put(self, flow_id): def put(self, flow_id):
flow = self.flow flow = self.flow
flow.backup() flow.backup()
for a, b in self.json.items(): try:
if a == "request" and hasattr(flow, "request"): for a, b in self.json.items():
request = flow.request if a == "request" and hasattr(flow, "request"):
for k, v in b.items(): request = flow.request
if k in ["method", "scheme", "host", "path", "http_version"]: for k, v in b.items():
setattr(request, k, str(v)) if k in ["method", "scheme", "host", "path", "http_version"]:
elif k == "port": setattr(request, k, str(v))
request.port = int(v) elif k == "port":
elif k == "headers": request.port = int(v)
request.headers.clear() elif k == "headers":
for header in v: request.headers.clear()
request.headers.add(*header) for header in v:
elif k == "content": request.headers.add(*header)
request.text = v elif k == "content":
else: request.text = v
print("Warning: Unknown update {}.{}: {}".format(a, k, v)) else:
raise APIError(400, "Unknown update request.{}: {}".format(k, v))
elif a == "response" and hasattr(flow, "response"): elif a == "response" and hasattr(flow, "response"):
response = flow.response response = flow.response
for k, v in b.items(): for k, v in b.items():
if k == "msg": if k in ["msg", "http_version"]:
response.msg = str(v) setattr(response, k, str(v))
elif k == "code": elif k == "code":
response.status_code = int(v) response.status_code = int(v)
elif k == "http_version": elif k == "headers":
response.http_version = str(v) response.headers.clear()
elif k == "headers": for header in v:
response.headers.clear() response.headers.add(*header)
for header in v: elif k == "content":
response.headers.add(*header) response.text = v
elif k == "content": else:
response.text = v raise APIError(400, "Unknown update response.{}: {}".format(k, v))
else: else:
print("Warning: Unknown update {}.{}: {}".format(a, k, v)) raise APIError(400, "Unknown update {}: {}".format(a, b))
else: except APIError:
print("Warning: Unknown update {}: {}".format(a, b)) flow.revert()
raise
self.view.update(flow) self.view.update(flow)
class DuplicateFlow(RequestHandler): class DuplicateFlow(RequestHandler):
def post(self, flow_id): 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): class RevertFlow(RequestHandler):
def post(self, flow_id): def post(self, flow_id):
self.flow.revert() if self.flow.modified():
self.flow.revert()
self.view.update(self.flow)
class ReplayFlow(RequestHandler): class ReplayFlow(RequestHandler):
@ -283,16 +303,17 @@ class ReplayFlow(RequestHandler):
self.flow.response = None self.flow.response = None
self.view.update(self.flow) self.view.update(self.flow)
r = self.master.replay_request(self.flow) try:
if r: self.master.replay_request(self.flow)
raise APIError(400, r) except exceptions.ReplayException as e:
raise APIError(400, str(e))
class FlowContent(RequestHandler): class FlowContent(RequestHandler):
def post(self, flow_id, message): def post(self, flow_id, message):
self.flow.backup() self.flow.backup()
message = getattr(self.flow, message) message = getattr(self.flow, message)
message.content = self.request.files.values()[0][0].body message.content = self.filecontents
self.view.update(self.flow) self.view.update(self.flow)
def get(self, flow_id, message): def get(self, flow_id, message):
@ -364,46 +385,16 @@ class Settings(RequestHandler):
)) ))
def put(self): def put(self):
update = {} update = self.json
for k, v in self.json.items(): option_whitelist = {
if k == "intercept": "intercept", "showhost", "no_upstream_cert",
self.master.options.intercept = v "rawtcp", "http2", "anticache", "anticomp",
update[k] = v "stickycookie", "stickyauth", "stream_large_bodies"
elif k == "showhost": }
self.master.options.showhost = v for k in update:
update[k] = v if k not in option_whitelist:
elif k == "no_upstream_cert": raise APIError(400, "Unknown setting {}".format(k))
self.master.options.no_upstream_cert = v self.master.options.update(**update)
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
)
class Application(tornado.web.Application): class Application(tornado.web.Application):

View File

@ -45,6 +45,8 @@ class WebMaster(master.Master):
self.events.sig_add.connect(self._sig_events_add) self.events.sig_add.connect(self._sig_events_add)
self.events.sig_refresh.connect(self._sig_events_refresh) 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(*addons.default_addons())
self.addons.add(self.view, self.events, intercept.Intercept()) self.addons.add(self.view, self.events, intercept.Intercept())
self.app = app.Application( self.app = app.Application(
@ -101,6 +103,13 @@ class WebMaster(master.Master):
cmd="reset" 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 def run(self): # pragma: no cover
iol = tornado.ioloop.IOLoop.instance() iol = tornado.ioloop.IOLoop.instance()

View File

@ -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)

View File

@ -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 import proxy
from mitmproxy.test import tflow
from mitmproxy.tools.web import app from mitmproxy.tools.web import app
from mitmproxy.tools.web import master as webmaster 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): class TestApp(tornado.testing.AsyncHTTPTestCase):
def get_app(self): def get_app(self):
o = webmaster.Options() o = webmaster.Options()
m = webmaster.WebMaster(o, proxy.DummyServer()) 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): def test_index(self):
assert self.fetch("/").code == 200 assert self.fetch("/").code == 200
@ -17,8 +49,217 @@ class TestApp(tornado.testing.AsyncHTTPTestCase):
def test_filter_help(self): def test_filter_help(self):
assert self.fetch("/filter-help").code == 200 assert self.fetch("/filter-help").code == 200
def test_events(self):
assert self.fetch("/events").code == 200
def test_flows(self): def test_flows(self):
assert self.fetch("/flows").code == 200 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):
resp = self.fetch("/events")
assert resp.code == 200
assert json(resp)[0]["level"] == "info"
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()

View File

@ -7,7 +7,7 @@ Footer.propTypes = {
} }
function Footer({ settings }) { 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 ( return (
<footer> <footer>
{mode && mode != "regular" && ( {mode && mode != "regular" && (
@ -40,8 +40,8 @@ function Footer({ settings }) {
{stickycookie && ( {stickycookie && (
<span className="label label-success">stickycookie: {stickycookie}</span> <span className="label label-success">stickycookie: {stickycookie}</span>
)} )}
{stream && ( {stream_large_bodies && (
<span className="label label-success">stream: {formatSize(stream)}</span> <span className="label label-success">stream: {formatSize(stream_large_bodies)}</span>
)} )}
</footer> </footer>
) )

View File

@ -49,11 +49,11 @@ function OptionMenu({ settings, updateSettings }) {
txt={settings.stickycookie} txt={settings.stickycookie}
onToggleChanged={txt => updateSettings({ stickycookie: !settings.stickycookie ? txt : null })} onToggleChanged={txt => updateSettings({ stickycookie: !settings.stickycookie ? txt : null })}
/> />
<ToggleInputButton name="stream" placeholder="stream..." <ToggleInputButton name="stream_large_bodies" placeholder="stream..."
checked={!!settings.stream} checked={!!settings.stream_large_bodies}
txt={settings.stream} txt={settings.stream_large_bodies}
inputType="number" inputType="number"
onToggleChanged={txt => updateSettings({ stream: !settings.stream ? txt : null })} onToggleChanged={txt => updateSettings({ stream_large_bodies: !settings.stream_large_bodies ? txt : null })}
/> />
</div> </div>
<div className="clearfix"/> <div className="clearfix"/>