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
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "rcache"
version = "0.11.5"
version = "0.11.6"
edition = "2024"

[dependencies]
Expand Down
54 changes: 52 additions & 2 deletions src/command/acl.rs
Original file line number Diff line number Diff line change
Expand Up @@ -493,7 +493,7 @@ pub fn check_password(username: &str, password: &str) -> AuthOutcome {
}
use sha2::{Digest, Sha256};
let hash = format!("{:x}", Sha256::digest(password.as_bytes()));
if u.passwords.contains(&hash) {
if u.passwords.iter().any(|p| constant_time_eq(p.as_bytes(), hash.as_bytes())) {
AuthOutcome::Ok
} else {
AuthOutcome::WrongPass
Expand All @@ -505,6 +505,30 @@ pub fn check_password(username: &str, password: &str) -> AuthOutcome {
}
}

/// Constant-time byte-slice equality. The length comparison is unavoidable, but
/// content comparison does not short-circuit on the first differing byte, so it
/// leaks no per-byte timing signal.
pub fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
if a.len() != b.len() {
return false;
}
let mut diff = 0u8;
for (x, y) in a.iter().zip(b.iter()) {
diff |= x ^ y;
}
diff == 0
}

/// Constant-time verification of a plaintext secret (e.g. `requirepass`) by
/// comparing fixed-length SHA-256 digests, so neither the secret's length nor a
/// matching prefix leaks through response timing.
pub fn verify_secret(provided: &str, expected: &str) -> bool {
use sha2::{Digest, Sha256};
let a = Sha256::digest(provided.as_bytes());
let b = Sha256::digest(expected.as_bytes());
constant_time_eq(&a, &b)
}

/// Whether `username` may run `cmd`. Unknown users are not blocked here (the
/// connection layer gates unauthenticated access separately); the `default`
/// user always exists.
Expand Down Expand Up @@ -556,7 +580,13 @@ pub fn http_authenticate(token: &str) -> Option<String> {
let users = ACL_USERS.lock().ok()?;
users
.iter()
.find(|(_, u)| u.enabled && !u.no_pass && u.passwords.contains(&hash))
.find(|(_, u)| {
u.enabled
&& !u.no_pass
&& u.passwords
.iter()
.any(|p| constant_time_eq(p.as_bytes(), hash.as_bytes()))
})
.map(|(name, _)| name.clone())
}

Expand All @@ -579,3 +609,23 @@ static ACL_USERS: std::sync::LazyLock<Mutex<HashMap<String, AclUserEntry>>> =
);
Mutex::new(users)
});

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn test_constant_time_eq() {
assert!(constant_time_eq(b"abc", b"abc"));
assert!(!constant_time_eq(b"abc", b"abd"));
assert!(!constant_time_eq(b"abc", b"ab"));
assert!(constant_time_eq(b"", b""));
}

