Security: guard against path traversal from hostile remotes
Build & Release APK / build (push) Successful in 12m42s

WebDAV already sanitizes server-supplied names, but SFTP passed entry.name
through unfiltered, and the engine had no central guard — a malicious or
compromised remote could return '../../x' and (on the JavaFile backend) write
outside the sync root.

- SyncEngine: isUnsafeSyncPath() rejects empty, absolute, and any '..'-segment
  path; every file is checked before any read/write/delete (covers all providers).
- SftpProvider.listFiles: drop '.'/'..' and names containing path separators.
- PathSafetyTest covers traversal, backslash, absolute, and empty cases.
This commit is contained in:
2026-06-05 02:54:21 +00:00
parent 160a3e5478
commit a0d759364e
3 changed files with 72 additions and 11 deletions
@@ -111,6 +111,14 @@ class SyncEngine @Inject constructor(
allPaths.map { rel ->
async {
semaphore.withPermit {
// Defense-in-depth against a malicious/compromised remote returning a
// path that escapes the sync root (e.g. "../../evil"). Skip rather than
// write outside pair.localPath / pair.remotePath.
if (isUnsafeSyncPath(rel)) {
Timber.w("SyncEngine: skipping unsafe path for pair ${pair.id}: $rel")
logEvent(pair.id, SyncEventType.FILE_SKIPPED, rel, "unsafe path", 0)
return@withPermit FileOutcome(skipped = 1)
}
val local = localFiles[rel]
val remote = remoteFiles[rel]
val known = knownStates[rel]
@@ -363,6 +371,19 @@ internal fun syncDecide(
enum class SyncDecision { UPLOAD, DOWNLOAD, DELETE_LOCAL, DELETE_REMOTE, CONFLICT, SKIP }
/**
* True if a relative sync path is unsafe to act on — empty, absolute, or containing a ".."
* segment that would let a hostile remote escape the sync root via path traversal. Applied to
* every path before any file operation as defense-in-depth (WebDAV already filters names at the
* parser; SFTP and any future provider are covered here).
*/
internal fun isUnsafeSyncPath(rel: String): Boolean {
if (rel.isBlank()) return true
val normalized = rel.replace('\\', '/')
if (normalized.startsWith("/")) return true
return normalized.split('/').any { it == ".." }
}
data class SyncResult(
val uploaded: Int = 0,
val downloaded: Int = 0,