Skip to content

Commit 0bd573a

Browse files
committed
web: add password-based authentication
1 parent fa89055 commit 0bd573a

File tree

8 files changed

+160
-13
lines changed

8 files changed

+160
-13
lines changed

mitmproxy/tools/web/app.py

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@
22

33
import functools
44
import hashlib
5-
import hmac
65
import json
76
import logging
87
import os.path
@@ -37,6 +36,7 @@
3736
from mitmproxy.net.http import status_codes
3837
from mitmproxy.tcp import TCPFlow
3938
from mitmproxy.tcp import TCPMessage
39+
from mitmproxy.tools.web.webaddons import WebAuth
4040
from mitmproxy.udp import UDPFlow
4141
from mitmproxy.udp import UDPMessage
4242
from mitmproxy.utils import asyncio_utils
@@ -221,6 +221,12 @@ def __init_subclass__(cls, **kwargs):
221221
if fn is not tornado.web.RequestHandler._unimplemented_method:
222222
setattr(cls, method, AuthRequestHandler._require_auth(fn))
223223

224+
def auth_fail(self, invalid_password: bool) -> None:
225+
"""
226+
Will be called when returning a 403.
227+
May write a login form as the response.
228+
"""
229+
224230
@staticmethod
225231
def _require_auth[**P, R](
226232
fn: Callable[Concatenate[AuthRequestHandler, P], R],
@@ -230,13 +236,10 @@ def wrapper(
230236
self: AuthRequestHandler, *args: P.args, **kwargs: P.kwargs
231237
) -> R | None:
232238
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-
):
239+
password = self.get_argument("token", default="")
240+
if not self.settings["is_valid_password"](password):
238241
self.set_status(403)
239-
self.render("login.html")
242+
self.auth_fail(bool(password))
240243
return None
241244
self.set_signed_cookie(
242245
self.AUTH_COOKIE_NAME,
@@ -332,6 +335,12 @@ def _is_fetch_mode_navigate(self) -> bool:
332335
# Forbid access for non-navigate fetch modes so that they can't obtain xsrf_token.
333336
return self.request.headers.get("Sec-Fetch-Mode", "navigate") == "navigate"
334337

338+
def auth_fail(self, invalid_password: bool) -> None:
339+
# For mitmweb, we only write a login form for IndexHandler,
340+
# which has additional Sec-Fetch-Mode protections.
341+
if self._is_fetch_mode_navigate():
342+
self.render("login.html", invalid_password=invalid_password)
343+
335344
def get(self):
336345
# Forbid access for non-navigate fetch modes so that they can't obtain xsrf_token.
337346
if self._is_fetch_mode_navigate():
@@ -342,6 +351,8 @@ def get(self):
342351
f"Unexpected Sec-Fetch-Mode header: {self.request.headers.get('Sec-Fetch-Mode')}",
343352
)
344353

354+
post = get # login form
355+
345356

