From a18ab2bc0783f624b6303882794c53e14b05d192 Mon Sep 17 00:00:00 2001 From: Jon Currey Date: Wed, 23 Sep 2026 16:16:40 -0400 Subject: [PATCH] postgres-protocol: update to 0.6.12 to bound SCRAM work --- postgres-protocol/CHANGELOG.md | 28 ++++++++++ postgres-protocol/Cargo.toml | 18 +++--- postgres-protocol/src/authentication/mod.rs | 5 +- postgres-protocol/src/authentication/sasl.rs | 58 +++++++++++++++++--- postgres-protocol/src/hex.rs | 25 +++++++++ postgres-protocol/src/lib.rs | 2 + postgres-protocol/src/message/backend.rs | 6 ++ postgres-protocol/src/message/frontend.rs | 8 +-- postgres-protocol/src/password/mod.rs | 7 ++- postgres-protocol/src/types/mod.rs | 12 +++- postgres-protocol/src/types/test.rs | 17 ++++++ 11 files changed, 157 insertions(+), 29 deletions(-) create mode 100644 postgres-protocol/src/hex.rs diff --git a/postgres-protocol/CHANGELOG.md b/postgres-protocol/CHANGELOG.md index 0d5370abf..229c775b2 100644 --- a/postgres-protocol/CHANGELOG.md +++ b/postgres-protocol/CHANGELOG.md @@ -1,5 +1,33 @@ # Change Log +## v0.6.12 - 2026-06-12 + +### Fixed + +* Bound SCRAM iteration count to 100,000 to prevent DoS. +* Error instead of panicking on out-of-bounds hstore key/value length. +* Fix inverted character match in SCRAM error value parser. + +## v0.6.11 - 2026-03-30 + +### Changed + +* Upgraded `hmac` to 0.13. +* Upgraded `md-5` to 0.11. +* Upgraded `sha2` to 0.11. +* Upgraded `rand` to 0.10. +* Upgraded to Rust edition 2024, minimum Rust version 1.85. + +## v0.6.10 - 2026-01-14 + +### Added + +* Implemented `FallibleIterator::size_hint` for `Fields`. + +### Fixed + +* Increased bind parameter limit from `i16::MAX` to `u16::MAX`. + ## v0.6.9 - 2025-09-25 ### Changed diff --git a/postgres-protocol/Cargo.toml b/postgres-protocol/Cargo.toml index 6d07df120..db782d44b 100644 --- a/postgres-protocol/Cargo.toml +++ b/postgres-protocol/Cargo.toml @@ -1,13 +1,13 @@ [package] name = "postgres-protocol" -version = "0.6.9" +version = "0.6.12" authors = ["Steven Fackler "] -edition = "2021" +edition = "2024" description = "Low level Postgres protocol APIs" license = "MIT OR Apache-2.0" repository = "https://github.com/rust-postgres/rust-postgres" readme = "../README.md" -rust-version = "1.81" +rust-version = "1.85" [features] default = [] @@ -16,12 +16,12 @@ js = ["getrandom/wasm_js"] [dependencies] base64 = "0.22" byteorder = "1.0" -bytes = "1.0" +bytes = "1.11" fallible-iterator = "0.2" -hmac = "0.12" -md-5 = "0.10" +hmac = "0.13" +md-5 = "0.11" memchr = "2.0" -rand = "0.9" -sha2 = "0.10" +rand = "0.10" +sha2 = "0.11" stringprep = "0.1" -getrandom = { version = "0.3", optional = true } +getrandom = { version = "0.4", optional = true } diff --git a/postgres-protocol/src/authentication/mod.rs b/postgres-protocol/src/authentication/mod.rs index efe7ce778..0c3b3a307 100644 --- a/postgres-protocol/src/authentication/mod.rs +++ b/postgres-protocol/src/authentication/mod.rs @@ -1,4 +1,5 @@ //! Authentication protocol support. +use crate::hex::LowerHexWrapper; use md5::{Digest, Md5}; pub mod sasl; @@ -13,10 +14,10 @@ pub fn md5_hash(username: &[u8], password: &[u8], salt: [u8; 4]) -> String { let mut md5 = Md5::new(); md5.update(password); md5.update(username); - let output = md5.finalize_reset(); + let output = LowerHexWrapper(md5.finalize_reset()); md5.update(format!("{output:x}")); md5.update(salt); - format!("md5{:x}", md5.finalize()) + format!("md5{:x}", LowerHexWrapper(md5.finalize())) } #[cfg(test)] diff --git a/postgres-protocol/src/authentication/sasl.rs b/postgres-protocol/src/authentication/sasl.rs index 38ea0eede..51e3d1811 100644 --- a/postgres-protocol/src/authentication/sasl.rs +++ b/postgres-protocol/src/authentication/sasl.rs @@ -1,10 +1,10 @@ //! SASL-based authentication support. +use base64::Engine; use base64::display::Base64Display; use base64::engine::general_purpose::STANDARD; -use base64::Engine; -use hmac::{Hmac, Mac}; -use rand::{self, Rng}; +use hmac::{Hmac, KeyInit, Mac}; +use rand::{self, RngExt}; use sha2::digest::FixedOutput; use sha2::{Digest, Sha256}; use std::fmt::Write; @@ -15,6 +15,16 @@ use std::str; const NONCE_LENGTH: usize = 24; +/// The maximum SCRAM iteration count the client will accept from the server. +/// +/// The iteration count is sent by the server and drives a PBKDF2 loop, so an +/// unbounded value lets a malicious or impersonating server force the client to +/// perform an arbitrary number of HMAC operations before authentication even +/// completes (a denial of service). 100_000 is ~24x the PostgreSQL default of +/// 4096 and matches the default cap the PostgreSQL JDBC driver (pgjdbc) adopted +/// for the same issue (CVE-2026-42198). +const MAX_ITERATION_COUNT: u32 = 100_000; + /// The identifier of the SCRAM-SHA-256 SASL authentication mechanism. pub const SCRAM_SHA_256: &str = "SCRAM-SHA-256"; /// The identifier of the SCRAM-SHA-256-PLUS SASL authentication mechanism. @@ -192,6 +202,13 @@ impl ScramSha256 { return Err(io::Error::new(io::ErrorKind::InvalidInput, "invalid nonce")); } + if parsed.iteration_count > MAX_ITERATION_COUNT { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + "SCRAM iteration count exceeds the maximum allowed", + )); + } + let salt = match STANDARD.decode(parsed.salt) { Ok(salt) => salt, Err(e) => return Err(io::Error::new(io::ErrorKind::InvalidInput, e)), @@ -391,7 +408,7 @@ impl<'a> Parser<'a> { } fn value(&mut self) -> io::Result<&'a str> { - self.take_while(|c| matches!(c, '\0' | '=' | ',')) + self.take_while(|c| !matches!(c, '\0' | '=' | ',')) } fn server_error(&mut self) -> io::Result> { @@ -445,6 +462,20 @@ mod test { assert_eq!(message.iteration_count, 4096); } + #[test] + fn parse_server_error_message() { + let message = "e=invalid-proof"; + match Parser::new(message).server_final_message().unwrap() { + ServerFinalMessage::Error(error) => assert_eq!(error, "invalid-proof"), + ServerFinalMessage::Verifier(_) => panic!("expected server error"), + } + + // the error value ends at the first '\0', '=' or ',' + for message in ["invalid-proof\0x", "invalid-proof=x", "invalid-proof,x"] { + assert_eq!(Parser::new(message).value().unwrap(), "invalid-proof"); + } + } + // recorded auth exchange from psql #[test] fn exchange() { @@ -452,11 +483,9 @@ mod test { let nonce = "9IZ2O01zb9IgiIZ1WJ/zgpJB"; let client_first = "n,,n=,r=9IZ2O01zb9IgiIZ1WJ/zgpJB"; - let server_first = - "r=9IZ2O01zb9IgiIZ1WJ/zgpJBjx/oIRLs02gGSHcw1KEty3eY,s=fs3IXBy7U7+IvVjZ,i\ + let server_first = "r=9IZ2O01zb9IgiIZ1WJ/zgpJBjx/oIRLs02gGSHcw1KEty3eY,s=fs3IXBy7U7+IvVjZ,i\ =4096"; - let client_final = - "c=biws,r=9IZ2O01zb9IgiIZ1WJ/zgpJBjx/oIRLs02gGSHcw1KEty3eY,p=AmNKosjJzS3\ + let client_final = "c=biws,r=9IZ2O01zb9IgiIZ1WJ/zgpJBjx/oIRLs02gGSHcw1KEty3eY,p=AmNKosjJzS3\ 1NTlQYNs5BTeQjdHdk7lOflDo5re2an8="; let server_final = "v=U+ppxD5XUKtradnv8e2MkeupiA8FU87Sg8CXzXHDAzw="; @@ -472,4 +501,17 @@ mod test { scram.finish(server_final.as_bytes()).unwrap(); } + + #[test] + fn excessive_iteration_count_is_rejected() { + // a malicious server cannot force an unbounded PBKDF2 loop; the iteration + // count is rejected before `hi()` runs. + let nonce = "9IZ2O01zb9IgiIZ1WJ/zgpJB"; + let server_first = + "r=9IZ2O01zb9IgiIZ1WJ/zgpJBjx/oIRLs02gGSHcw1KEty3eY,s=fs3IXBy7U7+IvVjZ,i=1000000"; + + let mut scram = + ScramSha256::new_inner(b"foobar", ChannelBinding::unsupported(), nonce.to_string()); + assert!(scram.update(server_first.as_bytes()).is_err()); + } } diff --git a/postgres-protocol/src/hex.rs b/postgres-protocol/src/hex.rs new file mode 100644 index 000000000..d34f7c538 --- /dev/null +++ b/postgres-protocol/src/hex.rs @@ -0,0 +1,25 @@ +use std::fmt::{self, Formatter, LowerHex, Write}; + +const HEX_CHARS: &[u8; 16] = b"0123456789abcdef"; + +pub(crate) struct LowerHexWrapper(pub T); + +impl> LowerHex for LowerHexWrapper { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + for &b in self.0.as_ref() { + let [a, b] = encode_byte(b); + f.write_char(char::from(a))?; + f.write_char(char::from(b))?; + } + + Ok(()) + } +} + +const fn encode_byte(byte: u8) -> [u8; 2] { + [lower_nibble_to_hex(byte >> 4), lower_nibble_to_hex(byte)] +} + +const fn lower_nibble_to_hex(half_byte: u8) -> u8 { + HEX_CHARS[(half_byte & 0x0F) as usize] +} diff --git a/postgres-protocol/src/lib.rs b/postgres-protocol/src/lib.rs index e0de3b6c6..0b6a14120 100644 --- a/postgres-protocol/src/lib.rs +++ b/postgres-protocol/src/lib.rs @@ -17,6 +17,7 @@ use std::io; pub mod authentication; pub mod escape; +mod hex; pub mod message; pub mod password; pub mod types; @@ -74,4 +75,5 @@ macro_rules! from_usize { } from_usize!(i16); +from_usize!(u16); from_usize!(i32); diff --git a/postgres-protocol/src/message/backend.rs b/postgres-protocol/src/message/backend.rs index 18e24e0e9..507197d50 100644 --- a/postgres-protocol/src/message/backend.rs +++ b/postgres-protocol/src/message/backend.rs @@ -865,6 +865,12 @@ impl<'a> FallibleIterator for Fields<'a> { format, })) } + + #[inline] + fn size_hint(&self) -> (usize, Option) { + let len = self.remaining as usize; + (len, Some(len)) + } } pub struct Field<'a> { diff --git a/postgres-protocol/src/message/frontend.rs b/postgres-protocol/src/message/frontend.rs index 22c420664..0085007de 100644 --- a/postgres-protocol/src/message/frontend.rs +++ b/postgres-protocol/src/message/frontend.rs @@ -7,7 +7,7 @@ use std::error::Error; use std::io; use std::marker; -use crate::{write_nullable, FromUsize, IsNull, Oid}; +use crate::{FromUsize, IsNull, Oid, write_nullable}; #[inline] fn write_body(buf: &mut BytesMut, f: F) -> Result<(), E> @@ -105,8 +105,8 @@ where serializer(item, buf)?; count += 1; } - let count = i16::from_usize(count)?; - BigEndian::write_i16(&mut buf[base..], count); + let count = u16::from_usize(count)?; + BigEndian::write_u16(&mut buf[base..], count); Ok(()) } @@ -259,7 +259,7 @@ where I: IntoIterator, { write_body(buf, |buf| { - // postgres protocol version 3.0(196608) in bigger-endian + // postgres protocol version 3.0(196608) in big-endian buf.put_i32(0x00_03_00_00); for (key, value) in parameters { write_cstr(key.as_bytes(), buf)?; diff --git a/postgres-protocol/src/password/mod.rs b/postgres-protocol/src/password/mod.rs index 5f0bbabad..80076843e 100644 --- a/postgres-protocol/src/password/mod.rs +++ b/postgres-protocol/src/password/mod.rs @@ -7,11 +7,12 @@ //! end up in logs pg_stat displays, etc. use crate::authentication::sasl; +use crate::hex::LowerHexWrapper; use base64::display::Base64Display; use base64::engine::general_purpose::STANDARD; -use hmac::{Hmac, Mac}; +use hmac::{Hmac, KeyInit, Mac}; use md5::Md5; -use rand::RngCore; +use rand::Rng; use sha2::digest::FixedOutput; use sha2::{Digest, Sha256}; @@ -101,6 +102,6 @@ pub fn md5(password: &[u8], username: &str) -> String { let mut hash = Md5::new(); hash.update(&salted_password); - let digest = hash.finalize(); + let digest = LowerHexWrapper(hash.finalize()); format!("md5{digest:x}") } diff --git a/postgres-protocol/src/types/mod.rs b/postgres-protocol/src/types/mod.rs index 9ebbeb43c..8878e27d4 100644 --- a/postgres-protocol/src/types/mod.rs +++ b/postgres-protocol/src/types/mod.rs @@ -8,7 +8,7 @@ use std::io::Read; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; use std::str; -use crate::{write_nullable, FromUsize, IsNull, Lsn, Oid}; +use crate::{FromUsize, IsNull, Lsn, Oid, write_nullable}; #[cfg(test)] mod test; @@ -273,7 +273,10 @@ impl<'a> FallibleIterator for HstoreEntries<'a> { if key_len < 0 { return Err("invalid key length".into()); } - let (key, buf) = self.buf.split_at(key_len as usize); + let (key, buf) = self + .buf + .split_at_checked(key_len as usize) + .ok_or("invalid key length")?; let key = str::from_utf8(key)?; self.buf = buf; @@ -281,7 +284,10 @@ impl<'a> FallibleIterator for HstoreEntries<'a> { let value = if value_len < 0 { None } else { - let (value, buf) = self.buf.split_at(value_len as usize); + let (value, buf) = self + .buf + .split_at_checked(value_len as usize) + .ok_or("invalid value length")?; let value = str::from_utf8(value)?; self.buf = buf; Some(value) diff --git a/postgres-protocol/src/types/test.rs b/postgres-protocol/src/types/test.rs index 3e33b08f0..83307fbe8 100644 --- a/postgres-protocol/src/types/test.rs +++ b/postgres-protocol/src/types/test.rs @@ -71,6 +71,23 @@ fn hstore() { ); } +#[test] +fn hstore_invalid_length() { + // a malicious server can declare a key (or value) length larger than the + // remaining buffer; this must error rather than panic. + let buf: &[u8] = &[ + 0, 0, 0, 1, // entry count: 1 + 0, 0, 3, 232, // key length: 1000 + b'a', b'b', // only two bytes actually present + ]; + assert!( + hstore_from_sql(buf) + .unwrap() + .collect::>() + .is_err() + ); +} + #[test] fn varbit() { let len = 12;