mirror of
https://github.com/sudosylabs/vnidrop.git
synced 2026-08-05 10:29:58 +02:00
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:
@@ -225,8 +225,7 @@ private class AndroidMediaStoreDownloadsSink(
|
||||
"MediaStore Downloads requires Android 10 or newer"
|
||||
}
|
||||
check(relativePath !in pending) { "Output stream is already open for $relativePath" }
|
||||
val parts = relativePath.split('/').filter { it.isNotBlank() }
|
||||
require(parts.isNotEmpty()) { "relative path must not be empty" }
|
||||
val parts = requireSafeRelativePathParts(relativePath)
|
||||
val finalName = parts.last()
|
||||
val relativeDir = mediaStoreRelativePath(parts.dropLast(1))
|
||||
check(!mediaStoreItemExists(finalName, relativeDir)) {
|
||||
@@ -346,6 +345,8 @@ private class AndroidTreeReceiveOutputSink(
|
||||
|
||||
override fun startFile(relativePath: String) {
|
||||
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)
|
||||
check(findChild(parent, finalName) == null) { "Destination already exists: $relativePath" }
|
||||
val temporaryName = ".$finalName.vnidrop-${UUID.randomUUID()}.part"
|
||||
@@ -391,8 +392,7 @@ private class AndroidTreeReceiveOutputSink(
|
||||
}
|
||||
|
||||
private fun resolveParent(relativePath: String): Pair<Uri, String> {
|
||||
val parts = relativePath.split('/').filter { it.isNotBlank() }
|
||||
require(parts.isNotEmpty()) { "relative path must not be empty" }
|
||||
val parts = requireSafeRelativePathParts(relativePath)
|
||||
var parent = DocumentsContract.buildDocumentUriUsingTree(
|
||||
treeUri,
|
||||
DocumentsContract.getTreeDocumentId(treeUri),
|
||||
@@ -428,3 +428,18 @@ private class AndroidTreeReceiveOutputSink(
|
||||
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
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
<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_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_transfer_created">Transfer created.</string>
|
||||
<string name="send_transfer_details_title">Transfer details</string>
|
||||
@@ -173,6 +174,7 @@
|
||||
<string name="value_on">On</string>
|
||||
<string name="value_off">Off</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="core_status_ready">Ready</string>
|
||||
<string name="event_log_title">Event log</string>
|
||||
|
||||
@@ -27,6 +27,8 @@ data class PendingApproval(
|
||||
val transferName: String,
|
||||
val receiverName: String?,
|
||||
val receiverDeviceName: String?,
|
||||
/** Cryptographic peer identity from the Iroh connection — not display-name spoofable. */
|
||||
val remoteEndpointId: String,
|
||||
val requestedAt: Long,
|
||||
)
|
||||
|
||||
@@ -162,6 +164,7 @@ private fun ReceiverRequestModel.toPending(): PendingApproval = PendingApproval(
|
||||
transferName = transferName,
|
||||
receiverName = receiverName,
|
||||
receiverDeviceName = receiverDeviceName,
|
||||
remoteEndpointId = remoteEndpointId,
|
||||
requestedAt = requestedAt,
|
||||
)
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ import com.vnidrop.app.ui.theme.LocalVniDropColors
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import vnidrop.shared.generated.resources.Res
|
||||
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.button_approve
|
||||
import vnidrop.shared.generated.resources.button_refuse
|
||||
@@ -78,6 +79,12 @@ fun ApprovalModalHost(
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
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) {
|
||||
Text(
|
||||
stringResource(Res.string.approval_pending_count, state.pending.size),
|
||||
|
||||
@@ -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.send_access_anyone
|
||||
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_description
|
||||
import vnidrop.shared.generated.resources.send_access_title
|
||||
@@ -166,6 +167,13 @@ private fun ReviewFileStep(
|
||||
selected = state.accessPolicy == 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) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ShareButton(state, coreInitialized, onCreateShare, Modifier.fillMaxWidth())
|
||||
|
||||
@@ -427,6 +427,7 @@ class FoundationComposeTest {
|
||||
transferName = "Photos",
|
||||
receiverName = "Alice",
|
||||
receiverDeviceName = "Phone",
|
||||
remoteEndpointId = "endpoint-alice",
|
||||
requestedAt = 1L,
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user