346357
class FilterHelp(RequestHandler):
347358
def get(self):
@@ -802,6 +813,7 @@ def __init__(
802813
self, master: mitmproxy.tools.web.master.WebMaster, debug: bool
803814
) -> None:
804815
self.master = master
816+
auth_addon: WebAuth = master.addons.get("webauth")
805817
super().__init__(
806818
default_host="dns-rebind-protection",
807819
template_path=os.path.join(os.path.dirname(__file__), "templates"),
@@ -812,7 +824,7 @@ def __init__(
812824
debug=debug,
813825
autoreload=False,
814826
transforms=[GZipContentAndFlowFiles],
815-
auth_token=secrets.token_hex(16),
827+
is_valid_password=auth_addon.is_valid_password,
816828
)
817829

818830
self.add_handlers("dns-rebind-protection", [(r"/.*", DnsRebind)])

mitmproxy/tools/web/master.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import errno
22
import logging
3+
from typing import cast
34

45
import tornado.httpserver
56
import tornado.ioloop
@@ -41,6 +42,7 @@ def __init__(self, opts: options.Options, with_termlog: bool = True):
4142
self.addons.add(*addons.default_addons())
4243
self.addons.add(
4344
webaddons.WebAddon(),
45+
webaddons.WebAuth(),
4446
intercept.Intercept(),
4547
readfile.ReadFileStdin(),
4648
static_viewer.StaticViewer(),
@@ -95,8 +97,7 @@ def _sig_servers_changed(self) -> None:
9597

9698
@property
9799
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+
return cast(webaddons.WebAuth, self.addons.get("webauth")).web_url
100101

101102
async def running(self):
102103
# Register tornado with the current event loop

mitmproxy/tools/web/templates/login.html

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,17 @@
1818
</style>
1919
</head>
2020
<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">
21+
{% if invalid_password %}
22+
<h1 style="color: darkred">403 Invalid Password</h1>
23+
{% else %}
24+
<h1>403 Authentication Required</h1>
25+
{% end %}
26+
<p>To access mitmproxy, please enter the password or authentication token printed in the console.</p>
27+
<form method="POST">
2428
<label>
2529
<input type="password" name="token" size="32" placeholder="" />
2630
</label>
31+
{% module xsrf_form_html() %}
2732
<input type="submit" />
2833
</form>
2934
</body>

mitmproxy/tools/web/webaddons.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,80 @@
11
from __future__ import annotations
22

3+
import hmac
34
import logging
5+
import secrets
46
import webbrowser
57
from collections.abc import Sequence
68
from typing import TYPE_CHECKING
79

10+
import argon2
11+
812
from mitmproxy import ctx
13+
from mitmproxy import exceptions
914
from mitmproxy.tools.web.web_columns import AVAILABLE_WEB_COLUMNS
1015

1116
if TYPE_CHECKING:
1217
from mitmproxy.tools.web.master import WebMaster
1318

19+
logger = logging.getLogger(__name__)
20+
21+
22+
class WebAuth:
23+
_password: str
24+
_hasher: argon2.PasswordHasher
25+
26+
def __init__(self):
27+
self._password = secrets.token_hex(16)
28+
self._hasher = argon2.PasswordHasher()
29+
30+
def load(self, loader):
31+
loader.add_option(
32+
"web_password",
33+
str,
34+
"",
35+
"Password to protect the mitmweb user interface. "
36+
"Values starting with `$` are interpreted as an argon2 hash, "
37+
"everything else is considered a plaintext password."
38+
"If not password is provided, a random token is generated on startup.",
39+
)
40+
41+
def configure(self, updated) -> None:
42+
if "web_password" in updated:
43+
if ctx.options.web_password.startswith("$"):
44+
try:
45+
argon2.extract_parameters(ctx.options.web_password)
46+
except argon2.exceptions.InvalidHashError:
47+
raise exceptions.OptionsError(
48+
"`web_password` starts with `$`, but it's not a valid argon2 hash."
49+
)
50+
elif ctx.options.web_password:
51+
logger.warning(
52+
"Using a plaintext password to protect the mitmweb user interface. "
53+
"Consider using an argon2 hash for `web_password` instead."
54+
)
55+
self._password = ctx.options.web_password or secrets.token_hex(16)
56+
57+
@property
58+
def web_url(self) -> str:
59+
if ctx.options.web_password:
60+
auth = "" # We don't want to print plaintext passwords (and it doesn't work for argon2 anyhow).
61+
else:
62+
auth = f"?token={self._password}"
63+
# noinspection HttpUrlsUsage
64+
return f"http://{ctx.options.web_host}:{ctx.options.web_port}/{auth}"
65+
66+
def is_valid_password(self, password: str) -> bool:
67+
if self._password.startswith("$"):
68+
try:
69+
return self._hasher.verify(self._password, password)
70+
except argon2.exceptions.VerificationError:
71+
return False
72+
else:
73+
return hmac.compare_digest(
74+
self._password,
75+
password,
76+
)
77+
1478

1579
class WebAddon:
1680
def load(self, loader):

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ classifiers = [
3131
# It is not considered best practice to use install_requires to pin dependencies to specific versions.
3232
dependencies = [
3333
"aioquic>=1.1.0,<=1.2.0",
34+
"argon2-cffi>=23.1.0,<=23.1.0",
3435
"asgiref>=3.2.10,<=3.8.1",
3536
"Brotli>=1.0,<=1.1.0",
3637
"certifi>=2019.9.11", # no upper bound here to get latest CA bundle

test/mitmproxy/tools/web/test_app.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,18 @@ def test_xsrf_hardening_app(self):
460460
assert b"xsrf" not in resp.body
461461
assert b"xsrf" in self.fetch("/", headers={"Sec-Fetch-Mode": "navigate"}).body
462462

463+
def test_xsrf_hardening_login(self):
464+
"""Ensure that xsrf token is not provided for JS requests."""
465+
resp = self.fetch("/", headers={"Sec-Fetch-Mode": "same-origin", "Cookie": ""})
466+
assert resp.code == 403
467+
assert b"xsrf" not in resp.body
468+
assert (
469+
b"xsrf"
470+
in self.fetch(
471+
"/", headers={"Sec-Fetch-Mode": "navigate", "Cookie": ""}
472+
).body
473+
)
474+
463475
def test_unauthorized_api(self):
464476
assert self.fetch("/", headers={"Cookie": ""}).code == 403
465477

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import pytest
2+
3+
from mitmproxy.exceptions import OptionsError
4+
from mitmproxy.test import taddons
5+
from mitmproxy.tools.web import webaddons
6+
7+
8+
class TestWebAuth:
9+
def test_token_auth(self):
10+
a = webaddons.WebAuth()
11+
with taddons.context(webaddons.WebAddon(), a) as tctx:
12+
assert not a.is_valid_password("")
13+
assert not a.is_valid_password("invalid")
14+
assert not a.is_valid_password("test")
15+
16+
tctx.options.web_password = ""
17+
assert not a.is_valid_password("")
18+
assert not a.is_valid_password("invalid")
19+
assert not a.is_valid_password("test")
20+
assert a.is_valid_password(a._password)
21+
assert "token" in a.web_url
22+
23+
def test_plaintext_auth(self, caplog):
24+
a = webaddons.WebAuth()
25+
with taddons.context(webaddons.WebAddon(), a) as tctx:
26+
tctx.options.web_password = "test"
27+
assert "Consider using an argon2 hash" in caplog.text
28+
assert not a.is_valid_password("")
29+
assert not a.is_valid_password("invalid")
30+
assert a.is_valid_password("test")
31+
assert "token" not in a.web_url
32+
33+
def test_argon2_auth(self, caplog):
34+
a = webaddons.WebAuth()
35+
with taddons.context(webaddons.WebAddon(), a) as tctx:
36+
tctx.options.web_password = (
37+
"$argon2id$v=19$m=8,t=1,p=1$c2FsdHNhbHQ$ieVgG5ysTJFx4k/KvmC9aQ"
38+
)
39+
assert not a.is_valid_password("")
40+
assert not a.is_valid_password("invalid")
41+
assert a.is_valid_password("test")
42+
assert "token" not in a.web_url
43+
44+
def test_invalid_hash(self, caplog):
45+
a = webaddons.WebAuth()
46+
with taddons.context(webaddons.WebAddon(), a) as tctx:
47+
with pytest.raises(OptionsError):
48+
tctx.options.web_password = "$argon2id$"
49+
assert not a.is_valid_password("")
50+
assert not a.is_valid_password("test")

web/src/js/ducks/_options_gen.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ export interface OptionsState {
9595
web_debug: boolean;
9696
web_host: string;
9797
web_open_browser: boolean;
98+
web_password: string;
9899
web_port: number;
99100
web_static_viewer: string | undefined;
100101
websocket: boolean;
@@ -198,6 +199,7 @@ export const defaultState: OptionsState = {
198199
web_debug: false,
199200
web_host: "127.0.0.1",
200201
web_open_browser: true,
202+
web_password: "",
201203
web_port: 8081,
202204
web_static_viewer: "",
203205
websocket: true,

0 commit comments

Comments
 (0)