fix(security): V-01,V-02,V-04,V-05,V-06 - multiple vulnerability fixes

- V-01: CalDAV unbounded body limit → MAX_CALDAV_BODY = 1MB
- V-02: CardDAV unbounded body limit → MAX_CARDDAV_BODY = 1MB
- V-04: Batch operations without max size → MAX_BATCH_SIZE = 1000
- V-05: WOPI get_editor_url IDOR → authorize_wopi_access with ownership check
- V-06: JWT secret without entropy validation → panic <16, warn 16-31, accept >=32
This commit is contained in:
Dionisio
2026-03-05 16:57:44 +01:00
parent 4197cc3b7b
commit 4d81bcbd7b
5 changed files with 176 additions and 37 deletions
+44 -26
View File
@@ -386,6 +386,28 @@ pub struct EditorUrlResponse {
pub access_token_ttl: i64,
}
/// Determines if `caller_id` can access `file_id` and with what permissions.
///
/// Uses the SQL-level ownership check (`get_file_owned`) so that files
/// belonging to other users — or non-existent files — both return `NOT_FOUND`,
/// avoiding existence-leak oracles.
///
/// Returns `(FileDto, can_write)` on success.
async fn authorize_wopi_access<S: FileRetrievalUseCase>(
file_retrieval: &S,
file_id: &str,
caller_id: &str,
requested_action: &str,
) -> Result<(crate::application::dtos::file_dto::FileDto, bool), StatusCode> {
let file = file_retrieval
.get_file_owned(file_id, caller_id)
.await
.map_err(|_| StatusCode::NOT_FOUND)?;
// Owner verified — grant write unless explicitly requesting view-only.
let can_write = requested_action != "view";
Ok((file, can_write))
}
/// GET /api/wopi/editor-url — Returns the editor iframe URL + WOPI token.
///
/// This endpoint is behind normal auth middleware. The authenticated user
@@ -399,16 +421,17 @@ pub async fn get_editor_url(
Query(params): Query<EditorUrlParams>,
State(state): State<WopiState>,
) -> Response {
// Get file info to determine extension
let file = match state
.app_state
.applications
.file_retrieval_service
.get_file(&params.file_id)
.await
// Verify the caller owns the file (SQL-level check, no existence leak).
let (file, can_write) = match authorize_wopi_access(
state.app_state.applications.file_retrieval_service.as_ref(),
&params.file_id,
&user_id,
&params.action,
)
.await
{
Ok(f) => f,
Err(_) => return StatusCode::NOT_FOUND.into_response(),
Ok(result) => result,
Err(status) => return status.into_response(),
};
// Extract extension from filename
@@ -437,13 +460,6 @@ pub async fn get_editor_url(
}
};
// Determine write permission: owner can write, others read-only.
// If no owner_id on the file, default to allowing write.
let can_write = match &file.owner_id {
Some(owner) => owner == &user_id,
None => true,
};
// Generate WOPI access token
let (access_token, access_token_ttl) =
match state
@@ -485,20 +501,22 @@ async fn host_page(
return StatusCode::UNAUTHORIZED.into_response();
}
// Get file info for extension
let file = match state
.app_state
.applications
.file_retrieval_service
.get_file(&file_id)
.await
// Re-verify ownership even though the token was valid — defence in depth.
let requested_action = if claims.can_write { "edit" } else { "view" };
let file = match authorize_wopi_access(
state.app_state.applications.file_retrieval_service.as_ref(),
&file_id,
&claims.sub,
requested_action,
)
.await
{
Ok(f) => f,
Err(_) => return StatusCode::NOT_FOUND.into_response(),
Ok((f, _)) => f,
Err(status) => return status.into_response(),
};
let extension = file.name.rsplit('.').next().unwrap_or("").to_lowercase();
let action = if claims.can_write { "edit" } else { "view" };
let action = requested_action;
let wopi_src = format!("{}/wopi/files/{}", state.wopi_base_url, file_id);
let editor_url = match state