| 1 | From 5937c2ddbd0306b395a98d3219c59405fab76f97 Mon Sep 17 00:00:00 2001
|
| 2 | From: Maximilian Hils <[email protected]>
|
| 3 | Date: Fri, 10 Oct 2025 08:59:23 +0200
|
| 4 | Subject: [PATCH] Add custom htpasswd parser (#7906)
|
| 5 |
|
| 6 | * add custom htpasswd parser
|
| 7 |
|
| 8 | This PR migrates us off passlib, which is unmaintained.
|
| 9 |
|
| 10 | * [autofix.ci] apply automated fixes
|
| 11 |
|
| 12 | ---------
|
| 13 |
|
| 14 | Co-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 |
|
| 31 | diff --git a/CHANGELOG.md b/CHANGELOG.md
|
| 32 | index 02b1a7f91..50bffa31e 100644
|
| 33 | |
| 34 | |
| 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 |
|
| 47 | diff --git a/mitmproxy/addons/proxyauth.py b/mitmproxy/addons/proxyauth.py
|
| 48 | index 9250251da..9839c619a 100644
|
| 49 | |
| 50 | |
| 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)
|
| 93 | diff --git a/mitmproxy/utils/htpasswd.py b/mitmproxy/utils/htpasswd.py
|
| 94 | new file mode 100644
|
| 95 | index 000000000..22081ea3f
|
| 96 | |
| 97 | |
| 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"))
|
| 180 | diff --git a/pyproject.toml b/pyproject.toml
|
| 181 | index af30cb0b7..a606945eb 100644
|
| 182 | |
| 183 | |
| 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",
|
| 200 | diff --git a/test/mitmproxy/addons/test_proxyauth.py b/test/mitmproxy/addons/test_proxyauth.py
|
| 201 | index e715e9d11..e88c36f92 100644
|
| 202 | |
| 203 | |
| 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()
|
| 251 | diff --git a/test/mitmproxy/data/htpasswd b/test/mitmproxy/data/htpasswd
|
| 252 | index 54c95b8cc..067047e7c 100644
|
| 253 | |
| 254 | |
| 255 | @@ -1 +1 @@
|
| 256 | -test:$apr1$/LkYxy3x$WI4.YbiJlu537jLGEW2eu1
|
| 257 | +user:$2b$05$opH8g9/PUhK6HVSnhdX7P.oB6MTMOlIlXgb4THm1Adh12t4IuqMsK
|
| 258 | diff --git a/test/mitmproxy/data/htpasswd.invalid b/test/mitmproxy/data/htpasswd.invalid
|
| 259 | deleted file mode 100644
|
| 260 | index 257cc5642..000000000
|
| 261 | |
| 262 | |
| 263 | @@ -1 +0,0 @@
|
| 264 | -foo
|
| 265 | diff --git a/test/mitmproxy/utils/test_htpasswd.py b/test/mitmproxy/utils/test_htpasswd.py
|
| 266 | new file mode 100644
|
| 267 | index 000000000..4eb4f7c6a
|
| 268 | |
| 269 | |
| 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
|