Skip to content

Commit fa89055

Browse files
committed
web: add token-based authentication for the web ui API
1 parent ea0dcb0 commit fa89055

File tree

5 files changed

+163
-44
lines changed

5 files changed

+163
-44
lines changed

mitmproxy/tools/web/app.py

+82-35
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
from __future__ import annotations
22

3+
import functools
34
import hashlib
5+
import hmac
46
import json
57
import logging
68
import os.path
@@ -12,6 +14,7 @@
1214
from io import BytesIO
1315
from itertools import islice
1416
from typing import ClassVar
17+
from typing import Concatenate
1518

1619
import tornado.escape
1720
import tornado.web
@@ -206,7 +209,54 @@ class APIError(tornado.web.HTTPError):
206209
pass
207210

208211

209-
class RequestHandler(tornado.web.RequestHandler):
212+
class AuthRequestHandler(tornado.web.RequestHandler):
213+
AUTH_COOKIE_NAME = "mitmproxy-auth"
214+
AUTH_COOKIE_VALUE = b"y"
215+
216+
def __init_subclass__(cls, **kwargs):
217+
"""Automatically wrap all request handlers with `_require_auth`."""
218+
for method in cls.SUPPORTED_METHODS:
219+
method = method.lower()
220+
fn = getattr(cls, method)
221+
if fn is not tornado.web.RequestHandler._unimplemented_method:
222+
setattr(cls, method, AuthRequestHandler._require_auth(fn))
223+
224+
@staticmethod
225+
def _require_auth[**P, R](
226+
fn: Callable[Concatenate[AuthRequestHandler, P], R],
227+
) -> Callable[Concatenate[AuthRequestHandler, P], R | None]:
228+
@functools.wraps(fn)
229+
def wrapper(
230+
self: AuthRequestHandler, *args: P.args, **kwargs: P.kwargs
231+
) -> R | None:
232+
if not self.current_user:
233+
self.require_setting("auth_token", "AuthRequestHandler")
234+
if not hmac.compare_digest(
235+
self.get_query_argument("token", default="invalid"),
236+
self.settings["auth_token"],
237+
):
238+
self.set_status(403)
239+
self.render("login.html")
240+
return None
241+
self.set_signed_cookie(
242+
self.AUTH_COOKIE_NAME,
243+
self.AUTH_COOKIE_VALUE,
244+
expires_days=400,
245+
httponly=True,
246+
samesite="Strict",
247+
)
248+
return fn(self, *args, **kwargs)
249+
250+
return wrapper
251+
252+
def get_current_user(self) -> bool:
253+
return (
254+
self.get_signed_cookie(self.AUTH_COOKIE_NAME, min_version=2)
255+
== self.AUTH_COOKIE_VALUE
256+
)
257+
258+
259+
class RequestHandler(AuthRequestHandler):
210260
application: Application
211261

212262
def write(self, chunk: str | bytes | dict | list):
@@ -298,7 +348,7 @@ def get(self):
298348
self.write(dict(commands=flowfilter.help))
299349

300350

301-
class WebSocketEventBroadcaster(tornado.websocket.WebSocketHandler):
351+
class WebSocketEventBroadcaster(tornado.websocket.WebSocketHandler, AuthRequestHandler):
302352
# raise an error if inherited class doesn't specify its own instance.
303353
connections: ClassVar[set[WebSocketEventBroadcaster]]
304354

@@ -717,6 +767,34 @@ class GZipContentAndFlowFiles(tornado.web.GZipContentEncoding):
717767
}
718768

719769

770+
handlers = [
771+
(r"/", IndexHandler),
772+
(r"/filter-help(?:\.json)?", FilterHelp),
773+
(r"/updates", ClientConnection),
774+
(r"/commands(?:\.json)?", Commands),
775+
(r"/commands/(?P<cmd>[a-z.]+)", ExecuteCommand),
776+
(r"/events(?:\.json)?", Events),
777+
(r"/flows(?:\.json)?", Flows),
778+
(r"/flows/dump", DumpFlows),
779+
(r"/flows/resume", ResumeFlows),
780+
(r"/flows/kill", KillFlows),
781+
(r"/flows/(?P<flow_id>[0-9a-f\-]+)", FlowHandler),
782+
(r"/flows/(?P<flow_id>[0-9a-f\-]+)/resume", ResumeFlow),
783+
(r"/flows/(?P<flow_id>[0-9a-f\-]+)/kill", KillFlow),
784+
(r"/flows/(?P<flow_id>[0-9a-f\-]+)/duplicate", DuplicateFlow),
785+
(r"/flows/(?P<flow_id>[0-9a-f\-]+)/replay", ReplayFlow),
786+
(r"/flows/(?P<flow_id>[0-9a-f\-]+)/revert", RevertFlow),
787+
(r"/flows/(?P<flow_id>[0-9a-f\-]+)/(?P<message>request|response|messages)/content.data", FlowContent),
788+
(r"/flows/(?P<flow_id>[0-9a-f\-]+)/(?P<message>request|response|messages)/content/(?P<content_view>[0-9a-zA-Z\-\_%]+)(?:\.json)?", FlowContentView),
789+
(r"/clear", ClearAll),
790+
(r"/options(?:\.json)?", Options),
791+
(r"/options/save", SaveOptions),
792+
(r"/state(?:\.json)?", State),
793+
(r"/processes", ProcessList),
794+
(r"/executable-icon", ProcessImage),
795+
] # fmt: skip
796+
797+
720798
class Application(tornado.web.Application):
721799
master: mitmproxy.tools.web.master.WebMaster
722800

