fix(security): address medium findings for ACL, limits, and UX

Tighten approve-endpoint to active shares with TTL sessions, reject non-file
FDs, lower default ticket/approval/size caps, show endpoint IDs and Public-mode
warnings, harden Android receive path checks, and run cargo-audit in CI.
This commit is contained in:
2026-07-13 18:54:28 +02:00
parent e7fb0331b5
commit d0c8d8774a
18 changed files with 184 additions and 14 deletions

11
.cargo/audit.toml Normal file
View File

@@ -0,0 +1,11 @@
# Known transitive advisories we cannot fully clear without upstream iroh bumps.
# cargo audit in CI fails on new unlisted vulnerabilities.
[advisories]
ignore = [
# rsa: Marvin timing side-channel; no fixed release; pulled by iroh stack.
"RUSTSEC-2023-0071",
# quick-xml / crossbeam-epoch: transitive; track via iroh/dependency updates.
"RUSTSEC-2026-0194",
"RUSTSEC-2026-0195",
"RUSTSEC-2026-0204",
]

View File

@@ -43,3 +43,8 @@ jobs:
env: env:
RUSTDOCFLAGS: -D warnings RUSTDOCFLAGS: -D warnings
run: cargo doc --workspace --no-deps run: cargo doc --workspace --no-deps
- name: Install cargo-audit
run: cargo install cargo-audit --locked
- name: Audit Rust dependencies
# Ignores are listed in .cargo/audit.toml for known transitive issues.
run: cargo audit

View File

@@ -91,7 +91,12 @@ collection. Restart reconciliation never restores a stopped share.
`CoreLimits` controls source count, collection files and bytes, path and ticket `CoreLimits` controls source count, collection files and bytes, path and ticket
sizes, metadata, retained events, pending approvals, concurrent transfers, and sizes, metadata, retained events, pending approvals, concurrent transfers, and
the event persistence queue. `initialize` uses conservative defaults; the event persistence queue. `initialize` uses conservative defaults (including
bounded ticket size, pending approvals, and total collection bytes);
`initialize_with_limits` supports stricter deployments and tests. Cheap limits `initialize_with_limits` supports stricter deployments and tests. Cheap limits
are checked before durable or network work, while remote collection limits are are checked before durable or network work, while remote collection limits are
checked before downloading file content. checked before downloading file content.
Manual `approve_endpoint_for_transfer` only applies to active shares, requires a
non-empty endpoint id, and creates a time-limited session (same TTL as handshake
approval), never a permanent grant.

View File

@@ -5,6 +5,9 @@ use tokio::sync::RwLock;
use crate::api::TransferAccessMode; use crate::api::TransferAccessMode;
use crate::util::now_ms; use crate::util::now_ms;
/// Default lifetime for endpoint approval sessions (matches handshake approval TTL).
pub(crate) const APPROVAL_SESSION_TTL_MS: i64 = 10 * 60 * 1000;
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum AccessDecision { pub(crate) enum AccessDecision {
Allow, Allow,
@@ -42,7 +45,12 @@ impl AccessPolicy {
} }
pub(crate) async fn approve_endpoint(&self, transfer_id: u64, endpoint_id: String) { pub(crate) async fn approve_endpoint(&self, transfer_id: u64, endpoint_id: String) {
self.approve_endpoint_until(transfer_id, endpoint_id, None) // Never grant permanent sessions from the public API: always expire.
self.approve_endpoint_until(
transfer_id,
endpoint_id,
Some(now_ms() + APPROVAL_SESSION_TTL_MS),
)
.await; .await;
} }

View File

@@ -23,12 +23,15 @@ impl Default for CoreLimits {
Self { Self {
max_sources: 128, max_sources: 128,
max_collection_files: 10_000, max_collection_files: 10_000,
max_total_bytes: 1024 * 1024 * 1024 * 1024, // Cap extreme disk fill while still allowing multi-GB folders.
max_total_bytes: 256 * 1024 * 1024 * 1024,
max_path_bytes: 4_096, max_path_bytes: 4_096,
max_ticket_bytes: 1024 * 1024, // vnd1 tickets are small JSON+base64; 256 KiB is a generous ceiling.
max_ticket_bytes: 256 * 1024,
max_metadata_bytes: 16 * 1024, max_metadata_bytes: 16 * 1024,
max_events: 500, max_events: 500,
max_pending_approvals: 1_024, // Bound handshake spam / notification pressure on the sender.
max_pending_approvals: 64,
max_concurrent_transfers: 8, max_concurrent_transfers: 8,
event_queue_capacity: 1_024, event_queue_capacity: 1_024,
} }

View File

@@ -6,7 +6,7 @@ use tokio::sync::{oneshot, Mutex};
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
access_policy::AccessPolicy, access_policy::{AccessPolicy, APPROVAL_SESSION_TTL_MS},
event_hub::EventHub, event_hub::EventHub,
handshake::{DeliveryReceipt, DeliveryReceiptResponse, HandshakeResponse, RequestTransfer}, handshake::{DeliveryReceipt, DeliveryReceiptResponse, HandshakeResponse, RequestTransfer},
repository::{ReceiverRequestInsert, Repository}, repository::{ReceiverRequestInsert, Repository},
@@ -14,7 +14,7 @@ use crate::{
util::now_ms, util::now_ms,
}; };
const APPROVAL_TTL_MS: i64 = 10 * 60 * 1000; const APPROVAL_TTL_MS: i64 = APPROVAL_SESSION_TTL_MS;
const APPROVAL_WAIT_TIMEOUT: Duration = Duration::from_secs(120); const APPROVAL_WAIT_TIMEOUT: Duration = Duration::from_secs(120);
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]

