Merge pull request #712 from EdouardVanbelle/fix/webdav-security

This commit is contained in:
Dionisio Pozo
2026-09-07 21:38:13 +02:00
committed by GitHub
5 changed files with 184 additions and 4 deletions
@@ -19,8 +19,26 @@
use std::sync::Arc;
use std::time::{Duration, Instant};
use subtle::ConstantTimeEq;
use crate::application::adapters::webdav_adapter::{LockInfo, LockScope};
/// Constant-time equality for lock tokens. Same rationale as
/// `webdav_handler::ct_str_eq` — see that helper's doc-comment.
///
/// The two callsites in this file (`refresh` at :169, `release`
/// at :191) are already gated by `self.by_token.get(token)?`, so
/// the attacker CANNOT reach these checks without already having
/// presented a valid token — the practical timing-attack surface is
/// nil. Kept constant-time for defense-in-depth consistency across
/// every token comparison in the WebDAV surface, so a future
/// auditor doesn't have to re-derive "this one is safe because…"
/// for each individual callsite.
#[inline]
fn ct_str_eq(a: &str, b: &str) -> bool {
a.len() == b.len() && a.as_bytes().ct_eq(b.as_bytes()).into()
}
/// Default lock timeout when the client does not specify one (RFC 4918 §10.7).
const DEFAULT_LOCK_TIMEOUT_SECS: u64 = 1800; // 30 minutes
@@ -166,7 +184,7 @@ impl WebDavLockStore {
let path = self.by_token.get(token)?;
let mut entry = self.by_path.get(&path)?;
if entry.info.token != token {
if !ct_str_eq(&entry.info.token, token) {
return None; // token mismatch — lock was replaced
}
@@ -188,7 +206,7 @@ impl WebDavLockStore {
if let Some(path) = self.by_token.get(token) {
// Only remove from by_path if the token still matches
if let Some(entry) = self.by_path.get(&path)
&& entry.info.token == token
&& ct_str_eq(&entry.info.token, token)
{
self.by_path.invalidate(&path);
}
+38 -2
View File
@@ -46,6 +46,7 @@ use crate::interfaces::upload_ingest::{IngestedBlob, RangeSegment, discard_inges
use percent_encoding::{AsciiSet, NON_ALPHANUMERIC, percent_decode_str, utf8_percent_encode};
use std::collections::HashMap;
use std::sync::Arc;
use subtle::ConstantTimeEq;
/// Characters that MUST NOT be percent-encoded inside a URI path segment.
/// RFC 3986 §3.3 pchar = unreserved / pct-encoded / sub-delims / ":" / "@"
@@ -1581,6 +1582,34 @@ fn parse_if_header(header: &str) -> IfLists {
lists
}
/// Constant-time string equality for security-sensitive tokens
/// (WebDAV lock State-tokens today; extend for future session /
/// secret-adjacent comparisons if any).
///
/// Rust's built-in `str::eq` compares byte-wise with early exit on
/// mismatch — the position of the differing byte is observable via
/// timing. For WebDAV lock tokens the practical exploit is not
/// realistic (ns-scale signal buried under ms-scale network jitter,
/// plus ~5×10⁸ samples needed to average through the noise before
/// the lock expires), but the fix is a 5-line change with zero
/// measurable perf cost and matches the "constant-time compare on
/// any token that gates access" hygiene rule the rest of the code
/// follows on session tokens. Reported responsibly on 2026-09-05.
///
/// Length leaks are acceptable here — WebDAV lock tokens have a
/// fixed public format (`opaquelocktoken:<UUID>`), so the length is
/// not secret and any timing distinguishability from a length
/// mismatch reveals nothing an attacker doesn't already know from
/// the URI grammar.
#[inline]
fn ct_str_eq(a: &str, b: &str) -> bool {
// `ct_eq` returns 1 on match, 0 on mismatch — same length always,
// no early exit within the byte compare. Different-length inputs
// still short-circuit at the length check (see doc note above),
// and equal-length inputs run the full constant-time compare.
a.len() == b.len() && a.as_bytes().ct_eq(b.as_bytes()).into()
}
/// Evaluate a parsed `If:` header against the current resource state.
///
/// Returns `(header_true, submitted_active_lock)`:
@@ -1614,7 +1643,7 @@ fn evaluate_if_header(
negated: false,
token,
} = cond
&& token == active
&& ct_str_eq(token, active)
{
submitted_active_lock = true;
}
@@ -1627,7 +1656,14 @@ fn evaluate_if_header(
list.iter().all(|cond| {
let (negated, natural) = match cond {
IfCondition::StateToken { negated, token } => {
let is_active = active_lock_token == Some(token.as_str());
// Constant-time compare (see `ct_str_eq` above).
// `active_lock_token = None` short-circuits at the
// outer `Some(_)` match — that branch is only
// reachable when a lock actually exists, so the
// "no lock present" fast path stays public info.
let is_active = active_lock_token
.map(|a| ct_str_eq(token, a))
.unwrap_or(false);
(*negated, is_active)
}
IfCondition::EntityTag {