Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions postgres-protocol/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
18 changes: 9 additions & 9 deletions postgres-protocol/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
[package]
name = "postgres-protocol"
version = "0.6.9"
version = "0.6.12"
authors = ["Steven Fackler <sfackler@gmail.com>"]
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 = []
Expand All @@ -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 }
5 changes: 3 additions & 2 deletions postgres-protocol/src/authentication/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
//! Authentication protocol support.
use crate::hex::LowerHexWrapper;
use md5::{Digest, Md5};

pub mod sasl;
Expand All @@ -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)]
Expand Down
58 changes: 50 additions & 8 deletions postgres-protocol/src/authentication/sasl.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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.
Expand Down Expand Up @@ -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)),
Expand Down Expand Up @@ -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<Option<&'a str>> {
Expand Down Expand Up @@ -445,18 +462,30 @@ 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() {
let password = "foobar";
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=";

Expand All @@ -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());
}
}
25 changes: 25 additions & 0 deletions postgres-protocol/src/hex.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
use std::fmt::{self, Formatter, LowerHex, Write};

const HEX_CHARS: &[u8; 16] = b"0123456789abcdef";

pub(crate) struct LowerHexWrapper<T>(pub T);

impl<T: AsRef<[u8]>> LowerHex for LowerHexWrapper<T> {
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]
}
2 changes: 2 additions & 0 deletions postgres-protocol/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ use std::io;

pub mod authentication;
pub mod escape;
mod hex;
pub mod message;
pub mod password;
pub mod types;
Expand Down Expand Up @@ -74,4 +75,5 @@ macro_rules! from_usize {
}

from_usize!(i16);
from_usize!(u16);
from_usize!(i32);
6 changes: 6 additions & 0 deletions postgres-protocol/src/message/backend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -865,6 +865,12 @@ impl<'a> FallibleIterator for Fields<'a> {
format,
}))
}

#[inline]
fn size_hint(&self) -> (usize, Option<usize>) {
let len = self.remaining as usize;
(len, Some(len))
}
}

pub struct Field<'a> {
Expand Down
8 changes: 4 additions & 4 deletions postgres-protocol/src/message/frontend.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<F, E>(buf: &mut BytesMut, f: F) -> Result<(), E>
Expand Down Expand Up @@ -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(())
}
Expand Down Expand Up @@ -259,7 +259,7 @@ where
I: IntoIterator<Item = (&'a str, &'a str)>,
{
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)?;
Expand Down
7 changes: 4 additions & 3 deletions postgres-protocol/src/password/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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}")
}
12 changes: 9 additions & 3 deletions postgres-protocol/src/types/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -273,15 +273,21 @@ 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;

let value_len = self.buf.read_i32::<BigEndian>()?;
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)
Expand Down
17 changes: 17 additions & 0 deletions postgres-protocol/src/types/test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<HashMap<_, _>>()
.is_err()
);
}

#[test]
fn varbit() {
let len = 12;
Expand Down
Loading