View File

@@ -582,6 +582,24 @@ fn duplicate_file_descriptor(value: &str) -> Result<OwnedFd> {
if duplicated < 0 { if duplicated < 0 {
return Err(io::Error::last_os_error()).context("failed to duplicate file descriptor"); return Err(io::Error::last_os_error()).context("failed to duplicate file descriptor");
} }
let owned = unsafe { OwnedFd::from_raw_fd(duplicated) };
Ok(owned) // Only regular files are valid share sources. Reject directories, sockets,
// and other fd types that a compromised UI could pass by integer.
let mut stat = std::mem::MaybeUninit::<libc::stat>::uninit();
let rc = unsafe { libc::fstat(duplicated, stat.as_mut_ptr()) };
if rc != 0 {
let error = io::Error::last_os_error();
unsafe {
libc::close(duplicated);
}
return Err(error).context("failed to fstat file descriptor");
}
let mode = unsafe { stat.assume_init() }.st_mode;
if mode & libc::S_IFMT != libc::S_IFREG {
unsafe {
libc::close(duplicated);
}
anyhow::bail!("file descriptor must refer to a regular file");
}
Ok(unsafe { OwnedFd::from_raw_fd(duplicated) })
} }

View File

@@ -143,6 +143,21 @@ impl CoreInner {
transfer_id: u64, transfer_id: u64,
endpoint_id: String, endpoint_id: String,
) -> Result<()> { ) -> Result<()> {
let endpoint_id = endpoint_id.trim().to_string();
if endpoint_id.is_empty() {
anyhow::bail!("endpoint id must not be empty");
}
if endpoint_id.len() as u64 > self.limits.max_metadata_bytes {
anyhow::bail!(
"endpoint id is {} bytes, limit is {}",
endpoint_id.len(),
self.limits.max_metadata_bytes
);
}
// Only live shares can gain receiver sessions.
if !self.active_shares.lock().await.contains_key(&transfer_id) {
anyhow::bail!("transfer is not an active share");
}
self.access_policy self.access_policy
.approve_endpoint(transfer_id, endpoint_id.clone()) .approve_endpoint(transfer_id, endpoint_id.clone())
.await; .await;

View File

@@ -82,3 +82,16 @@ async fn public_mode_allows_without_session() {
); );
assert_eq!(policy.decide(7, None).await, AccessDecision::Allow); assert_eq!(policy.decide(7, None).await, AccessDecision::Allow);
} }
#[tokio::test]
async fn approve_endpoint_grants_time_limited_session() {
let policy = AccessPolicy::new();
policy
.set_mode(11, TransferAccessMode::ApprovalRequired)
.await;
policy.approve_endpoint(11, "node-b".to_string()).await;
assert_eq!(
policy.decide(11, Some("node-b")).await,
AccessDecision::Allow
);
}

