Security: guard against path traversal from hostile remotes
Build & Release APK / build (push) Successful in 12m42s
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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user