@@ -734,43 +812,12 @@ def __init__(
734812
debug=debug,
735813
autoreload=False,
736814
transforms=[GZipContentAndFlowFiles],
815+
auth_token=secrets.token_hex(16),
737816
)
738817

739818
self.add_handlers("dns-rebind-protection", [(r"/.*", DnsRebind)])
740819
self.add_handlers(
741820
# make mitmweb accessible by IP only to prevent DNS rebinding.
742821
r"^(localhost|[0-9.]+|\[[0-9a-fA-F:]+\])$",
743-
[
744-
(r"/", IndexHandler),
745-
(r"/filter-help(?:\.json)?", FilterHelp),
746-
(r"/updates", ClientConnection),
747-
(r"/commands(?:\.json)?", Commands),
748-
(r"/commands/(?P<cmd>[a-z.]+)", ExecuteCommand),
749-
(r"/events(?:\.json)?", Events),
750-
(r"/flows(?:\.json)?", Flows),
751-
(r"/flows/dump", DumpFlows),
752-
(r"/flows/resume", ResumeFlows),
753-
(r"/flows/kill", KillFlows),
754-
(r"/flows/(?P<flow_id>[0-9a-f\-]+)", FlowHandler),
755-
(r"/flows/(?P<flow_id>[0-9a-f\-]+)/resume", ResumeFlow),
756-
(r"/flows/(?P<flow_id>[0-9a-f\-]+)/kill", KillFlow),
757-
(r"/flows/(?P<flow_id>[0-9a-f\-]+)/duplicate", DuplicateFlow),
758-
(r"/flows/(?P<flow_id>[0-9a-f\-]+)/replay", ReplayFlow),
759-
(r"/flows/(?P<flow_id>[0-9a-f\-]+)/revert", RevertFlow),
760-
(
761-
r"/flows/(?P<flow_id>[0-9a-f\-]+)/(?P<message>request|response|messages)/content.data",
762-
FlowContent,
763-
),
764-
(
765-
r"/flows/(?P<flow_id>[0-9a-f\-]+)/(?P<message>request|response|messages)/"
766-
r"content/(?P<content_view>[0-9a-zA-Z\-\_%]+)(?:\.json)?",
767-
FlowContentView,
768-
),
769-
(r"/clear", ClearAll),
770-
(r"/options(?:\.json)?", Options),
771-
(r"/options/save", SaveOptions),
772-
(r"/state(?:\.json)?", State),
773-
(r"/processes", ProcessList),
774-
(r"/executable-icon", ProcessImage),
775-
],
822+
handlers, # type: ignore # https://github.com/tornadoweb/tornado/pull/3455
776823
)

mitmproxy/tools/web/master.py

+6-3
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,11 @@ def _sig_servers_changed(self) -> None:
9393
},
9494
)
9595

96+
@property
97+
def web_url(self) -> str:
98+
# noinspection HttpUrlsUsage
99+
return f"http://{self.options.web_host}:{self.options.web_port}/?token={self.app.settings["auth_token"]}"
100+
96101
async def running(self):
97102
# Register tornado with the current event loop
98103
tornado.ioloop.IOLoop.current()
@@ -109,8 +114,6 @@ async def running(self):
109114
message += f"\nTry specifying a different port by using `--set web_port={self.options.web_port + 2}`."
110115
raise OSError(e.errno, message, e.filename) from e
111116

112-
logger.info(
113-
f"Web server listening at http://{self.options.web_host}:{self.options.web_port}/",
114-
)
117+
logger.info(f"Web server listening at {self.web_url}")
115118

116119
return await super().running()
+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8">
5+
<title>mitmproxy</title>
6+
<link rel="icon" href=".{{ static_url('images/favicon.ico') }}" type="image/x-icon"/>
7+
<meta name="viewport" content="width=device-width, initial-scale=1" />
8+
<style>
9+
body {
10+
font-family: sans-serif;
11+
display: flex;
12+
flex-direction: column;
13+
align-items: center;
14+
}
15+
input {
16+
font-family: monospace;
17+
}
18+
</style>
19+
</head>
20+
<body>
21+
<h1>403 Auth Token Required</h1>
22+
<p>To access mitmproxy, please enter the authentication token printed in the console below.</p>
23+
<form method="GET">
24+
<label>
25+
<input type="password" name="token" size="32" placeholder="" />
26+
</label>
27+
<input type="submit" />
28+
</form>
29+
</body>
30+
</html>

