Merge pull request #336 from gidsi/fix/caldav-username-prefix-routing

This commit is contained in:
Dionisio Pozo
2026-04-30 23:39:20 +02:00
committed by GitHub
2 changed files with 151 additions and 17 deletions
+112 -11
View File
@@ -136,6 +136,38 @@ fn reject_path_traversal(path: &str) -> Result<(), AppError> {
Ok(())
}
// ─── Helper: strip optional username prefix from CalDAV path ─────────
//
// The `calendar-home-set` discovery property returns `/caldav/{username}/`,
// so standard clients (DAVx5, Apple Calendar, Thunderbird) will prefix all
// subsequent requests with the username segment. The handlers below expect
// paths of the form `{calendar_id}` or `{calendar_id}/{event}.ics`, so we
// need to detect and strip the leading username when present.
//
// Heuristic: if the first path segment is a valid UUID it is already a
// calendar ID; otherwise treat it as a username and skip it.
fn strip_username_prefix(path: &str) -> &str {
if let Some(pos) = path.find('/') {
let first = &path[..pos];
if uuid::Uuid::parse_str(first).is_ok() {
// First segment is a UUID → no username prefix
path
} else {
// First segment is not a UUID → treat as username, return the rest
&path[pos + 1..]
}
} else {
// Single segment (no slash)
if uuid::Uuid::parse_str(path).is_ok() {
path
} else {
// Single non-UUID segment (bare username) → nothing useful after it
""
}
}
}
// ─── Helper: extract user from request ───────────────────────────────
fn extract_user(req: &Request<Body>) -> Result<AuthUser, AppError> {
@@ -260,12 +292,24 @@ async fn handle_propfind(
// {calendar_id}/{event_uid}.ics — individual event
// {username}/{calendar_id} — calendar under user home
// {username}/{calendar_id}/{uid}.ics — event under user home
//
// Use strip_username_prefix heuristic: if first segment is a UUID
// it's a calendar ID, otherwise it's a username prefix.
let parts: Vec<&str> = path.splitn(2, '/').collect();
let first_segment = parts[0];
let first_is_uuid = uuid::Uuid::parse_str(first_segment).is_ok();
if parts.len() == 1 {
// Single path segment: try as calendar ID first, fall back to user home
let calendar_result = calendar_service.get_calendar(first_segment, user.id).await;
// Single path segment: UUID means calendar ID, otherwise user home
let calendar_result = if first_is_uuid {
calendar_service.get_calendar(first_segment, user.id).await
} else {
Err(crate::domain::errors::DomainError::new(
crate::domain::errors::ErrorKind::NotFound,
"Calendar",
"Not a UUID",
))
};
if let Ok(calendar) = calendar_result {
// Valid calendar ID — return calendar collection
@@ -327,10 +371,8 @@ async fn handle_propfind(
// Multi-segment path: {something}/{rest}
let rest = parts[1];
// Check if first_segment is a valid calendar ID
let calendar_result = calendar_service.get_calendar(first_segment, user.id).await;
let (calendar_id, event_path) = if calendar_result.is_ok() {
// Use UUID heuristic: if first segment is a UUID it's a calendar ID
let (calendar_id, event_path) = if first_is_uuid {
// first_segment is a calendar ID, rest is event path
(first_segment, rest)
} else {
@@ -434,7 +476,8 @@ async fn handle_report(
let report = CalDavAdapter::parse_report(body_bytes.reader())
.map_err(|e| AppError::bad_request(format!("Failed to parse REPORT: {}", e)))?;
let calendar_id = path.split('/').next().unwrap_or(path);
let effective_path = strip_username_prefix(path);
let calendar_id = effective_path.split('/').next().unwrap_or(effective_path);
if calendar_id.is_empty() {
return Err(AppError::bad_request("Calendar ID required in path"));
@@ -546,7 +589,8 @@ async fn handle_put(
let user = extract_user(&req)?;
let calendar_service = get_calendar_service(&state)?;
let parts: Vec<&str> = path.splitn(2, '/').collect();
let effective_path = strip_username_prefix(path);
let parts: Vec<&str> = effective_path.splitn(2, '/').collect();
if parts.len() < 2 {
return Err(AppError::bad_request(
"Path must be {calendar_id}/{uid}.ics",
@@ -635,7 +679,8 @@ async fn handle_get(
let user = extract_user(&req)?;
let calendar_service = get_calendar_service(&state)?;
let parts: Vec<&str> = path.splitn(2, '/').collect();
let effective_path = strip_username_prefix(path);
let parts: Vec<&str> = effective_path.splitn(2, '/').collect();
let calendar_id = parts[0];
if parts.len() < 2 {
@@ -751,7 +796,8 @@ async fn handle_delete(
let user = extract_user(&req)?;
let calendar_service = get_calendar_service(&state)?;
let parts: Vec<&str> = path.splitn(2, '/').collect();
let effective_path = strip_username_prefix(path);
let parts: Vec<&str> = effective_path.splitn(2, '/').collect();
let calendar_id = parts[0];
if calendar_id.is_empty() {
@@ -809,7 +855,8 @@ async fn handle_proppatch(
)
.map_err(|e| AppError::bad_request(format!("Failed to parse PROPPATCH: {}", e)))?;
let calendar_id = path.split('/').next().unwrap_or(path);
let effective_path = strip_username_prefix(path);
let calendar_id = effective_path.split('/').next().unwrap_or(effective_path);
if calendar_id.is_empty() {
return Err(AppError::bad_request("Calendar ID required"));
@@ -861,3 +908,57 @@ async fn handle_proppatch(
.body(Body::from(response_body))
.unwrap())
}
#[cfg(test)]
mod tests {
use super::strip_username_prefix;
#[test]
fn test_strip_username_prefix_uuid_only() {
let uuid = "ae8ae236-709f-4939-b766-37ad589ac7f2";
assert_eq!(strip_username_prefix(uuid), uuid);
}
#[test]
fn test_strip_username_prefix_uuid_with_event() {
let path = "ae8ae236-709f-4939-b766-37ad589ac7f2/event.ics";
assert_eq!(strip_username_prefix(path), path);
}
#[test]
fn test_strip_username_prefix_username_and_uuid() {
let path = "timm/ae8ae236-709f-4939-b766-37ad589ac7f2";
assert_eq!(
strip_username_prefix(path),
"ae8ae236-709f-4939-b766-37ad589ac7f2"
);
}
#[test]
fn test_strip_username_prefix_username_uuid_and_event() {
let path = "timm/ae8ae236-709f-4939-b766-37ad589ac7f2/event.ics";
assert_eq!(
strip_username_prefix(path),
"ae8ae236-709f-4939-b766-37ad589ac7f2/event.ics"
);
}
#[test]
fn test_strip_username_prefix_bare_username() {
assert_eq!(strip_username_prefix("timm"), "");
}
#[test]
fn test_strip_username_prefix_empty() {
assert_eq!(strip_username_prefix(""), "");
}
#[test]
fn test_strip_username_prefix_email_style_username() {
let path = "user@example.com/ae8ae236-709f-4939-b766-37ad589ac7f2/event.ics";
assert_eq!(
strip_username_prefix(path),
"ae8ae236-709f-4939-b766-37ad589ac7f2/event.ics"
);
}
}
+39 -6
View File
@@ -124,6 +124,33 @@ fn reject_path_traversal(path: &str) -> Result<(), AppError> {
Ok(())
}
// ─── Helper: strip optional username prefix from CardDAV path ────────
//
// The `addressbook-home-set` discovery property returns `/carddav/{username}/`,
// so standard clients will prefix all subsequent requests with the username segment.
// The handlers below expect paths of the form `{address_book_id}` or `{address_book_id}/{contact}.vcf`.
//
// Heuristic: if the first path segment is a valid UUID it is already an
// address book ID; otherwise treat it as a username and skip it.
fn strip_username_prefix(path: &str) -> &str {
if let Some(pos) = path.find('/') {
let first = &path[..pos];
if uuid::Uuid::parse_str(first).is_ok() {
path
} else {
&path[pos + 1..]
}
} else {
if uuid::Uuid::parse_str(path).is_ok() {
path
} else {
""
}
}
}
// ─── Helper: extract user from request ───────────────────────────────
fn extract_user(req: &Request<Body>) -> Result<AuthUser, AppError> {
@@ -226,7 +253,8 @@ async fn handle_propfind(
.body(Body::from(response_body))
.unwrap())
} else {
let parts: Vec<&str> = path.splitn(2, '/').collect();
let effective_path = strip_username_prefix(path);
let parts: Vec<&str> = effective_path.splitn(2, '/').collect();
let address_book_id = parts[0];
if parts.len() == 1 {
@@ -324,7 +352,8 @@ async fn handle_report(
let report = CardDavAdapter::parse_report(body_bytes.reader())
.map_err(|e| AppError::bad_request(format!("Failed to parse REPORT: {}", e)))?;
let address_book_id = path.split('/').next().unwrap_or(path);
let effective_path = strip_username_prefix(path);
let address_book_id = effective_path.split('/').next().unwrap_or(effective_path);
if address_book_id.is_empty() {
return Err(AppError::bad_request("Address book ID required in path"));
@@ -431,7 +460,8 @@ async fn handle_put(
let user = extract_user(&req)?;
let contact_svc = get_contact_service(&state)?;
let parts: Vec<&str> = path.splitn(2, '/').collect();
let effective_path = strip_username_prefix(path);
let parts: Vec<&str> = effective_path.splitn(2, '/').collect();
if parts.len() < 2 {
return Err(AppError::bad_request(
"Path must be {address_book_id}/{uid}.vcf",
@@ -524,7 +554,8 @@ async fn handle_get(
let user = extract_user(&req)?;
let contact_svc = get_contact_service(&state)?;
let parts: Vec<&str> = path.splitn(2, '/').collect();
let effective_path = strip_username_prefix(path);
let parts: Vec<&str> = effective_path.splitn(2, '/').collect();
let address_book_id = parts[0];
if parts.len() < 2 {
@@ -581,7 +612,8 @@ async fn handle_delete(
let addressbook_service = get_addressbook_service(&state)?;
let contact_svc = get_contact_service(&state)?;
let parts: Vec<&str> = path.splitn(2, '/').collect();
let effective_path = strip_username_prefix(path);
let parts: Vec<&str> = effective_path.splitn(2, '/').collect();
let address_book_id = parts[0];
if address_book_id.is_empty() {
@@ -643,7 +675,8 @@ async fn handle_proppatch(
)
.map_err(|e| AppError::bad_request(format!("Failed to parse PROPPATCH: {}", e)))?;
let address_book_id = path.split('/').next().unwrap_or(path);
let effective_path = strip_username_prefix(path);
let address_book_id = effective_path.split('/').next().unwrap_or(effective_path);
if address_book_id.is_empty() {
return Err(AppError::bad_request("Address book ID required"));