#[test]
fn test_verify_secret() {
assert!(verify_secret("hunter2", "hunter2"));
assert!(!verify_secret("hunter2", "hunter3"));
assert!(!verify_secret("", "x"));
}
}
6 changes: 6 additions & 0 deletions src/command/bitmap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,12 @@ pub fn cmd_bitcount(ctx: &mut CommandContext) -> RespValue {
let use_bit = ctx.args.len() > 4 &&
String::from_utf8_lossy(&ctx.args[4]).to_uppercase() == "BIT";

// An empty value has no bits/bytes to count. Guard here so the range math
// below (`data.len() - 1` / `total_bits - 1`) can't underflow.
if data.is_empty() {
return RespValue::integer(0);
}

if use_bit {
// BIT mode: start and end are bit offsets
let total_bits = data.len() * 8;
Expand Down
11 changes: 10 additions & 1 deletion src/command/hash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ use crate::protocol::RespValue;
use crate::storage::RedisObject;
use super::registry::CommandContext;

/// Upper bound on the magnitude of a negative HRANDFIELD `count` (the
/// with-duplicates form), preventing an unbounded allocation.
const MAX_RAND_COUNT: u64 = 1_000_000;

fn ensure_hash<'a>(ctx: &'a mut CommandContext, key: &Bytes) -> Result<&'a mut HashMap<Bytes, Bytes>, RespValue> {
match ctx.db().get_or_insert_with(key, || RedisObject::Hash(HashMap::new())) {
RedisObject::Hash(h) => Ok(h),
Expand Down Expand Up @@ -310,7 +314,12 @@ pub fn cmd_hrandfield(ctx: &mut CommandContext) -> RespValue {
}
}
Some(n) => {
let n = (-n) as usize;
// Negative count allows duplicates; cap the magnitude and
// guard i64::MIN to avoid an unbounded allocation.
let n = match n.checked_neg() {
Some(v) if v as u64 <= MAX_RAND_COUNT => v as usize,
_ => return RespValue::error("ERR count value is out of range"),
};
use rand::seq::SliceRandom;
let entries: Vec<(Bytes, Bytes)> = hash.iter().map(|(k, v)| (k.clone(), v.clone())).collect();
let mut items = Vec::new();
Expand Down
61 changes: 58 additions & 3 deletions src/command/probabilistic.rs
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,13 @@ pub fn cmd_bf_reserve(ctx: &mut CommandContext) -> RespValue {
_ => return RespValue::error("ERR (error) bad capacity"),
};

// Reject dimensions that would allocate more than the per-structure cap.
let num_bits = optimal_bits(capacity, error_rate).max(8);
let byte_count = (num_bits as usize + 7) / 8;
if byte_count > MAX_PROB_BYTES {
return RespValue::error("ERR Bloom filter dimensions too large");
}

let db = ctx.db();
if db.exists(&key) {
return RespValue::error("ERR item exists");
Expand Down Expand Up @@ -364,7 +371,18 @@ fn new_cms(width: u32, depth: u32) -> Vec<u8> {
}

fn is_cms(data: &[u8]) -> bool {
data.len() >= CMS_META_LEN && &data[0..CMS_HEADER_LEN] == CMS_HEADER
if data.len() < CMS_META_LEN || &data[0..CMS_HEADER_LEN] != CMS_HEADER {
return false;
}
// Reject a forged value whose declared dimensions don't fit the buffer.
// Without this, counter accessors index out of bounds (panic) and readers
// allocate from attacker-controlled dimensions.
let width = cms_width(data) as usize;
let depth = cms_depth(data) as usize;
match width.checked_mul(depth).and_then(|c| c.checked_mul(8)) {
Some(counter_bytes) => data.len() >= CMS_META_LEN + counter_bytes,
None => false,
}
}

fn cms_width(data: &[u8]) -> u32 {
Expand Down Expand Up @@ -754,7 +772,17 @@ fn new_topk(k: u32, width: u32, depth: u32, decay: f64) -> Vec<u8> {
}

fn is_topk(data: &[u8]) -> bool {
data.len() >= TOPK_META_LEN && &data[0..TOPK_HEADER_LEN] == TOPK_HEADER
if data.len() < TOPK_META_LEN || &data[0..TOPK_HEADER_LEN] != TOPK_HEADER {
return false;
}
// Reject a forged value whose declared CMS dimensions don't fit the buffer,
// so `from_bytes` cannot read counters out of bounds.
let width = topk_width(data) as usize;
let depth = topk_depth(data) as usize;
match width.checked_mul(depth).and_then(|c| c.checked_mul(8)) {
Some(counter_bytes) => data.len() >= TOPK_META_LEN + counter_bytes,
None => false,
}
}

fn topk_k(data: &[u8]) -> u32 {
Expand Down Expand Up @@ -810,7 +838,10 @@ impl TopKState {
counters.push(val);
}

let mut heap = Vec::with_capacity(num_items);
// Each heap entry needs at least 12 bytes (4-byte name length + 8-byte
// count), so bound the pre-allocation by what the buffer can actually
// hold — a forged `num_items` can't drive a huge reservation.
let mut heap = Vec::with_capacity(num_items.min(data.len() / 12 + 1));
let mut pos = TOPK_META_LEN + cms_size * 8;
for _ in 0..num_items {
if pos + 4 > data.len() {
Expand Down Expand Up @@ -1206,6 +1237,30 @@ mod tests {
assert_eq!(k, 10);
}

#[test]
fn test_forged_cms_header_rejected() {
// Magic + oversized declared width/depth but a short buffer must be
// rejected so counter accessors can't index out of bounds.
let mut forged = Vec::new();
forged.extend_from_slice(CMS_HEADER);
forged.extend_from_slice(&u32::MAX.to_le_bytes()); // width
forged.extend_from_slice(&u32::MAX.to_le_bytes()); // depth
forged.extend_from_slice(&0u64.to_le_bytes()); // total_count
assert!(!is_cms(&forged));
}

#[test]
fn test_forged_topk_header_rejected() {
let mut forged = Vec::new();
forged.extend_from_slice(TOPK_HEADER);
forged.extend_from_slice(&3u32.to_le_bytes()); // k
forged.extend_from_slice(&u32::MAX.to_le_bytes()); // width
forged.extend_from_slice(&u32::MAX.to_le_bytes()); // depth
forged.extend_from_slice(&0.9f64.to_le_bytes()); // decay
forged.extend_from_slice(&0u32.to_le_bytes()); // num_items
assert!(!is_topk(&forged));
}

#[test]
fn test_cms_basic() {
let mut data = new_cms(100, 5);
Expand Down
14 changes: 12 additions & 2 deletions src/command/set.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ use crate::protocol::RespValue;
use crate::storage::RedisObject;
use super::registry::CommandContext;

/// Upper bound on the magnitude of a negative `count` for SRANDMEMBER /
/// ZRANDMEMBER / HRANDFIELD (the with-duplicates form), preventing an
/// attacker from forcing a multi-billion-element allocation.
const MAX_RAND_COUNT: u64 = 1_000_000;

fn get_set<'a>(ctx: &'a mut CommandContext, key: &Bytes) -> Result<Option<&'a HashSet<Bytes>>, RespValue> {
match ctx.db().get(key) {
Some(RedisObject::Set(s)) => Ok(Some(s)),
Expand Down Expand Up @@ -154,8 +159,13 @@ pub fn cmd_srandmember(ctx: &mut CommandContext) -> RespValue {
RespValue::array(members)
}
Some(n) => {
// Negative count: allow duplicates
let n = (-n) as usize;
// Negative count: allow duplicates. Cap the magnitude (and
// guard i64::MIN's negation) so a huge count can't allocate
// billions of elements.
let n = match n.checked_neg() {
Some(v) if v as u64 <= MAX_RAND_COUNT => v as usize,
_ => return RespValue::error("ERR count value is out of range"),
};
use rand::seq::SliceRandom;
let items: Vec<&Bytes> = set.iter().collect();
let members: Vec<RespValue> = (0..n)
Expand Down
11 changes: 10 additions & 1 deletion src/command/sorted_set.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ use crate::storage::types::SortedSetData;
use crate::storage::RedisObject;
use super::registry::CommandContext;

/// Upper bound on the magnitude of a negative ZRANDMEMBER `count` (the
/// with-duplicates form), preventing an unbounded allocation.
const MAX_RAND_COUNT: u64 = 1_000_000;

fn get_zset<'a>(ctx: &'a mut CommandContext, key: &Bytes) -> Result<Option<&'a SortedSetData>, RespValue> {
match ctx.db().get(key) {
Some(RedisObject::SortedSet(z)) => Ok(Some(z)),
Expand Down Expand Up @@ -588,7 +592,12 @@ pub fn cmd_zrandmember(ctx: &mut CommandContext) -> RespValue {
}
}
Some(n) => {
let n = (-n) as usize;
// Negative count allows duplicates; cap the magnitude and
// guard i64::MIN to avoid an unbounded allocation.
let n = match n.checked_neg() {
Some(v) if v as u64 <= MAX_RAND_COUNT => v as usize,
_ => return RespValue::error("ERR count value is out of range"),
};
use rand::seq::SliceRandom;
let all: Vec<(Bytes, f64)> = zset.range_by_index(0, -1);
let mut items = Vec::new();
Expand Down
57 changes: 49 additions & 8 deletions src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,13 @@ pub async fn run_http_server(state: Arc<SharedState>, port: u16) -> Result<(), B
}
});

if let Err(e) = http1::Builder::new().serve_connection(io, svc).await {
// Bound how long a client may take to send request headers, so a
// slow-loris client cannot hold a connection open indefinitely.
let mut builder = http1::Builder::new();
builder
.timer(hyper_util::rt::TokioTimer::new())
.header_read_timeout(std::time::Duration::from_secs(15));
if let Err(e) = builder.serve_connection(io, svc).await {
tracing::debug!("HTTP connection error: {}", e);
}
});
Expand Down Expand Up @@ -77,17 +83,21 @@ async fn handle_request(
(Method::GET, "/info") => handle_info(&state).await,
(Method::GET, "/metrics") => handle_metrics(&state).await,
(Method::POST, "/api/v1/command") => {
let body = req.collect().await?.to_bytes();
handle_command(&state, &user, &body).await
match collect_limited(req).await {
Ok(body) => handle_command(&state, &user, &body).await,
Err(resp) => Ok(resp),
}
}
(Method::GET, p) if p.starts_with("/api/v1/") => {
let key = &p["/api/v1/".len()..];
handle_get_key(&state, &user, key).await
}
(Method::PUT, p) if p.starts_with("/api/v1/") => {
let key = &p["/api/v1/".len()..];
let body = req.collect().await?.to_bytes();
handle_put_key(&state, &user, key, &body).await
let key = p["/api/v1/".len()..].to_string();
match collect_limited(req).await {
Ok(body) => handle_put_key(&state, &user, &key, &body).await,
Err(resp) => Ok(resp),
}
}
(Method::DELETE, p) if p.starts_with("/api/v1/") => {
let key = &p["/api/v1/".len()..];
Expand Down Expand Up @@ -140,17 +150,48 @@ async fn check_auth(
};

if let Some(ref req_pass) = state.config.requirepass {
if token == *req_pass {
if crate::command::acl::verify_secret(&token, req_pass) {
return Ok("default".to_string());
}
}

match acl::http_authenticate(&token) {
Some(user) => Ok(user),
None => Err(unauthorized_response()),
None => {
// Fixed delay on a bad credential to slow brute-force attempts. The
// HTTP path is stateless per request, so this is a per-attempt cost
// rather than a per-connection backoff.
tokio::time::sleep(std::time::Duration::from_millis(AUTH_FAIL_DELAY_MS)).await;
Err(unauthorized_response())
}
}
}

/// Maximum request-body size accepted by the HTTP API (64 MB). Bounds
/// memory used before a handler runs, preventing a body-flood DoS.
const MAX_HTTP_BODY: usize = 64 * 1024 * 1024;
/// Delay applied after a failed HTTP auth attempt to slow brute force.
const AUTH_FAIL_DELAY_MS: u64 = 250;

/// Collect a request body, rejecting anything larger than `MAX_HTTP_BODY`.
async fn collect_limited(req: Request<Incoming>) -> Result<Bytes, Response<Full<Bytes>>> {
use http_body_util::Limited;
let limited = Limited::new(req.into_body(), MAX_HTTP_BODY);
match limited.collect().await {
Ok(collected) => Ok(collected.to_bytes()),
Err(_) => Err(payload_too_large_response()),
}
}

fn payload_too_large_response() -> Response<Full<Bytes>> {
let body = serde_json::to_vec(&json!({"error": "payload too large"})).unwrap_or_default();
Response::builder()
.status(StatusCode::PAYLOAD_TOO_LARGE)
.header("Content-Type", "application/json")
.body(Full::new(Bytes::from(body)))
.expect("static response builder")
}

fn unauthorized_response() -> Response<Full<Bytes>> {
let body = serde_json::to_vec(&json!({"error": "unauthorized"})).unwrap_or_default();
Response::builder()
Expand Down
Loading
Loading