fix(hashview): validate cracked pairs client-side before upload

upload_cracked_hashes sent every hash:plaintext pair to Hashview
untouched. A single line whose plaintext doesn't match the declared
hash mode (e.g. a stray MD5 hash mixed into an NTLM list) made
Hashview reject the entire batch with an opaque
"Plaintext for hash ... was found to be invalid" error.

Add two client-side guards (default on, disable via validate=False):
1. Length filter — drop hashes whose hex width is wrong for the mode.
2. Plaintext verification — for reproducible fast modes (MD5, SHA1,
   MD4, NTLM, SHA2-256/512) recompute the digest from the plaintext
   ($HEX[...] decoded) and skip pairs that don't match.

Skipped lines are reported with line number and reason instead of
failing the whole upload; raise clearly if nothing valid remains.
Ships a pure-Python MD4 since OpenSSL 3 dropped it from hashlib.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Justin Bollinger
2026-07-23 12:52:55 -04:00
co-authored by Claude Opus 4.8
parent 1e23199aac
commit ecedcbf7dc
2 changed files with 205 additions and 2 deletions
+63
View File
@@ -353,6 +353,69 @@ class TestHashviewAPI:
api.upload_cracked_hashes(str(cracked_file), hash_type="1000")
assert "Invalid API response" in str(excinfo.value)
def test_upload_skips_wrong_type_line(self, api, tmp_path, capsys):
"""An MD5 line mixed into an NTLM upload is filtered client-side."""
cracked_file = tmp_path / "cracked.txt"
cracked_file.write_text(
# MD5("password") — invalid as NTLM, must be dropped
"5f4dcc3b5aa765d61d8327deb882cf99:password\n"
# genuine NTLM("password") — must be kept
"8846f7eaee8fb117ad06bdd830b7586c:password\n"
)
mock_response = Mock()
mock_response.json.return_value = {"imported": 1}
mock_response.raise_for_status = Mock()
api.session.post.return_value = mock_response
result = api.upload_cracked_hashes(str(cracked_file), hash_type="1000")
assert result["imported"] == 1
# Only the valid NTLM line should have been sent
sent = api.session.post.call_args.kwargs.get("data")
if sent is None:
sent = api.session.post.call_args.args[1]
assert "8846f7eaee8fb117ad06bdd830b7586c:password" in sent
assert "5f4dcc3b5aa765d61d8327deb882cf99" not in sent
out = capsys.readouterr().out
assert "Skipped 1 line" in out
def test_upload_accepts_hex_ntlm(self, api, tmp_path):
"""$HEX[...] NTLM plaintexts validate and are uploaded."""
cracked_file = tmp_path / "cracked.txt"
# NTLM of "%032023RC$ " emitted by hashcat as $HEX[...]
cracked_file.write_text(
"c153ace1d5b148820dab48a8aa5aa02e:$HEX[2530333230323352432420]\n"
)
mock_response = Mock()
mock_response.json.return_value = {"imported": 1}
mock_response.raise_for_status = Mock()
api.session.post.return_value = mock_response
result = api.upload_cracked_hashes(str(cracked_file), hash_type="1000")
assert result["imported"] == 1
def test_upload_all_invalid_raises(self, api, tmp_path):
"""If validation drops every line, we raise instead of posting empty."""
cracked_file = tmp_path / "cracked.txt"
cracked_file.write_text("5f4dcc3b5aa765d61d8327deb882cf99:password\n")
api.session.post.side_effect = AssertionError("should not POST")
with pytest.raises(Exception) as excinfo:
api.upload_cracked_hashes(str(cracked_file), hash_type="1000")
assert "No valid hashes" in str(excinfo.value)
def test_upload_validation_can_be_disabled(self, api, tmp_path):
"""validate=False restores the old permissive behaviour."""
cracked_file = tmp_path / "cracked.txt"
cracked_file.write_text("5f4dcc3b5aa765d61d8327deb882cf99:password\n")
mock_response = Mock()
mock_response.json.return_value = {"imported": 1}
mock_response.raise_for_status = Mock()
api.session.post.return_value = mock_response
result = api.upload_cracked_hashes(
str(cracked_file), hash_type="1000", validate=False
)
assert result["imported"] == 1
def test_create_customer_success(self, api):
"""Test creating a customer (real API if possible)."""
hashview_url, hashview_api_key = self._get_hashview_config()