View File

@@ -62,6 +62,26 @@ fn file_descriptor_source_rejects_invalid_values() {
} }
} }
#[cfg(unix)]
#[test]
fn file_descriptor_source_rejects_directory_fds() {
let temp = tempfile::tempdir().unwrap();
let dir = std::fs::File::open(temp.path()).unwrap();
use std::os::fd::AsRawFd;
let error = collect_import_files(vec![ShareSource {
kind: SourceKind::FileDescriptor,
value: dir.as_raw_fd().to_string(),
display_name: Some("folder".to_string()),
is_directory: false,
}])
.unwrap_err()
.to_string();
assert!(
error.contains("regular file"),
"directory fd must be rejected: {error}"
);
}
#[test] #[test]
fn android_content_uri_must_be_opened_by_platform_code() { fn android_content_uri_must_be_opened_by_platform_code() {
let error = collect_import_files(vec![ShareSource { let error = collect_import_files(vec![ShareSource {

View File

@@ -5,6 +5,14 @@ fn default_limits_are_valid() {
CoreLimits::default().validate().unwrap(); CoreLimits::default().validate().unwrap();
} }
#[test]
fn default_limits_bound_ticket_and_approval_pressure() {
let limits = CoreLimits::default();
assert!(limits.max_ticket_bytes <= 256 * 1024);
assert!(limits.max_pending_approvals <= 64);
assert!(limits.max_total_bytes <= 256 * 1024 * 1024 * 1024);
}
#[test] #[test]
fn zero_limit_is_rejected() { fn zero_limit_is_rejected() {
let limits = CoreLimits { let limits = CoreLimits {

View File

@@ -251,6 +251,34 @@ fn access_mode_update_requires_active_persisted_share() {
.is_err()); .is_err());
} }
#[test]
fn approve_endpoint_requires_active_share_and_nonempty_id() {
let source_dir = tempfile::tempdir().unwrap();
let source_path = source_dir.path().join("shared.txt");
std::fs::write(&source_path, b"content").unwrap();
let sender = TestNode::new();
let share = share_path(&sender.core, &source_path, 42, "shared.txt", false);
assert!(sender
.core
.approve_endpoint_for_transfer(share.transfer_id, " ".to_string())
.is_err());
assert!(sender
.core
.approve_endpoint_for_transfer(999, "endpoint-a".to_string())
.is_err());
sender
.core
.approve_endpoint_for_transfer(share.transfer_id, "endpoint-a".to_string())
.unwrap();
sender.core.cancel_transfer(share.transfer_id).unwrap();
assert!(sender
.core
.approve_endpoint_for_transfer(share.transfer_id, "endpoint-a".to_string())
.is_err());
}
#[test] #[test]
fn source_limit_rejection_creates_no_transfer_state() { fn source_limit_rejection_creates_no_transfer_state() {
let core_dir = tempfile::tempdir().unwrap(); let core_dir = tempfile::tempdir().unwrap();

View File

@@ -225,8 +225,7 @@ private class AndroidMediaStoreDownloadsSink(
"MediaStore Downloads requires Android 10 or newer" "MediaStore Downloads requires Android 10 or newer"
} }
check(relativePath !in pending) { "Output stream is already open for $relativePath" } check(relativePath !in pending) { "Output stream is already open for $relativePath" }
val parts = relativePath.split('/').filter { it.isNotBlank() } val parts = requireSafeRelativePathParts(relativePath)
require(parts.isNotEmpty()) { "relative path must not be empty" }
val finalName = parts.last() val finalName = parts.last()
val relativeDir = mediaStoreRelativePath(parts.dropLast(1)) val relativeDir = mediaStoreRelativePath(parts.dropLast(1))
check(!mediaStoreItemExists(finalName, relativeDir)) { check(!mediaStoreItemExists(finalName, relativeDir)) {
@@ -346,6 +345,8 @@ private class AndroidTreeReceiveOutputSink(
override fun startFile(relativePath: String) { override fun startFile(relativePath: String) {
check(relativePath !in pending) { "Output stream is already open for $relativePath" } check(relativePath !in pending) { "Output stream is already open for $relativePath" }
// Defense in depth: Rust also validates, but sinks must reject traversal alone.
requireSafeRelativePathParts(relativePath)
val (parent, finalName) = resolveParent(relativePath) val (parent, finalName) = resolveParent(relativePath)
check(findChild(parent, finalName) == null) { "Destination already exists: $relativePath" } check(findChild(parent, finalName) == null) { "Destination already exists: $relativePath" }
val temporaryName = ".$finalName.vnidrop-${UUID.randomUUID()}.part" val temporaryName = ".$finalName.vnidrop-${UUID.randomUUID()}.part"
@@ -391,8 +392,7 @@ private class AndroidTreeReceiveOutputSink(
} }
private fun resolveParent(relativePath: String): Pair<Uri, String> { private fun resolveParent(relativePath: String): Pair<Uri, String> {
val parts = relativePath.split('/').filter { it.isNotBlank() } val parts = requireSafeRelativePathParts(relativePath)
require(parts.isNotEmpty()) { "relative path must not be empty" }
var parent = DocumentsContract.buildDocumentUriUsingTree( var parent = DocumentsContract.buildDocumentUriUsingTree(
treeUri, treeUri,
DocumentsContract.getTreeDocumentId(treeUri), DocumentsContract.getTreeDocumentId(treeUri),
@@ -428,3 +428,18 @@ private class AndroidTreeReceiveOutputSink(
return null return null
} }
} }
/**
* Split a receive relative path and reject traversal / absolute-style components.
* Rust already validates; this keeps Android sinks safe if called incorrectly.
*/
private fun requireSafeRelativePathParts(relativePath: String): List<String> {
val parts = relativePath.split('/').filter { it.isNotBlank() }
require(parts.isNotEmpty()) { "relative path must not be empty" }
require(parts.none { part ->
part == "." || part == ".." || part.contains('\\') || part.contains('\u0000')
}) {
"relative path contains invalid components: $relativePath"
}
return parts
}

View File

@@ -22,6 +22,7 @@
<string name="send_access_approval_description">You approve or refuse every new receiver.</string> <string name="send_access_approval_description">You approve or refuse every new receiver.</string>
<string name="send_access_anyone">Anyone with this transfer</string> <string name="send_access_anyone">Anyone with this transfer</string>
<string name="send_access_anyone_description">No approval is required. Only use this for files you are comfortable sharing.</string> <string name="send_access_anyone_description">No approval is required. Only use this for files you are comfortable sharing.</string>
<string name="send_access_anyone_warning">Anyone who has the ticket can download until you stop the share. Do not use this for private or sensitive files.</string>
<string name="send_file_size_unknown">Size unavailable</string> <string name="send_file_size_unknown">Size unavailable</string>
<string name="send_transfer_created">Transfer created.</string> <string name="send_transfer_created">Transfer created.</string>
<string name="send_transfer_details_title">Transfer details</string> <string name="send_transfer_details_title">Transfer details</string>
@@ -173,6 +174,7 @@
<string name="value_on">On</string> <string name="value_on">On</string>
<string name="value_off">Off</string> <string name="value_off">Off</string>
<string name="approval_connection_request">Connection request</string> <string name="approval_connection_request">Connection request</string>
<string name="approval_endpoint_id">Endpoint ID: %1$s</string>
<string name="approval_pending_count">%1$d requests are waiting</string> <string name="approval_pending_count">%1$d requests are waiting</string>
<string name="core_status_ready">Ready</string> <string name="core_status_ready">Ready</string>
<string name="event_log_title">Event log</string> <string name="event_log_title">Event log</string>

View File

@@ -27,6 +27,8 @@ data class PendingApproval(
val transferName: String, val transferName: String,
val receiverName: String?, val receiverName: String?,
val receiverDeviceName: String?, val receiverDeviceName: String?,
/** Cryptographic peer identity from the Iroh connection — not display-name spoofable. */
val remoteEndpointId: String,
val requestedAt: Long, val requestedAt: Long,
) )
@@ -162,6 +164,7 @@ private fun ReceiverRequestModel.toPending(): PendingApproval = PendingApproval(
transferName = transferName, transferName = transferName,
receiverName = receiverName, receiverName = receiverName,
receiverDeviceName = receiverDeviceName, receiverDeviceName = receiverDeviceName,
remoteEndpointId = remoteEndpointId,
requestedAt = requestedAt, requestedAt = requestedAt,
) )

View File

@@ -31,6 +31,7 @@ import com.vnidrop.app.ui.theme.LocalVniDropColors
import org.jetbrains.compose.resources.stringResource import org.jetbrains.compose.resources.stringResource
import vnidrop.shared.generated.resources.Res import vnidrop.shared.generated.resources.Res
import vnidrop.shared.generated.resources.approval_connection_request import vnidrop.shared.generated.resources.approval_connection_request
import vnidrop.shared.generated.resources.approval_endpoint_id
import vnidrop.shared.generated.resources.approval_pending_count import vnidrop.shared.generated.resources.approval_pending_count
import vnidrop.shared.generated.resources.button_approve import vnidrop.shared.generated.resources.button_approve
import vnidrop.shared.generated.resources.button_refuse import vnidrop.shared.generated.resources.button_refuse
@@ -78,6 +79,12 @@ fun ApprovalModalHost(
style = MaterialTheme.typography.bodyLarge, style = MaterialTheme.typography.bodyLarge,
color = colors.foregroundLight, color = colors.foregroundLight,
) )
// Trusted identity is the endpoint id; display names are peer-provided.
Text(
stringResource(Res.string.approval_endpoint_id, request.remoteEndpointId),
style = MaterialTheme.typography.bodySmall,
color = colors.foregroundLighter,
)
if (state.pending.size > 1) { if (state.pending.size > 1) {
Text( Text(
stringResource(Res.string.approval_pending_count, state.pending.size), stringResource(Res.string.approval_pending_count, state.pending.size),

View File

@@ -51,6 +51,7 @@ import vnidrop.shared.generated.resources.field_sender_name
import vnidrop.shared.generated.resources.field_transfer_name import vnidrop.shared.generated.resources.field_transfer_name
import vnidrop.shared.generated.resources.send_access_anyone import vnidrop.shared.generated.resources.send_access_anyone
import vnidrop.shared.generated.resources.send_access_anyone_description import vnidrop.shared.generated.resources.send_access_anyone_description
import vnidrop.shared.generated.resources.send_access_anyone_warning
import vnidrop.shared.generated.resources.send_access_approval import vnidrop.shared.generated.resources.send_access_approval
import vnidrop.shared.generated.resources.send_access_approval_description import vnidrop.shared.generated.resources.send_access_approval_description
import vnidrop.shared.generated.resources.send_access_title import vnidrop.shared.generated.resources.send_access_title
@@ -166,6 +167,13 @@ private fun ReviewFileStep(
selected = state.accessPolicy == ShareAccessPolicy.AnyoneWithTransfer, selected = state.accessPolicy == ShareAccessPolicy.AnyoneWithTransfer,
onClick = { onAccessPolicyChanged(ShareAccessPolicy.AnyoneWithTransfer) }, onClick = { onAccessPolicyChanged(ShareAccessPolicy.AnyoneWithTransfer) },
) )
if (state.accessPolicy == ShareAccessPolicy.AnyoneWithTransfer) {
Text(
stringResource(Res.string.send_access_anyone_warning),
color = LocalVniDropColors.current.destructiveDefault,
style = MaterialTheme.typography.bodySmall,
)
}
if (windowClass == WindowClass.Phone) { if (windowClass == WindowClass.Phone) {
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
ShareButton(state, coreInitialized, onCreateShare, Modifier.fillMaxWidth()) ShareButton(state, coreInitialized, onCreateShare, Modifier.fillMaxWidth())

View File

@@ -427,6 +427,7 @@ class FoundationComposeTest {
transferName = "Photos", transferName = "Photos",
receiverName = "Alice", receiverName = "Alice",
receiverDeviceName = "Phone", receiverDeviceName = "Phone",
remoteEndpointId = "endpoint-alice",
requestedAt = 1L, requestedAt = 1L,
) )