CVE-2025-23217
Description
mitmproxy is a interactive TLS-capable intercepting HTTP proxy for penetration testers and software developers and mitmweb is a web-based interface for mitmproxy. In mitmweb 11.1.1 and below, a malicious client can use mitmweb's proxy server (bound to *:8080 by default) to access mitmweb's internal API (bound to 127.0.0.1:8081 by default). In other words, while the cannot access the API directly, they can access the API through the proxy. An attacker may be able to escalate this SSRF-style access to remote code execution. The mitmproxy and mitmdump tools are unaffected. Only mitmweb is affected. This vulnerability has been fixed in mitmproxy 11.1.2 and above. Users are advised to upgrade. There are no known workarounds for this vulnerability.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
mitmproxyPyPI | < 11.1.2 | 11.1.2 |
Patches
201490b61817efa89055e196dweb: add token-based authentication for the web ui API
5 files changed · +163 −44
mitmproxy/tools/web/app.py+82 −35 modified@@ -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 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 @@ def get(self): 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<cmd>[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<flow_id>[0-9a-f\-]+)", FlowHandler), + (r"/flows/(?P<flow_id>[0-9a-f\-]+)/resume", ResumeFlow), + (r"/flows/(?P<flow_id>[0-9a-f\-]+)/kill", KillFlow), + (r"/flows/(?P<flow_id>[0-9a-f\-]+)/duplicate", DuplicateFlow), + (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|messages)/content.data", FlowContent), + (r"/flows/(?P<flow_id>[0-9a-f\-]+)/(?P<message>request|response|messages)/content/(?P<content_view>[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 @@ def __init__( 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<cmd>[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<flow_id>[0-9a-f\-]+)", FlowHandler), - (r"/flows/(?P<flow_id>[0-9a-f\-]+)/resume", ResumeFlow), - (r"/flows/(?P<flow_id>[0-9a-f\-]+)/kill", KillFlow), - (r"/flows/(?P<flow_id>[0-9a-f\-]+)/duplicate", DuplicateFlow), - (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|messages)/content.data", - FlowContent, - ), - ( - r"/flows/(?P<flow_id>[0-9a-f\-]+)/(?P<message>request|response|messages)/" - r"content/(?P<content_view>[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 )
mitmproxy/tools/web/master.py+6 −3 modified@@ -93,6 +93,11 @@ def _sig_servers_changed(self) -> None: }, ) + @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 @@ async def running(self): 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()
mitmproxy/tools/web/templates/login.html+30 −0 added@@ -0,0 +1,30 @@ +<!doctype html> +<html lang="en"> +<head> + <meta charset="utf-8"> + <title>mitmproxy</title> + <link rel="icon" href=".{{ static_url('images/favicon.ico') }}" type="image/x-icon"/> + <meta name="viewport" content="width=device-width, initial-scale=1" /> + <style> + body { + font-family: sans-serif; + display: flex; + flex-direction: column; + align-items: center; + } + input { + font-family: monospace; + } + </style> +</head> +<body> + <h1>403 Auth Token Required</h1> + <p>To access mitmproxy, please enter the authentication token printed in the console below.</p> + <form method="GET"> + <label> + <input type="password" name="token" size="32" placeholder="" /> + </label> + <input type="submit" /> + </form> +</body> +</html>
mitmproxy/tools/web/webaddons.py+9 −3 modified@@ -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 @@ def load(self, loader): 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}", )
test/mitmproxy/tools/web/test_app.py+36 −3 modified@@ -9,6 +9,7 @@ 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 @@ async def make_master() -> webmaster.WebMaster: 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 @@ def test_err(self): @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 @@ def test_websocket(self): 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 @@ def test_xsrf_hardening_app(self): 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
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
7- github.com/advisories/GHSA-wg33-5h85-7q5pghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-23217ghsaADVISORY
- en.wikipedia.org/wiki/Server-side_request_forgerynvdWEB
- github.com/mitmproxy/mitmproxy/blob/main/CHANGELOG.mdghsaWEB
- github.com/mitmproxy/mitmproxy/blob/main/CHANGELOG.mdnvdWEB
- github.com/mitmproxy/mitmproxy/commit/fa89055e196d953f11fd241e36ee37858993486aghsaWEB
- github.com/mitmproxy/mitmproxy/security/advisories/GHSA-wg33-5h85-7q5pnvdWEB
News mentions
0No linked articles in our index yet.