projects/aports·blob master · refs

329 lines · 11.5 KiB

1From 5937c2ddbd0306b395a98d3219c59405fab76f97 Mon Sep 17 00:00:00 2001
2From: Maximilian Hils <[email protected]>
3Date: Fri, 10 Oct 2025 08:59:23 +0200
4Subject: [PATCH] Add custom htpasswd parser (#7906)
5
6* add custom htpasswd parser
7
8This PR migrates us off passlib, which is unmaintained.
9
10* [autofix.ci] apply automated fixes
11
12---------
13
14Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
15
16(cherry-picked from commit 46d0adc7d1474e35d2c8490cba9a7129594ef0a0 on top of v11.0.0)
17---
18 CHANGELOG.md | 5 ++
19 mitmproxy/addons/proxyauth.py | 13 ++--
20 mitmproxy/utils/htpasswd.py | 81 +++++++++++++++++++++++++
21 pyproject.toml | 2 +-
22 test/mitmproxy/addons/test_proxyauth.py | 27 ++++++---
23 test/mitmproxy/data/htpasswd | 2 +-
24 test/mitmproxy/data/htpasswd.invalid | 1 -
25 test/mitmproxy/utils/test_htpasswd.py | 59 ++++++++++++++++++
26 8 files changed, 172 insertions(+), 18 deletions(-)
27 create mode 100644 mitmproxy/utils/htpasswd.py
28 delete mode 100644 test/mitmproxy/data/htpasswd.invalid
29 create mode 100644 test/mitmproxy/utils/test_htpasswd.py
30
31diff --git a/CHANGELOG.md b/CHANGELOG.md
32index 02b1a7f91..50bffa31e 100644
33--- a/CHANGELOG.md
34+++ b/CHANGELOG.md
35@@ -7,6 +7,11 @@
36
37 ## Unreleased: mitmproxy next
38
39+- Replace `htpasswd` file parser with a custom implementation to migrate off unmaintained
40+ `passlib` dependency. The new parser only supports bcrypt and SHA-1 hashing.
41+ Contributions for additional formats are welcome as long as they don't introduce new
42+ dependencies.
43+ ([#7906](https://github.com/mitmproxy/mitmproxy/pull/7906), @mhils)
44
45 ## 02 October 2024: mitmproxy 11.0.0
46
47diff --git a/mitmproxy/addons/proxyauth.py b/mitmproxy/addons/proxyauth.py
48index 9250251da..9839c619a 100644
49--- a/mitmproxy/addons/proxyauth.py
50+++ b/mitmproxy/addons/proxyauth.py
51@@ -1,6 +1,7 @@
52 from __future__ import annotations
53
54 import binascii
55+import pathlib
56 import weakref
57 from abc import ABC
58 from abc import abstractmethod
59@@ -8,7 +9,6 @@ from collections.abc import MutableMapping
60 from typing import Optional
61
62 import ldap3
63-import passlib.apache
64
65 from mitmproxy import connection
66 from mitmproxy import ctx
67@@ -17,6 +17,7 @@ from mitmproxy import http
68 from mitmproxy.net.http import status_codes
69 from mitmproxy.proxy import mode_specs
70 from mitmproxy.proxy.layers import modes
71+from mitmproxy.utils import htpasswd
72
73 REALM = "mitmproxy"
74
75@@ -201,11 +202,13 @@ class SingleUser(Validator):
76
77 class Htpasswd(Validator):
78 def __init__(self, proxyauth: str):
79- path = proxyauth[1:]
80+ path = pathlib.Path(proxyauth[1:]).expanduser()
81 try:
82- self.htpasswd = passlib.apache.HtpasswdFile(path)
83- except (ValueError, OSError):
84- raise exceptions.OptionsError(f"Could not open htpasswd file: {path}")
85+ self.htpasswd = htpasswd.HtpasswdFile.from_file(path)
86+ except (ValueError, OSError) as e:
87+ raise exceptions.OptionsError(
88+ f"Could not open htpasswd file: {path}"
89+ ) from e
90
91 def __call__(self, username: str, password: str) -> bool:
92 return self.htpasswd.check_password(username, password)
93diff --git a/mitmproxy/utils/htpasswd.py b/mitmproxy/utils/htpasswd.py
94new file mode 100644
95index 000000000..22081ea3f
96--- /dev/null
97+++ b/mitmproxy/utils/htpasswd.py
98@@ -0,0 +1,81 @@
99+"""
100+A standalone, minimal htpasswd parser.
101+
102+This implementation currently supports bcrypt and SHA1 passwords. SHA1 is insecure.
103+"""
104+
105+from __future__ import annotations
106+
107+import base64
108+import hashlib
109+from pathlib import Path
110+
111+import bcrypt
112+
113+
114+class HtpasswdFile:
115+ def __init__(self, content: str):
116+ """
117+ Create a HtpasswdFile from a string.
118+ """
119+ self.users: dict[str, str] = {}
120+ for line in content.splitlines():
121+ line = line.strip()
122+ if not line or line.startswith("#"):
123+ continue
124+ if ":" not in line:
125+ raise ValueError(f"Malformed htpasswd line: {line!r}")
126+ user, pwhash = line.split(":", 1)
127+ if not user:
128+ raise ValueError(f"Malformed htpasswd line: {line!r}")
129+
130+ is_sha = pwhash.startswith("{SHA}")
131+ is_bcrypt = pwhash.startswith(("$2y$", "$2b$", "$2a$"))
132+ if not is_sha and not is_bcrypt:
133+ raise ValueError(f"Unsupported htpasswd format for user {user!r}")
134+
135+ self.users[user] = pwhash
136+
137+ @classmethod
138+ def from_file(cls, path: Path) -> HtpasswdFile:
139+ """
140+ Initializes and loads an htpasswd file.
141+
142+ Args:
143+ path: The path to the htpasswd file.
144+
145+ Raises:
146+ OSError: If the file cannot be read.
147+ ValueError: If the file is malformed.
148+ """
149+ try:
150+ content = path.read_text("utf-8")
151+ except FileNotFoundError:
152+ raise OSError(f"Htpasswd file not found: {path}") from None
153+ return cls(content)
154+
155+ def check_password(self, username: str, password: str) -> bool:
156+ """
157+ Checks if a username and password combination is valid.
158+
159+ Args:
160+ username: The username to check.
161+ password: The password to check.
162+
163+ Returns:
164+ True if the password is valid, False otherwise.
165+ """
166+ pwhash = self.users.get(username)
167+ if pwhash is None:
168+ return False
169+
170+ pwhash = pwhash.split(":", 1)[0]
171+
172+ if pwhash.startswith("{SHA}"):
173+ # Apache's {SHA} is base64-encoded SHA-1.
174+ # https://httpd.apache.org/docs/2.4/misc/password_encryptions.html
175+ digest = hashlib.sha1(password.encode("utf-8")).digest()
176+ expected = base64.b64encode(digest).decode("ascii")
177+ return pwhash[5:] == expected
178+ else: # pwhash.startswith(("$2y$", "$2b$", "$2a$")):
179+ return bcrypt.checkpw(password.encode("utf-8"), pwhash.encode("utf-8"))
180diff --git a/pyproject.toml b/pyproject.toml
181index af30cb0b7..a606945eb 100644
182--- a/pyproject.toml
183+++ b/pyproject.toml
184@@ -34,6 +34,7 @@ classifiers = [
185 dependencies = [
186 "aioquic>=1.1.0,<=1.2.0",
187 "asgiref>=3.2.10,<=3.8.1",
188+ "bcrypt>=5.0.0,<=5.0.0",
189 "Brotli>=1.0,<=1.1.0",
190 "certifi>=2019.9.11", # no upper bound here to get latest CA bundle
191 "cryptography>=42.0,<43.1", # relaxed upper bound here to get security fixes
192@@ -45,7 +46,6 @@ dependencies = [
193 "ldap3>=2.8,<=2.9.1",
194 "mitmproxy_rs>=0.9.1,<0.10", # relaxed upper bound here: we control this
195 "msgpack>=1.0.0,<=1.1.0",
196- "passlib>=1.6.5,<=1.7.4",
197 "protobuf>=5.27.2,<=5.28.2",
198 "pydivert>=2.0.3,<=2.1.0; sys_platform == 'win32'",
199 "pyOpenSSL>=22.1,<=24.2.1",
200diff --git a/test/mitmproxy/addons/test_proxyauth.py b/test/mitmproxy/addons/test_proxyauth.py
201index e715e9d11..e88c36f92 100644
202--- a/test/mitmproxy/addons/test_proxyauth.py
203+++ b/test/mitmproxy/addons/test_proxyauth.py
204@@ -125,7 +125,7 @@ class TestProxyAuth:
205 assert not f.response
206 assert not f.request.headers.get("Authorization")
207
208- def test_configure(self, monkeypatch, tdata):
209+ def test_configure(self, monkeypatch, tdata, tmp_path):
210 monkeypatch.setattr(ldap3, "Server", mock.MagicMock())
211 monkeypatch.setattr(ldap3, "Connection", mock.MagicMock())
212
213@@ -196,21 +196,28 @@ class TestProxyAuth:
214 ):
215 ctx.configure(pa, proxyauth="ldapssssssss:fake_server:dn:password:tree")
216
217+ # test htpasswd
218+ # Manually create a SHA1-hashed password for "test:test".
219+ # The htpasswd file in the repo uses unsalted MD5, which we don't support.
220+ p = tmp_path / "htpasswd"
221+ p.write_text("test:{SHA}qUqP5cyxm6YcTAhz05Hph5gvu9M=\n")
222+ ctx.configure(pa, proxyauth=f"@{p}")
223+ assert isinstance(pa.validator, proxyauth.Htpasswd)
224+ assert pa.validator("test", "test")
225+ assert not pa.validator("test", "foo")
226+
227+ # nonexistent file
228 with pytest.raises(
229 exceptions.OptionsError, match="Could not open htpasswd file"
230 ):
231- ctx.configure(
232- pa, proxyauth="@" + tdata.path("mitmproxy/net/data/server.crt")
233- )
234+ ctx.configure(pa, proxyauth="@nonexistent")
235+
236+ # malformed file
237+ p.write_text("malformed\n")
238 with pytest.raises(
239 exceptions.OptionsError, match="Could not open htpasswd file"
240 ):
241- ctx.configure(pa, proxyauth="@nonexistent")
242-
243- ctx.configure(pa, proxyauth="@" + tdata.path("mitmproxy/net/data/htpasswd"))
244- assert isinstance(pa.validator, proxyauth.Htpasswd)
245- assert pa.validator("test", "test")
246- assert not pa.validator("test", "foo")
247+ ctx.configure(pa, proxyauth=f"@{p}")
248
249 def test_handlers(self):
250 up = proxyauth.ProxyAuth()
251diff --git a/test/mitmproxy/data/htpasswd b/test/mitmproxy/data/htpasswd
252index 54c95b8cc..067047e7c 100644
253--- a/test/mitmproxy/data/htpasswd
254+++ b/test/mitmproxy/data/htpasswd
255@@ -1 +1 @@
256-test:$apr1$/LkYxy3x$WI4.YbiJlu537jLGEW2eu1
257+user:$2b$05$opH8g9/PUhK6HVSnhdX7P.oB6MTMOlIlXgb4THm1Adh12t4IuqMsK
258diff --git a/test/mitmproxy/data/htpasswd.invalid b/test/mitmproxy/data/htpasswd.invalid
259deleted file mode 100644
260index 257cc5642..000000000
261--- a/test/mitmproxy/data/htpasswd.invalid
262+++ /dev/null
263@@ -1 +0,0 @@
264-foo
265diff --git a/test/mitmproxy/utils/test_htpasswd.py b/test/mitmproxy/utils/test_htpasswd.py
266new file mode 100644
267index 000000000..4eb4f7c6a
268--- /dev/null
269+++ b/test/mitmproxy/utils/test_htpasswd.py
270@@ -0,0 +1,59 @@
271+from pathlib import Path
272+
273+import pytest
274+
275+from mitmproxy.utils import htpasswd
276+
277+
278+def test_sha1():
279+ ht = htpasswd.HtpasswdFile(
280+ "user1:{SHA}8FePHnF0saQcTqjG4X96ijuIySo=\n"
281+ "user2:{SHA}i+UhJqb95FCnFio2UdWJu1HpV50=\n"
282+ "user3:{SHA}3ipNV1GrBtxPmHFC21fCbVCSXIo=:extra\n"
283+ )
284+ assert ht.check_password("user1", "pass1")
285+ assert ht.check_password("user2", "pass2")
286+ assert ht.check_password("user3", "pass3")
287+ assert not ht.check_password("user1", "pass2")
288+ assert not ht.check_password("wronguser", "testpassword")
289+
290+
291+def test_bcrypt():
292+ ht = htpasswd.HtpasswdFile(
293+ "user_bcrypt:$2b$05$opH8g9/PUhK6HVSnhdX7P.oB6MTMOlIlXgb4THm1Adh12t4IuqMsK\n"
294+ )
295+ assert ht.check_password("user_bcrypt", "pass")
296+ assert not ht.check_password("user_bcrypt", "wrong")
297+
298+
299+@pytest.mark.parametrize(
300+ "file_content, err_msg",
301+ [
302+ ("malformed", "Malformed htpasswd line"),
303+ (":malformed", "Malformed htpasswd line"),
304+ ("user_md5:$apr1$....", "Unsupported htpasswd format"),
305+ ("user_ssha:{SSHA}...", "Unsupported htpasswd format"),
306+ ("user_plain:pass", "Unsupported htpasswd format"),
307+ ("user_crypt:..j8N8I28nVM", "Unsupported htpasswd format"),
308+ ("user_empty_pw:", "Unsupported htpasswd format"),
309+ ],
310+)
311+def test_errors(file_content, err_msg):
312+ with pytest.raises(ValueError, match=err_msg):
313+ htpasswd.HtpasswdFile(file_content)
314+
315+
316+def test_from_file(tdata):
317+ assert htpasswd.HtpasswdFile.from_file(
318+ Path(tdata.path("mitmproxy/data/htpasswd"))
319+ ).users
320+
321+
322+def test_file_not_found():
323+ with pytest.raises(OSError, match="Htpasswd file not found"):
324+ htpasswd.HtpasswdFile.from_file(Path("/nonexistent"))
325+
326+
327+def test_empty_and_comments():
328+ ht = htpasswd.HtpasswdFile("\n# comment\n \n\t# another comment\n")
329+ assert not ht.users