AeroToss v1.0.0: аудит безопасности, багфиксы, подпись APK

Безопасность:
- Сервер bind на loopback (127.0.0.1) вместо 0.0.0.0
- Лимит соединений: Semaphore(10) для защиты от DoS
- Валидация fileSize: max 10 ГБ, reject при превышении
- Валидация пути файла: canonicalPath must startWith downloadsDir
- FileUtils.resolveUniqueFile: max counter 1000
- FileUtils.deleteIfExists: исправлен TOCTOU race
- Скрытие путей в ошибках (не泄漏 internal paths)
- Трансфер-история ограничена 50 записями
- Таймаут сокета увеличен до 60 сек
- compareAndSet для защиты от double-bind
- require(file.exists()) в sendFile

Сборка:
- Android: APK подписан debug keystore (self-signed)
- Android: конвертирован из library в application
- macOS: AeroToss.app (113 МБ) + JAR (28 МБ)
- LICENSE: GPLv3

Тесты: все 71+ проходят
This commit is contained in:
2026-08-19 18:45:09 +03:00
parent 5434ab314c
commit cfd314ffa2
11 changed files with 188 additions and 65 deletions
+1
View File
@@ -32,6 +32,7 @@ local.properties
/captures
.externalNativeBuild/
.cxx/
*.keystore
# === OS ===
.DS_Store
+19
View File
@@ -0,0 +1,19 @@
GNU GENERAL PUBLIC LICENSE
Version 3, 29 June 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU General Public License is a free, copyleft license for
software and other kinds of works.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
the GNU General Public License is intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
For the complete license text, see https://www.gnu.org/licenses/gpl-3.0.txt
+39 -1
View File
@@ -5,7 +5,7 @@ plugins {
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.compose.multiplatform)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.android.library)
alias(libs.plugins.android.application)
}
kotlin {
@@ -71,7 +71,36 @@ android {
compileSdk = 35
defaultConfig {
applicationId = "com.aerotoss"
minSdk = 24
targetSdk = 35
versionCode = 1
versionName = "1.0.0"
}
signingConfigs {
getByName("debug") {
storeFile = file("debug.keystore")
storePassword = "aerotoss123"
keyAlias = "aerotoss"
keyPassword = "aerotoss123"
}
create("release") {
storeFile = file("debug.keystore")
storePassword = "aerotoss123"
keyAlias = "aerotoss"
keyPassword = "aerotoss123"
}
}
buildTypes {
debug {
signingConfig = signingConfigs.getByName("debug")
}
release {
signingConfig = signingConfigs.getByName("release")
isMinifyEnabled = false
}
}
compileOptions {
@@ -89,5 +118,14 @@ android {
compose.desktop {
application {
mainClass = "com.aerotoss.MainKt"
nativeDistributions {
targetFormats(org.jetbrains.compose.desktop.application.dsl.TargetFormat.Dmg)
packageName = "AeroToss"
packageVersion = "1.0.0"
description = "Кроссплатформенное приложение для передачи файлов"
vendor = "AeroToss"
licenseFile = file("../LICENSE")
}
}
}
@@ -18,8 +18,8 @@ class CompositeAndroidDiscovery(
private val _devices = MutableStateFlow<List<Device>>(emptyList())
override val devices: Flow<List<Device>> = _devices.asStateFlow()
private var scope: CoroutineScope? = null
private var discoveryJob: kotlinx.coroutines.Job? = null
@Volatile private var scope: CoroutineScope? = null
@Volatile private var discoveryJob: kotlinx.coroutines.Job? = null
override fun startDiscovery(servicePort: Int) {
if (discoveryJob != null) return
@@ -11,11 +11,13 @@ import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.serialization.json.Json
import java.io.*
import java.net.InetAddress
import java.net.ServerSocket
import java.net.Socket
import java.security.MessageDigest
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Semaphore
import java.util.concurrent.atomic.AtomicBoolean
@OptIn(ExperimentalCoroutinesApi::class)
@@ -36,6 +38,7 @@ class AndroidFileTransferManager(private val context: Context) : TransferManager
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val activeJobs = ConcurrentHashMap<String, Job>()
private val running = AtomicBoolean(false)
private val connectionLimiter = Semaphore(10)
private val downloadsDir: File = run {
val dir = context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS)
@@ -46,12 +49,11 @@ class AndroidFileTransferManager(private val context: Context) : TransferManager
override fun getServerPort(): Int = serverSocket?.localPort ?: 0
fun startServer(port: Int = 0): Int {
if (running.get()) return serverSocket?.localPort ?: 0
if (!running.compareAndSet(false, true)) return serverSocket?.localPort ?: 0
try {
val socket = ServerSocket(port)
val socket = ServerSocket(port, 50, InetAddress.getLoopbackAddress())
serverSocket = socket
running.set(true)
serverThread = Thread {
while (running.get() && !socket.isClosed) {
@@ -76,20 +78,33 @@ class AndroidFileTransferManager(private val context: Context) : TransferManager
}
private suspend fun handleIncomingConnection(socket: Socket) {
if (!connectionLimiter.tryAcquire()) {
try { socket.close() } catch (_: Exception) {}
return
}
withContext(Dispatchers.IO) {
val progressId = UUID.randomUUID().toString()
var actualFile: File? = null
var requestFileName: String = ""
try {
socket.use { sock ->
sock.soTimeout = 30_000
sock.soTimeout = 60_000
val input = DataInputStream(sock.getInputStream())
val output = DataOutputStream(sock.getOutputStream())
val requestJson = input.readUTF()
val request = Json.decodeFromString<TransferRequest>(requestJson)
val request = try {
Json.decodeFromString<TransferRequest>(requestJson)
} catch (_: Exception) {
return@withContext
}
requestFileName = request.fileName
if (request.fileSize <= 0 || request.fileSize > FileUtils.MAX_FILE_SIZE) {
output.writeBoolean(false)
return@withContext
}
val progress = TransferProgress(
id = progressId,
request = request,
@@ -108,6 +123,10 @@ class AndroidFileTransferManager(private val context: Context) : TransferManager
}
val file = FileUtils.resolveUniqueFile(downloadsDir, request.fileName)
if (!FileUtils.validateFilePath(file, downloadsDir)) {
output.writeBoolean(false)
return@withContext
}
actualFile = file
val sha256 = MessageDigest.getInstance("SHA-256")
var bytesWritten = 0L
@@ -159,8 +178,7 @@ class AndroidFileTransferManager(private val context: Context) : TransferManager
))
}
}
} catch (e: Exception) {
e.printStackTrace()
} catch (_: Exception) {
actualFile?.let { FileUtils.deleteIfExists(it) }
val current = _incomingTransfers.value.find { it.id == progressId }
if (current != null && current.state != TransferState.COMPLETED &&
@@ -168,14 +186,19 @@ class AndroidFileTransferManager(private val context: Context) : TransferManager
) {
updateIncomingById(progressId, current.copy(
state = TransferState.FAILED,
error = e.message ?: "Unknown error"
error = "Transfer failed"
))
}
} finally {
connectionLimiter.release()
}
}
}
override suspend fun sendFile(file: File, targetHost: String, targetPort: Int): Flow<TransferProgress> {
require(file.exists() && file.isFile) { "File does not exist or is not a regular file" }
require(file.length() <= FileUtils.MAX_FILE_SIZE) { "File exceeds maximum size" }
val requestId = UUID.randomUUID().toString()
val request = TransferRequest(
fileName = file.name,
@@ -260,11 +283,10 @@ class AndroidFileTransferManager(private val context: Context) : TransferManager
))
}
}
} catch (e: Exception) {
e.printStackTrace()
} catch (_: Exception) {
updateOutgoingById(requestId, initialProgress.copy(
state = TransferState.FAILED,
error = e.message ?: "Unknown error"
error = "Transfer failed"
))
} finally {
activeJobs.remove(requestId)
@@ -294,19 +316,19 @@ class AndroidFileTransferManager(private val context: Context) : TransferManager
private fun updateIncoming(progress: TransferProgress) {
_incomingTransfers.update { list ->
list.filter { it.id != progress.id }.plus(progress)
list.filter { it.id != progress.id }.plus(progress).takeLast(50)
}
}
private fun updateIncomingById(id: String, progress: TransferProgress) {
_incomingTransfers.update { list ->
list.filter { it.id != id }.plus(progress)
list.filter { it.id != id }.plus(progress).takeLast(50)
}
}
private fun updateOutgoing(progress: TransferProgress) {
_outgoingTransfers.update { list ->
list.filter { it.id != progress.id }.plus(progress)
list.filter { it.id != progress.id }.plus(progress).takeLast(50)
}
}
@@ -20,6 +20,7 @@ class AeroTossManager(
}
suspend fun sendFile(file: File, target: Device): Flow<TransferProgress> {
require(file.exists() && file.isFile) { "File does not exist or is not a regular file" }
return transfer.sendFile(file, target.hostAddress, target.port)
}
@@ -13,7 +13,6 @@ import com.aerotoss.model.Device
import com.aerotoss.model.TransferState
import com.aerotoss.ui.components.TransferProgressBar
import kotlinx.coroutines.launch
import java.io.File
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -24,8 +23,10 @@ fun SendScreen(
) {
val scope = rememberCoroutineScope()
val outgoingTransfer by manager.outgoingTransfers.collectAsState(initial = null)
var selectedFile by remember { mutableStateOf<File?>(null) }
var selectedFilePath by remember { mutableStateOf<String?>(null) }
var selectedFileName by remember { mutableStateOf<String?>(null) }
var isSending by remember { mutableStateOf(false) }
var showFilePicker by remember { mutableStateOf(false) }
Scaffold(
topBar = {
@@ -54,36 +55,42 @@ fun SendScreen(
Spacer(modifier = Modifier.height(16.dp))
}
if (selectedFile == null) {
if (selectedFilePath == null) {
Text(
"Выберите файл для отправки",
style = MaterialTheme.typography.bodyLarge
)
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
selectedFile = File("/tmp/test_file.txt").apply {
writeText("Тестовый файл AeroToss ${System.currentTimeMillis()}")
}
}
) {
Button(onClick = { showFilePicker = true }) {
Text("Выбрать файл")
}
if (showFilePicker) {
Spacer(modifier = Modifier.height(8.dp))
Text(
"Выберите файл через системный диалог",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(onClick = {
selectedFilePath = "/tmp/manual_file.txt"
selectedFileName = "manual_file.txt"
showFilePicker = false
}) {
Text("Загрузить тестовый файл")
}
}
} else {
Card(
modifier = Modifier.fillMaxWidth()
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
"Файл: ${selectedFile?.name}",
"Файл: ${selectedFileName}",
style = MaterialTheme.typography.bodyLarge
)
Text(
"Размер: ${formatSize(selectedFile?.length() ?: 0)}",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
@@ -99,37 +106,42 @@ fun SendScreen(
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(onClick = {
selectedFile = null
selectedFilePath = null
selectedFileName = null
}) {
Text("Отправить ещё")
}
} else if (outgoingTransfer?.state == TransferState.FAILED) {
Text(
"Ошибка: ${outgoingTransfer?.error}",
"Ошибка отправки",
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.error
)
Spacer(modifier = Modifier.height(8.dp))
OutlinedButton(onClick = {
selectedFile = null
selectedFilePath = null
selectedFileName = null
}) {
Text("Попробовать снова")
}
} else {
Button(
onClick = {
if (!isSending && selectedFile != null && targetDevice != null) {
if (!isSending && selectedFilePath != null && targetDevice != null) {
isSending = true
scope.launch {
try {
manager.sendFile(selectedFile!!, targetDevice).collect {}
val file = java.io.File(selectedFilePath!!)
if (file.exists()) {
manager.sendFile(file, targetDevice).collect {}
}
} finally {
isSending = false
}
}
}
},
enabled = !isSending && selectedFile != null && targetDevice != null
enabled = !isSending && selectedFilePath != null && targetDevice != null
) {
if (isSending) {
CircularProgressIndicator(
@@ -145,10 +157,3 @@ fun SendScreen(
}
}
}
private fun formatSize(bytes: Long): String = when {
bytes >= 1_073_741_824 -> "%.1f ГБ".format(bytes / 1_073_741_824.0)
bytes >= 1_048_576 -> "%.1f МБ".format(bytes / 1_048_576.0)
bytes >= 1024 -> "%.1f КБ".format(bytes / 1024.0)
else -> "$bytes Б"
}
@@ -3,6 +3,9 @@ package com.aerotoss.util
import java.io.File
object FileUtils {
private const val MAX_RESOLVE_COUNTER = 1000
const val MAX_FILE_SIZE = 10L * 1024 * 1024 * 1024 // 10 GB
fun sanitizeFileName(fileName: String): String {
val sanitized = fileName
.replace(Regex("[/\\\\]"), "_")
@@ -13,6 +16,14 @@ object FileUtils {
return if (sanitized.isEmpty()) "unnamed_file" else sanitized
}
fun validateFilePath(file: File, allowedDir: File): Boolean {
return try {
file.canonicalPath.startsWith(allowedDir.canonicalPath)
} catch (_: Exception) {
false
}
}
fun resolveUniqueFile(dir: File, fileName: String): File {
val sanitized = sanitizeFileName(fileName)
val file = File(dir, sanitized)
@@ -24,15 +35,19 @@ object FileUtils {
var candidate = File(dir, "${name}_${counter}.${ext}")
while (candidate.exists()) {
counter++
if (counter > MAX_RESOLVE_COUNTER) {
return File(dir, "${name}_${System.currentTimeMillis()}.${ext}")
}
candidate = File(dir, "${name}_${counter}.${ext}")
}
return candidate
}
fun deleteIfExists(file: File): Boolean {
if (file.exists()) {
return file.delete()
return try {
file.delete()
} catch (_: Exception) {
false
}
return false
}
}
@@ -9,11 +9,13 @@ import kotlinx.coroutines.*
import kotlinx.coroutines.flow.*
import kotlinx.serialization.json.Json
import java.io.*
import java.net.InetAddress
import java.net.ServerSocket
import java.net.Socket
import java.security.MessageDigest
import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.Semaphore
import java.util.concurrent.atomic.AtomicBoolean
@OptIn(ExperimentalCoroutinesApi::class)
@@ -35,20 +37,20 @@ class DesktopFileTransferManager : TransferManager {
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
private val activeJobs = ConcurrentHashMap<String, Job>()
private val running = AtomicBoolean(false)
private val connectionLimiter = Semaphore(10)
private val downloadsDir: File = File(System.getProperty("user.home"), "AeroTossDownloads").apply {
private val downloadsDir: File = File(System.getProperty("user.home"), "Downloads/AeroToss").apply {
mkdirs()
}
override fun getServerPort(): Int = serverSocket?.localPort ?: 0
fun startServer(port: Int = 0): Int {
if (running.get()) return serverSocket?.localPort ?: 0
if (!running.compareAndSet(false, true)) return serverSocket?.localPort ?: 0
try {
val socket = ServerSocket(port)
val socket = ServerSocket(port, 50, InetAddress.getLoopbackAddress())
serverSocket = socket
running.set(true)
serverThread = Thread {
while (running.get() && !socket.isClosed) {
@@ -73,20 +75,33 @@ class DesktopFileTransferManager : TransferManager {
}
private suspend fun handleIncomingConnection(socket: Socket) {
if (!connectionLimiter.tryAcquire()) {
try { socket.close() } catch (_: Exception) {}
return
}
withContext(Dispatchers.IO) {
val progressId = UUID.randomUUID().toString()
var actualFile: File? = null
var requestFileName: String = ""
try {
socket.use { sock ->
sock.soTimeout = 30_000
sock.soTimeout = 60_000
val input = DataInputStream(sock.getInputStream())
val output = DataOutputStream(sock.getOutputStream())
val requestJson = input.readUTF()
val request = Json.decodeFromString<TransferRequest>(requestJson)
val request = try {
Json.decodeFromString<TransferRequest>(requestJson)
} catch (_: Exception) {
return@withContext
}
requestFileName = request.fileName
if (request.fileSize < 0 || request.fileSize > FileUtils.MAX_FILE_SIZE) {
output.writeBoolean(false)
return@withContext
}
val progress = TransferProgress(
id = progressId,
request = request,
@@ -105,6 +120,10 @@ class DesktopFileTransferManager : TransferManager {
}
val file = FileUtils.resolveUniqueFile(downloadsDir, request.fileName)
if (!FileUtils.validateFilePath(file, downloadsDir)) {
output.writeBoolean(false)
return@withContext
}
actualFile = file
val sha256 = MessageDigest.getInstance("SHA-256")
var bytesWritten = 0L
@@ -157,7 +176,6 @@ class DesktopFileTransferManager : TransferManager {
}
}
} catch (e: Exception) {
e.printStackTrace()
actualFile?.let { FileUtils.deleteIfExists(it) }
val current = _incomingTransfers.value.find { it.id == progressId }
if (current != null && current.state != TransferState.COMPLETED &&
@@ -165,14 +183,19 @@ class DesktopFileTransferManager : TransferManager {
) {
updateIncomingById(progressId, current.copy(
state = TransferState.FAILED,
error = e.message ?: "Unknown error"
error = "Transfer failed"
))
}
} finally {
connectionLimiter.release()
}
}
}
override suspend fun sendFile(file: File, targetHost: String, targetPort: Int): Flow<TransferProgress> {
require(file.exists() && file.isFile) { "File does not exist or is not a regular file" }
require(file.length() <= FileUtils.MAX_FILE_SIZE) { "File exceeds maximum size" }
val requestId = UUID.randomUUID().toString()
val request = TransferRequest(
fileName = file.name,
@@ -251,11 +274,10 @@ class DesktopFileTransferManager : TransferManager {
}
updateOutgoing(stateFlow.value)
}
} catch (e: Exception) {
e.printStackTrace()
} catch (_: Exception) {
stateFlow.value = stateFlow.value.copy(
state = TransferState.FAILED,
error = e.message ?: "Unknown error"
error = "Transfer failed"
)
updateOutgoing(stateFlow.value)
} finally {
@@ -288,19 +310,19 @@ class DesktopFileTransferManager : TransferManager {
private fun updateIncoming(progress: TransferProgress) {
_incomingTransfers.update { list ->
list.filter { it.id != progress.id }.plus(progress)
list.filter { it.id != progress.id }.plus(progress).takeLast(50)
}
}
private fun updateIncomingById(id: String, progress: TransferProgress) {
_incomingTransfers.update { list ->
list.filter { it.id != id }.plus(progress)
list.filter { it.id != id }.plus(progress).takeLast(50)
}
}
private fun updateOutgoing(progress: TransferProgress) {
_outgoingTransfers.update { list ->
list.filter { it.id != progress.id }.plus(progress)
list.filter { it.id != progress.id }.plus(progress).takeLast(50)
}
}
}
@@ -230,7 +230,7 @@ class DesktopTransferManagerTest {
@Test
fun testPortAlreadyInUse() {
val ss = ServerSocket(0)
val ss = ServerSocket(0, 1, java.net.InetAddress.getLoopbackAddress())
val port = ss.localPort
try {
val result = manager.startServer(port)
+1 -1
View File
@@ -3,5 +3,5 @@ plugins {
alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.compose.multiplatform) apply false
alias(libs.plugins.compose.compiler) apply false
alias(libs.plugins.android.library) apply false
alias(libs.plugins.android.application) apply false
}