From a76f3c2ac9812004e2a0e42e6ff39cd30c62ccfe Mon Sep 17 00:00:00 2001 From: MUHAMMED HUSSEIN Date: Thu, 11 Jun 2026 15:58:44 +0300 Subject: [PATCH 1/2] Fix verify.rs fuzz target to actually call verify() The target was calling bcrypt::hash() instead of bcrypt::verify(), which meant the hash-string parser surface was never fuzzed. This is why the #62 regression in #95 wasn't caught by CI. --- fuzz/fuzz_targets/verify.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/fuzz/fuzz_targets/verify.rs b/fuzz/fuzz_targets/verify.rs index 1117034..b0e0bfb 100644 --- a/fuzz/fuzz_targets/verify.rs +++ b/fuzz/fuzz_targets/verify.rs @@ -1,6 +1,10 @@ #![no_main] use libfuzzer_sys::fuzz_target; -fuzz_target!(|data: &[u8]| { - let _ = bcrypt::hash(&data, 4); +fuzz_target!(|data: (&[u8], &str)| { + // Exercise the hash-string parser by feeding both arbitrary + // password bytes and arbitrary &str inputs to verify(). + // The &str input is what reaches split_hash; this is the + // surface that #62 (and its regression) lived in. + let _ = bcrypt::verify(data.0, data.1); }); From f5f1ee2862c1198a85afe3c2f8cd80835162b7e9 Mon Sep 17 00:00:00 2001 From: MUHAMMED HUSSEIN Date: Thu, 11 Jun 2026 16:10:49 +0300 Subject: [PATCH 2/2] Reject non-ASCII bytes in split_hash to prevent panic (regression of #62) A valid bcrypt hash is always 60 ASCII bytes. The parser rewrite in #95 removed the is_char_boundary check that #62's fix introduced. Rejecting non-ASCII up front in split_hash closes the entire class of byte-boundary panics across all five &str slices in the parser. Adds two regression tests: - verify_rejects_multibyte_utf8_in_hash: end-to-end via verify() - split_hash_rejects_non_ascii: direct parser-level test --- src/lib.rs | 34 +++++++++++++++++++++++++++++++--- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 7edc627..391cb62 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -190,10 +190,12 @@ fn _hash_password( /// cost, salt and hash #[cfg(any(feature = "alloc", feature = "std"))] fn split_hash(hash: &str) -> BcryptResult { - // A valid bcrypt hash is always exactly 60 bytes: - if hash.len() != 60 { + // A valid bcrypt hash is always exactly 60 ASCII bytes. Rejecting + // non-ASCII up front avoids panics from `&str` slicing through the + // middle of a multi-byte UTF-8 character (regression of #62). + if hash.len() != 60 || !hash.is_ascii() { return Err(BcryptError::InvalidHash( - "the hash format is malformed; expected 60 bytes", + "the hash format is malformed; expected 60 ASCII bytes", )); } @@ -748,4 +750,30 @@ mod tests { "2a$$$0$OOOOOOOOOOOOOOOOOOOOO£OOOOOOOOOOOOOOOOOOOOOOOOOOOOOO", ); } + + #[test] + fn verify_rejects_multibyte_utf8_in_hash() { + // Constructed so byte position 22 falls inside the multi-byte + // sequence for '£' (0xC2 0xA3). Before this fix, this hash would + // panic in str::slice_error_fail when split_hash sliced + // &salt_and_hash[..22]. After: returns InvalidHash, like every + // other malformed input. Regression test for #62. + let hash = "$2b$04$aaaaaaaaaaaaaaaaaaaaa£aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + assert_eq!(hash.len(), 60); // sanity: byte length is still 60 + assert!(matches!( + verify(&b"password"[..], hash), + Err(BcryptError::InvalidHash(_)) + )); + } + + #[test] + fn split_hash_rejects_non_ascii() { + // Direct parser-level test of the invariant: any non-ASCII byte + // anywhere in a 60-byte hash string is rejected up front. + let hash = "$2b$04$aaaaaaaaaaaaaaaaaaaaa£aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + assert!(matches!( + split_hash(hash), + Err(BcryptError::InvalidHash(_)) + )); + } }