502 lines
18 KiB
Python
502 lines
18 KiB
Python
import sys
|
||
from functools import lru_cache
|
||
|
||
import urwid
|
||
|
||
import mitmproxy.flow
|
||
import mitmproxy.tools.console.master
|
||
import mitmproxy_rs.syntax_highlight
|
||
from mitmproxy import contentviews
|
||
from mitmproxy import ctx
|
||
from mitmproxy import dns
|
||
from mitmproxy import http
|
||
from mitmproxy import tcp
|
||
from mitmproxy import udp
|
||
from mitmproxy.dns import DNSMessage
|
||
from mitmproxy.tools.console import common
|
||
from mitmproxy.tools.console import flowdetailview
|
||
from mitmproxy.tools.console import layoutwidget
|
||
from mitmproxy.tools.console import searchable
|
||
from mitmproxy.tools.console import tabs
|
||
from mitmproxy.utils import strutils
|
||
|
||
|
||
class SearchError(Exception):
|
||
pass
|
||
|
||
|
||
class FlowViewHeader(urwid.WidgetWrap):
|
||
def __init__(
|
||
self,
|
||
master: "mitmproxy.tools.console.master.ConsoleMaster",
|
||
) -> None:
|
||
self.master = master
|
||
self.focus_changed()
|
||
|
||
def focus_changed(self):
|
||
cols, _ = self.master.ui.get_cols_rows()
|
||
if self.master.view.focus.flow:
|
||
self._w = common.format_flow(
|
||
self.master.view.focus.flow,
|
||
render_mode=common.RenderMode.DETAILVIEW,
|
||
hostheader=self.master.options.showhost,
|
||
)
|
||
else:
|
||
self._w = urwid.Pile([])
|
||
|
||
|
||
class FlowDetails(tabs.Tabs):
|
||
def __init__(self, master):
|
||
self.master = master
|
||
super().__init__([])
|
||
self.show()
|
||
self.last_displayed_body = None
|
||
self.last_displayed_websocket_messages = None
|
||
contentviews.registry.on_change.connect(self.contentview_changed)
|
||
|
||
@property
|
||
def view(self):
|
||
return self.master.view
|
||
|
||
@property
|
||
def flow(self) -> mitmproxy.flow.Flow:
|
||
return self.master.view.focus.flow
|
||
|
||
def contentview_changed(self, view):
|
||
# this is called when a contentview addon is live-reloaded.
|
||
# we clear our cache and then rerender
|
||
self._get_content_view.cache_clear()
|
||
if self.master.window.current_window("flowview"):
|
||
self.show()
|
||
|
||
def focus_changed(self):
|
||
f = self.flow
|
||
if f:
|
||
if isinstance(f, http.HTTPFlow):
|
||
if f.websocket:
|
||
self.tabs = [
|
||
(self.tab_http_request, self.view_request),
|
||
(self.tab_http_response, self.view_response),
|
||
(self.tab_websocket_messages, self.view_websocket_messages),
|
||
(self.tab_details, self.view_details),
|
||
]
|
||
else:
|
||
self.tabs = [
|
||
(self.tab_http_request, self.view_request),
|
||
(self.tab_http_response, self.view_response),
|
||
(self.tab_details, self.view_details),
|
||
]
|
||
elif isinstance(f, tcp.TCPFlow):
|
||
self.tabs = [
|
||
(self.tab_tcp_stream, self.view_message_stream),
|
||
(self.tab_details, self.view_details),
|
||
]
|
||
elif isinstance(f, udp.UDPFlow):
|
||
self.tabs = [
|
||
(self.tab_udp_stream, self.view_message_stream),
|
||
(self.tab_details, self.view_details),
|
||
]
|
||
elif isinstance(f, dns.DNSFlow):
|
||
self.tabs = [
|
||
(self.tab_dns_request, self.view_dns_request),
|
||
(self.tab_dns_response, self.view_dns_response),
|
||
(self.tab_details, self.view_details),
|
||
]
|
||
self.show()
|
||
else:
|
||
# Get the top window from the focus stack (the currently active view).
|
||
# If it's NOT the "flowlist", it's safe to pop back to the previous view.
|
||
if self.master.window.focus_stack().stack[-1] != "flowlist":
|
||
self.master.window.pop()
|
||
# If it is the "flowlist", we’re already at the main view with no flows to show.
|
||
# Popping now would close the last window and prompt app exit, so we remain on the empty flow list screen instead.
|
||
|
||
def tab_http_request(self):
|
||
flow = self.flow
|
||
assert isinstance(flow, http.HTTPFlow)
|
||
if self.flow.intercepted and not flow.response:
|
||
return "Request intercepted"
|
||
else:
|
||
return "Request"
|
||
|
||
def tab_http_response(self):
|
||
flow = self.flow
|
||
assert isinstance(flow, http.HTTPFlow)
|
||
|
||
# there is no good way to detect what part of the flow is intercepted,
|
||
# so we apply some heuristics to see if it's the HTTP response.
|
||
websocket_started = flow.websocket and len(flow.websocket.messages) != 0
|
||
response_is_intercepted = (
|
||
self.flow.intercepted and flow.response and not websocket_started
|
||
)
|
||
if response_is_intercepted:
|
||
return "Response intercepted"
|
||
else:
|
||
return "Response"
|
||
|
||
def tab_dns_request(self) -> str:
|
||
flow = self.flow
|
||
assert isinstance(flow, dns.DNSFlow)
|
||
if self.flow.intercepted and not flow.response:
|
||
return "Request intercepted"
|
||
else:
|
||
return "Request"
|
||
|
||
def tab_dns_response(self) -> str:
|
||
flow = self.flow
|
||
assert isinstance(flow, dns.DNSFlow)
|
||
if self.flow.intercepted and flow.response:
|
||
return "Response intercepted"
|
||
else:
|
||
return "Response"
|
||
|
||
def tab_tcp_stream(self):
|
||
return "TCP Stream"
|
||
|
||
def tab_udp_stream(self):
|
||
return "UDP Stream"
|
||
|
||
def tab_websocket_messages(self):
|
||
flow = self.flow
|
||
assert isinstance(flow, http.HTTPFlow)
|
||
assert flow.websocket
|
||
|
||
if self.flow.intercepted and len(flow.websocket.messages) != 0:
|
||
return "WebSocket Messages intercepted"
|
||
else:
|
||
return "WebSocket Messages"
|
||
|
||
def tab_details(self):
|
||
return "Detail"
|
||
|
||
def view_request(self):
|
||
flow = self.flow
|
||
assert isinstance(flow, http.HTTPFlow)
|
||
return self.conn_text(flow.request)
|
||
|
||
def view_response(self):
|
||
flow = self.flow
|
||
assert isinstance(flow, http.HTTPFlow)
|
||
return self.conn_text(flow.response)
|
||
|
||
def view_dns_request(self):
|
||
flow = self.flow
|
||
assert isinstance(flow, dns.DNSFlow)
|
||
return self.dns_message_text("request", flow.request)
|
||
|
||
def view_dns_response(self):
|
||
flow = self.flow
|
||
assert isinstance(flow, dns.DNSFlow)
|
||
return self.dns_message_text("response", flow.response)
|
||
|
||
def _contentview_status_bar(self, description: str, viewmode: str):
|
||
cols = [
|
||
urwid.Text(
|
||
[
|
||
("heading", description),
|
||
]
|
||
),
|
||
urwid.Text(
|
||
[
|
||
" ",
|
||
("heading", "["),
|
||
("heading_key", "m"),
|
||
("heading", (":%s]" % viewmode)),
|
||
],
|
||
align="right",
|
||
),
|
||
]
|
||
contentview_status_bar = urwid.AttrMap(urwid.Columns(cols), "heading")
|
||
return contentview_status_bar
|
||
|
||
FROM_CLIENT_MARKER = ("from_client", f"{common.SYMBOL_FROM_CLIENT} ")
|
||
TO_CLIENT_MARKER = ("to_client", f"{common.SYMBOL_TO_CLIENT} ")
|
||
|
||
def view_websocket_messages(self):
|
||
flow = self.flow
|
||
assert isinstance(flow, http.HTTPFlow)
|
||
assert flow.websocket is not None
|
||
|
||
if not flow.websocket.messages:
|
||
return searchable.Searchable([urwid.Text(("highlight", "No messages."))])
|
||
|
||
viewmode = self.master.commands.call("console.flowview.mode")
|
||
|
||
widget_lines = []
|
||
for m in flow.websocket.messages:
|
||
pretty = contentviews.prettify_message(m, flow, viewmode)
|
||
chunks = mitmproxy_rs.syntax_highlight.highlight(
|
||
pretty.text,
|
||
language=pretty.syntax_highlight,
|
||
)
|
||
if m.from_client:
|
||
marker = self.FROM_CLIENT_MARKER
|
||
else:
|
||
marker = self.TO_CLIENT_MARKER
|
||
widget_lines.append(urwid.Text([marker, *chunks]))
|
||
|
||
if flow.websocket.closed_by_client is not None:
|
||
widget_lines.append(
|
||
urwid.Text(
|
||
[
|
||
(
|
||
self.FROM_CLIENT_MARKER
|
||
if flow.websocket.closed_by_client
|
||
else self.TO_CLIENT_MARKER
|
||
),
|
||
(
|
||
"alert"
|
||
if flow.websocket.close_code in (1000, 1001, 1005)
|
||
else "error",
|
||
f"Connection closed: {flow.websocket.close_code} {flow.websocket.close_reason}",
|
||
),
|
||
]
|
||
)
|
||
)
|
||
|
||
if flow.intercepted:
|
||
markup = widget_lines[-1].get_text()[0]
|
||
widget_lines[-1].set_text(("intercept", markup))
|
||
|
||
widget_lines.insert(
|
||
0, self._contentview_status_bar(viewmode.capitalize(), viewmode)
|
||
)
|
||
|
||
if (last_view := self.last_displayed_websocket_messages) is not None:
|
||
last_view.walker[:] = widget_lines
|
||
view = last_view
|
||
else:
|
||
view = searchable.Searchable(widget_lines)
|
||
self.last_displayed_websocket_messages = view
|
||
|
||
return view
|
||
|
||
def view_message_stream(self) -> urwid.Widget:
|
||
flow = self.flow
|
||
assert isinstance(flow, (tcp.TCPFlow, udp.UDPFlow))
|
||
|
||
if not flow.messages:
|
||
return searchable.Searchable([urwid.Text(("highlight", "No messages."))])
|
||
|
||
viewmode = self.master.commands.call("console.flowview.mode")
|
||
|
||
widget_lines = []
|
||
for m in flow.messages:
|
||
if m.from_client:
|
||
marker = self.FROM_CLIENT_MARKER
|
||
else:
|
||
marker = self.TO_CLIENT_MARKER
|
||
pretty = contentviews.prettify_message(m, flow, viewmode)
|
||
chunks = mitmproxy_rs.syntax_highlight.highlight(
|
||
pretty.text,
|
||
language=pretty.syntax_highlight,
|
||
)
|
||
|
||
widget_lines.append(urwid.Text([marker, *chunks]))
|
||
|
||
if flow.intercepted:
|
||
markup = widget_lines[-1].get_text()[0]
|
||
widget_lines[-1].set_text(("intercept", markup))
|
||
|
||
widget_lines.insert(
|
||
0, self._contentview_status_bar(viewmode.capitalize(), viewmode)
|
||
)
|
||
|
||
return searchable.Searchable(widget_lines)
|
||
|
||
def view_details(self):
|
||
return flowdetailview.flowdetails(self.view, self.flow)
|
||
|
||
def content_view(
|
||
self, viewmode: str, message: http.Message
|
||
) -> tuple[str, list[urwid.Text]]:
|
||
if message.raw_content is None:
|
||
return "", [urwid.Text([("error", "[content missing]")])]
|
||
|
||
if message.raw_content == b"":
|
||
if isinstance(message, http.Request):
|
||
query = getattr(message, "query", "")
|
||
if not query:
|
||
# No body and no query params
|
||
return "", [urwid.Text("No request content")]
|
||
# else: there are query params -> fall through to render them
|
||
else:
|
||
return "", [urwid.Text("No content")]
|
||
|
||
full = self.master.commands.execute(
|
||
"view.settings.getval @focus fullcontents false"
|
||
)
|
||
|
||
if full == "true":
|
||
limit = sys.maxsize
|
||
else:
|
||
limit = ctx.options.content_view_lines_cutoff
|
||
|
||
flow_modify_cache_invalidation = hash(
|
||
(
|
||
message.raw_content,
|
||
message.headers.fields,
|
||
getattr(message, "path", None),
|
||
)
|
||
)
|
||
# we need to pass the message off-band because it's not hashable
|
||
self._get_content_view_message = message
|
||
return self._get_content_view(viewmode, limit, flow_modify_cache_invalidation)
|
||
|
||
@lru_cache(maxsize=200)
|
||
def _get_content_view(
|
||
self, viewmode: str, max_lines: int, _
|
||
) -> tuple[str, list[urwid.Text]]:
|
||
message: http.Message = self._get_content_view_message
|
||
self._get_content_view_message = None # type: ignore[assignment]
|
||
|
||
pretty = contentviews.prettify_message(message, self.flow, viewmode)
|
||
cut_off = strutils.cut_after_n_lines(pretty.text, max_lines)
|
||
|
||
chunks = mitmproxy_rs.syntax_highlight.highlight(
|
||
cut_off,
|
||
language=pretty.syntax_highlight,
|
||
)
|
||
|
||
text_objects = [urwid.Text(chunks)]
|
||
if len(cut_off) < len(pretty.text):
|
||
text_objects.append(
|
||
urwid.Text(
|
||
[
|
||
(
|
||
"highlight",
|
||
"Stopped displaying data after %d lines. Press "
|
||
% max_lines,
|
||
),
|
||
("key", "f"),
|
||
("highlight", " to load all data."),
|
||
]
|
||
)
|
||
)
|
||
|
||
return f"{pretty.view_name} {pretty.description}", text_objects
|
||
|
||
def conn_text(self, conn):
|
||
if conn:
|
||
hdrs = []
|
||
for k, v in conn.headers.fields:
|
||
# This will always force an ascii representation of headers. For example, if the server sends a
|
||
#
|
||
# X-Authors: Made with ❤ in Hamburg
|
||
#
|
||
# header, mitmproxy will display the following:
|
||
#
|
||
# X-Authors: Made with \xe2\x9d\xa4 in Hamburg.
|
||
#
|
||
# The alternative would be to just use the header's UTF-8 representation and maybe
|
||
# do `str.replace("\t", "\\t")` to exempt tabs from urwid's special characters escaping [1].
|
||
# That would in some terminals allow rendering UTF-8 characters, but the mapping
|
||
# wouldn't be bijective, i.e. a user couldn't distinguish "\\t" and "\t".
|
||
# Also, from a security perspective, a mitmproxy user couldn't be fooled by homoglyphs.
|
||
#
|
||
# 1) https://github.com/mitmproxy/mitmproxy/issues/1833
|
||
# https://github.com/urwid/urwid/blob/6608ee2c9932d264abd1171468d833b7a4082e13/urwid/display_common.py#L35-L36,
|
||
|
||
k = strutils.bytes_to_escaped_str(k) + ":"
|
||
v = strutils.bytes_to_escaped_str(v)
|
||
hdrs.append((k, v))
|
||
txt = common.format_keyvals(hdrs, key_format="header")
|
||
viewmode = self.master.commands.call("console.flowview.mode")
|
||
msg, body = self.content_view(viewmode, conn)
|
||
|
||
txt.append(self._contentview_status_bar(msg, viewmode))
|
||
txt.extend(body)
|
||
else:
|
||
txt = [
|
||
urwid.Text(""),
|
||
urwid.Text(
|
||
[
|
||
("highlight", "No response. Press "),
|
||
("key", "e"),
|
||
("highlight", " and edit any aspect to add one."),
|
||
]
|
||
),
|
||
]
|
||
return searchable.Searchable(txt)
|
||
|
||
def dns_message_text(
|
||
self, type: str, message: DNSMessage | None
|
||
) -> searchable.Searchable:
|
||
"""
|
||
Alternative:
|
||
if not message:
|
||
return searchable.Searchable([urwid.Text(("highlight", f"No {typ}."))])
|
||
|
||
viewmode = self.master.commands.call("console.flowview.mode")
|
||
pretty = contentviews.prettify_message(message, flow, viewmode)
|
||
chunks = mitmproxy_rs.syntax_highlight.highlight(
|
||
pretty.text,
|
||
language=pretty.syntax_highlight,
|
||
)
|
||
|
||
widget_lines = [
|
||
self._contentview_status_bar(viewmode.capitalize(), viewmode),
|
||
urwid.Text(chunks)
|
||
]
|
||
return searchable.Searchable(widget_lines)
|
||
"""
|
||
# Keep in sync with web/src/js/components/FlowView/DnsMessages.tsx
|
||
if message:
|
||
|
||
def rr_text(rr: dns.ResourceRecord):
|
||
return urwid.Text(
|
||
f" {rr.name} {dns.types.to_str(rr.type)} {dns.classes.to_str(rr.class_)} {rr.ttl} {rr}"
|
||
)
|
||
|
||
txt = []
|
||
txt.append(
|
||
urwid.Text(
|
||
"{recursive}Question".format(
|
||
recursive="Recursive " if message.recursion_desired else "",
|
||
)
|
||
)
|
||
)
|
||
txt.extend(
|
||
urwid.Text(
|
||
f" {q.name} {dns.types.to_str(q.type)} {dns.classes.to_str(q.class_)}"
|
||
)
|
||
for q in message.questions
|
||
)
|
||
txt.append(urwid.Text(""))
|
||
txt.append(
|
||
urwid.Text(
|
||
"{authoritative}{recursive}Answer".format(
|
||
authoritative="Authoritative "
|
||
if message.authoritative_answer
|
||
else "",
|
||
recursive="Recursive " if message.recursion_available else "",
|
||
)
|
||
)
|
||
)
|
||
txt.extend(map(rr_text, message.answers))
|
||
txt.append(urwid.Text(""))
|
||
txt.append(urwid.Text("Authority"))
|
||
txt.extend(map(rr_text, message.authorities))
|
||
txt.append(urwid.Text(""))
|
||
txt.append(urwid.Text("Addition"))
|
||
txt.extend(map(rr_text, message.additionals))
|
||
return searchable.Searchable(txt)
|
||
else:
|
||
return searchable.Searchable([urwid.Text(("highlight", f"No {type}."))])
|
||
|
||
|
||
class FlowView(urwid.Frame, layoutwidget.LayoutWidget):
|
||
keyctx = "flowview"
|
||
title = "Flow Details"
|
||
|
||
def __init__(self, master):
|
||
super().__init__(
|
||
FlowDetails(master),
|
||
header=FlowViewHeader(master),
|
||
)
|
||
self.master = master
|
||
|
||
def focus_changed(self, *args, **kwargs):
|
||
self.body.focus_changed()
|
||
self.header.focus_changed()
|