mitmproxy/tools/web/webaddons.py

+9-3
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,16 @@
1+
from __future__ import annotations
2+
13
import logging
24
import webbrowser
35
from collections.abc import Sequence
6+
from typing import TYPE_CHECKING
47

58
from mitmproxy import ctx
69
from mitmproxy.tools.web.web_columns import AVAILABLE_WEB_COLUMNS
710

11+
if TYPE_CHECKING:
12+
from mitmproxy.tools.web.master import WebMaster
13+
814

915
class WebAddon:
1016
def load(self, loader):
@@ -21,11 +27,11 @@ def load(self, loader):
2127

2228
def running(self):
2329
if hasattr(ctx.options, "web_open_browser") and ctx.options.web_open_browser:
24-
web_url = f"http://{ctx.options.web_host}:{ctx.options.web_port}/"
25-
success = open_browser(web_url)
30+
master: WebMaster = ctx.master # type: ignore
31+
success = open_browser(master.web_url)
2632
if not success:
2733
logging.info(
28-
f"No web browser found. Please open a browser and point it to {web_url}",
34+
f"No web browser found. Please open a browser and point it to {master.web_url}",
2935
)
3036

3137

test/mitmproxy/tools/web/test_app.py

+36-3
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
import tornado.testing
1010
from tornado import httpclient
1111
from tornado import websocket
12+
from tornado.web import create_signed_value
1213

1314
import mitmproxy_rs
1415
from mitmproxy import log
@@ -45,6 +46,11 @@ async def test_generated_files(filename):
4546
)
4647

4748

49+
def test_all_handlers_have_auth():
50+
for _, handler in app.handlers:
51+
assert issubclass(handler, app.AuthRequestHandler)
52+
53+
4854
@pytest.mark.usefixtures("no_tornado_logging", "tdata")
4955
class TestApp(tornado.testing.AsyncHTTPTestCase):
5056
def get_app(self):
@@ -73,7 +79,17 @@ async def make_master() -> webmaster.WebMaster:
7379
webapp.settings["xsrf_cookies"] = False
7480
return webapp
7581

82+
@property
83+
def auth_cookie(self) -> str:
84+
auth_cookie = create_signed_value(
85+
secret=self._app.settings["cookie_secret"],
86+
name=app.AuthRequestHandler.AUTH_COOKIE_NAME,
87+
value=app.AuthRequestHandler.AUTH_COOKIE_VALUE,
88+
).decode()
89+
return f"{app.AuthRequestHandler.AUTH_COOKIE_NAME}={auth_cookie}"
90+
7691
def fetch(self, *args, **kwargs) -> httpclient.HTTPResponse:
92+
kwargs.setdefault("headers", {}).setdefault("Cookie", self.auth_cookie)
7793
# tornado disallows POST without content by default.
7894
return super().fetch(*args, **kwargs, allow_nonstandard_methods=True)
7995

@@ -379,9 +395,12 @@ def test_err(self):
379395

380396
@tornado.testing.gen_test
381397
def test_websocket(self):
382-
ws_url = f"ws://localhost:{self.get_http_port()}/updates"
398+
ws_req = httpclient.HTTPRequest(
399+
f"ws://localhost:{self.get_http_port()}/updates",
400+
headers={"Cookie": self.auth_cookie},
401+
)
383402

384-
ws_client = yield websocket.websocket_connect(ws_url)
403+
ws_client = yield websocket.websocket_connect(ws_req)
385404
self.master.options.anticomp = True
386405

387406
r1 = yield ws_client.read_message()
@@ -402,7 +421,7 @@ def test_websocket(self):
402421
ws_client.close()
403422

404423
# trigger on_close by opening a second connection.
405-
ws_client2 = yield websocket.websocket_connect(ws_url)
424+
ws_client2 = yield websocket.websocket_connect(ws_req)
406425
ws_client2.close()
407426

408427
def test_process_list(self):
@@ -440,3 +459,17 @@ def test_xsrf_hardening_app(self):
440459
assert resp.code == 412
441460
assert b"xsrf" not in resp.body
442461
assert b"xsrf" in self.fetch("/", headers={"Sec-Fetch-Mode": "navigate"}).body
462+
463+
def test_unauthorized_api(self):
464+
assert self.fetch("/", headers={"Cookie": ""}).code == 403
465+
466+
@tornado.testing.gen_test
467+
def test_unauthorized_websocket(self):
468+
try:
469+
yield websocket.websocket_connect(
470+
f"ws://localhost:{self.get_http_port()}/updates"
471+
)
472+
except httpclient.HTTPClientError as e:
473+
assert e.code == 403
474+
else:
475+
assert False

0 commit comments

Comments
 (0)