From fa89055e196d953f11fd241e36ee37858993486a Mon Sep 17 00:00:00 2001 From: Maximilian Hils Date: Fri, 17 Jan 2025 19:13:02 +0100 Subject: [PATCH] web: add token-based authentication for the web ui API --- mitmproxy/tools/web/app.py | 117 ++++++++++++++++------- mitmproxy/tools/web/master.py | 9 +- mitmproxy/tools/web/templates/login.html | 30 ++++++ mitmproxy/tools/web/webaddons.py | 12 ++- test/mitmproxy/tools/web/test_app.py | 39 +++++++- 5 files changed, 163 insertions(+), 44 deletions(-) create mode 100644 mitmproxy/tools/web/templates/login.html diff --git a/mitmproxy/tools/web/app.py b/mitmproxy/tools/web/app.py index fe6a0da80..837debfae 100644 --- a/mitmproxy/tools/web/app.py +++ b/mitmproxy/tools/web/app.py @@ -1,6 +1,8 @@ from __future__ import annotations +import functools import hashlib +import hmac import json import logging import os.path @@ -12,6 +14,7 @@ from collections.abc import Sequence from io import BytesIO from itertools import islice from typing import ClassVar +from typing import Concatenate import tornado.escape import tornado.web @@ -206,7 +209,54 @@ class APIError(tornado.web.HTTPError): pass -class RequestHandler(tornado.web.RequestHandler): +class AuthRequestHandler(tornado.web.RequestHandler): + AUTH_COOKIE_NAME = "mitmproxy-auth" + AUTH_COOKIE_VALUE = b"y" + + def __init_subclass__(cls, **kwargs): + """Automatically wrap all request handlers with `_require_auth`.""" + for method in cls.SUPPORTED_METHODS: + method = method.lower() + fn = getattr(cls, method) + if fn is not tornado.web.RequestHandler._unimplemented_method: + setattr(cls, method, AuthRequestHandler._require_auth(fn)) + + @staticmethod + def _require_auth[**P, R]( + fn: Callable[Concatenate[AuthRequestHandler, P], R], + ) -> Callable[Concatenate[AuthRequestHandler, P], R | None]: + @functools.wraps(fn) + def wrapper( + self: AuthRequestHandler, *args: P.args, **kwargs: P.kwargs + ) -> R | None: + if not self.current_user: + self.require_setting("auth_token", "AuthRequestHandler") + if not hmac.compare_digest( + self.get_query_argument("token", default="invalid"), + self.settings["auth_token"], + ): + self.set_status(403) + self.render("login.html") + return None + self.set_signed_cookie( + self.AUTH_COOKIE_NAME, + self.AUTH_COOKIE_VALUE, + expires_days=400, + httponly=True, + samesite="Strict", + ) + return fn(self, *args, **kwargs) + + return wrapper + + def get_current_user(self) -> bool: + return ( + self.get_signed_cookie(self.AUTH_COOKIE_NAME, min_version=2) + == self.AUTH_COOKIE_VALUE + ) + + +class RequestHandler(AuthRequestHandler): application: Application def write(self, chunk: str | bytes | dict | list): @@ -298,7 +348,7 @@ class FilterHelp(RequestHandler): self.write(dict(commands=flowfilter.help)) -class WebSocketEventBroadcaster(tornado.websocket.WebSocketHandler): +class WebSocketEventBroadcaster(tornado.websocket.WebSocketHandler, AuthRequestHandler): # raise an error if inherited class doesn't specify its own instance. connections: ClassVar[set[WebSocketEventBroadcaster]] @@ -717,6 +767,34 @@ class GZipContentAndFlowFiles(tornado.web.GZipContentEncoding): } +handlers = [ + (r"/", IndexHandler), + (r"/filter-help(?:\.json)?", FilterHelp), + (r"/updates", ClientConnection), + (r"/commands(?:\.json)?", Commands), + (r"/commands/(?P[a-z.]+)", ExecuteCommand), + (r"/events(?:\.json)?", Events), + (r"/flows(?:\.json)?", Flows), + (r"/flows/dump", DumpFlows), + (r"/flows/resume", ResumeFlows), + (r"/flows/kill", KillFlows), + (r"/flows/(?P[0-9a-f\-]+)", FlowHandler), + (r"/flows/(?P[0-9a-f\-]+)/resume", ResumeFlow), + (r"/flows/(?P[0-9a-f\-]+)/kill", KillFlow), + (r"/flows/(?P[0-9a-f\-]+)/duplicate", DuplicateFlow), + (r"/flows/(?P[0-9a-f\-]+)/replay", ReplayFlow), + (r"/flows/(?P[0-9a-f\-]+)/revert", RevertFlow), + (r"/flows/(?P[0-9a-f\-]+)/(?Prequest|response|messages)/content.data", FlowContent), + (r"/flows/(?P[0-9a-f\-]+)/(?Prequest|response|messages)/content/(?P[0-9a-zA-Z\-\_%]+)(?:\.json)?", FlowContentView), + (r"/clear", ClearAll), + (r"/options(?:\.json)?", Options), + (r"/options/save", SaveOptions), + (r"/state(?:\.json)?", State), + (r"/processes", ProcessList), + (r"/executable-icon", ProcessImage), +] # fmt: skip + + class Application(tornado.web.Application): master: mitmproxy.tools.web.master.WebMaster @@ -734,43 +812,12 @@ class Application(tornado.web.Application): debug=debug, autoreload=False, transforms=[GZipContentAndFlowFiles], + auth_token=secrets.token_hex(16), ) self.add_handlers("dns-rebind-protection", [(r"/.*", DnsRebind)]) self.add_handlers( # make mitmweb accessible by IP only to prevent DNS rebinding. r"^(localhost|[0-9.]+|\[[0-9a-fA-F:]+\])$", - [ - (r"/", IndexHandler), - (r"/filter-help(?:\.json)?", FilterHelp), - (r"/updates", ClientConnection), - (r"/commands(?:\.json)?", Commands), - (r"/commands/(?P[a-z.]+)", ExecuteCommand), - (r"/events(?:\.json)?", Events), - (r"/flows(?:\.json)?", Flows), - (r"/flows/dump", DumpFlows), - (r"/flows/resume", ResumeFlows), - (r"/flows/kill", KillFlows), - (r"/flows/(?P[0-9a-f\-]+)", FlowHandler), - (r"/flows/(?P[0-9a-f\-]+)/resume", ResumeFlow), - (r"/flows/(?P[0-9a-f\-]+)/kill", KillFlow), - (r"/flows/(?P[0-9a-f\-]+)/duplicate", DuplicateFlow), - (r"/flows/(?P[0-9a-f\-]+)/replay", ReplayFlow), - (r"/flows/(?P[0-9a-f\-]+)/revert", RevertFlow), - ( - r"/flows/(?P[0-9a-f\-]+)/(?Prequest|response|messages)/content.data", - FlowContent, - ), - ( - r"/flows/(?P[0-9a-f\-]+)/(?Prequest|response|messages)/" - r"content/(?P[0-9a-zA-Z\-\_%]+)(?:\.json)?", - FlowContentView, - ), - (r"/clear", ClearAll), - (r"/options(?:\.json)?", Options), - (r"/options/save", SaveOptions), - (r"/state(?:\.json)?", State), - (r"/processes", ProcessList), - (r"/executable-icon", ProcessImage), - ], + handlers, # type: ignore # https://github.com/tornadoweb/tornado/pull/3455 ) diff --git a/mitmproxy/tools/web/master.py b/mitmproxy/tools/web/master.py index 5ec3ac602..a16a289e2 100644 --- a/mitmproxy/tools/web/master.py +++ b/mitmproxy/tools/web/master.py @@ -93,6 +93,11 @@ class WebMaster(master.Master): }, ) + @property + def web_url(self) -> str: + # noinspection HttpUrlsUsage + return f"http://{self.options.web_host}:{self.options.web_port}/?token={self.app.settings["auth_token"]}" + async def running(self): # Register tornado with the current event loop tornado.ioloop.IOLoop.current() @@ -109,8 +114,6 @@ class WebMaster(master.Master): message += f"\nTry specifying a different port by using `--set web_port={self.options.web_port + 2}`." raise OSError(e.errno, message, e.filename) from e - logger.info( - f"Web server listening at http://{self.options.web_host}:{self.options.web_port}/", - ) + logger.info(f"Web server listening at {self.web_url}") return await super().running() diff --git a/mitmproxy/tools/web/templates/login.html b/mitmproxy/tools/web/templates/login.html new file mode 100644 index 000000000..9f06a1bc9 --- /dev/null +++ b/mitmproxy/tools/web/templates/login.html @@ -0,0 +1,30 @@ + + + + + mitmproxy + + + + + +

403 Auth Token Required

+

To access mitmproxy, please enter the authentication token printed in the console below.

+
+ + +
+ + diff --git a/mitmproxy/tools/web/webaddons.py b/mitmproxy/tools/web/webaddons.py index 7aa209d13..1656035b6 100644 --- a/mitmproxy/tools/web/webaddons.py +++ b/mitmproxy/tools/web/webaddons.py @@ -1,10 +1,16 @@ +from __future__ import annotations + import logging import webbrowser from collections.abc import Sequence +from typing import TYPE_CHECKING from mitmproxy import ctx from mitmproxy.tools.web.web_columns import AVAILABLE_WEB_COLUMNS +if TYPE_CHECKING: + from mitmproxy.tools.web.master import WebMaster + class WebAddon: def load(self, loader): @@ -21,11 +27,11 @@ class WebAddon: def running(self): if hasattr(ctx.options, "web_open_browser") and ctx.options.web_open_browser: - web_url = f"http://{ctx.options.web_host}:{ctx.options.web_port}/" - success = open_browser(web_url) + master: WebMaster = ctx.master # type: ignore + success = open_browser(master.web_url) if not success: logging.info( - f"No web browser found. Please open a browser and point it to {web_url}", + f"No web browser found. Please open a browser and point it to {master.web_url}", ) diff --git a/test/mitmproxy/tools/web/test_app.py b/test/mitmproxy/tools/web/test_app.py index 28158ec6b..25c2f6b76 100644 --- a/test/mitmproxy/tools/web/test_app.py +++ b/test/mitmproxy/tools/web/test_app.py @@ -9,6 +9,7 @@ import pytest import tornado.testing from tornado import httpclient from tornado import websocket +from tornado.web import create_signed_value import mitmproxy_rs from mitmproxy import log @@ -45,6 +46,11 @@ async def test_generated_files(filename): ) +def test_all_handlers_have_auth(): + for _, handler in app.handlers: + assert issubclass(handler, app.AuthRequestHandler) + + @pytest.mark.usefixtures("no_tornado_logging", "tdata") class TestApp(tornado.testing.AsyncHTTPTestCase): def get_app(self): @@ -73,7 +79,17 @@ class TestApp(tornado.testing.AsyncHTTPTestCase): webapp.settings["xsrf_cookies"] = False return webapp + @property + def auth_cookie(self) -> str: + auth_cookie = create_signed_value( + secret=self._app.settings["cookie_secret"], + name=app.AuthRequestHandler.AUTH_COOKIE_NAME, + value=app.AuthRequestHandler.AUTH_COOKIE_VALUE, + ).decode() + return f"{app.AuthRequestHandler.AUTH_COOKIE_NAME}={auth_cookie}" + def fetch(self, *args, **kwargs) -> httpclient.HTTPResponse: + kwargs.setdefault("headers", {}).setdefault("Cookie", self.auth_cookie) # tornado disallows POST without content by default. return super().fetch(*args, **kwargs, allow_nonstandard_methods=True) @@ -379,9 +395,12 @@ class TestApp(tornado.testing.AsyncHTTPTestCase): @tornado.testing.gen_test def test_websocket(self): - ws_url = f"ws://localhost:{self.get_http_port()}/updates" + ws_req = httpclient.HTTPRequest( + f"ws://localhost:{self.get_http_port()}/updates", + headers={"Cookie": self.auth_cookie}, + ) - ws_client = yield websocket.websocket_connect(ws_url) + ws_client = yield websocket.websocket_connect(ws_req) self.master.options.anticomp = True r1 = yield ws_client.read_message() @@ -402,7 +421,7 @@ class TestApp(tornado.testing.AsyncHTTPTestCase): ws_client.close() # trigger on_close by opening a second connection. - ws_client2 = yield websocket.websocket_connect(ws_url) + ws_client2 = yield websocket.websocket_connect(ws_req) ws_client2.close() def test_process_list(self): @@ -440,3 +459,17 @@ class TestApp(tornado.testing.AsyncHTTPTestCase): assert resp.code == 412 assert b"xsrf" not in resp.body assert b"xsrf" in self.fetch("/", headers={"Sec-Fetch-Mode": "navigate"}).body + + def test_unauthorized_api(self): + assert self.fetch("/", headers={"Cookie": ""}).code == 403 + + @tornado.testing.gen_test + def test_unauthorized_websocket(self): + try: + yield websocket.websocket_connect( + f"ws://localhost:{self.get_http_port()}/updates" + ) + except httpclient.HTTPClientError as e: + assert e.code == 403 + else: + assert False