Skip to content

fix(security): sanitize untrusted file names in local path resolution - #2099

Closed
tevincent wants to merge 1 commit into
mainfrom
fix/path-traversal-file-names
Closed

tevincent wants to merge 1 commit into
mainfrom
fix/path-traversal-file-names

Conversation

@tevincent

Copy link
Copy Markdown
Contributor

Context

Path traversal via unsanitized file names (reported through the bug bounty program). File names and paths delivered by the API — or by external apps through SAF / the content resolver — were joined onto local directories with a plain java.io.File, so .. or absolute segments resolved outside the intended folder when the app created the folder, created the file, wrote downloaded content, or read the file back.

This mirrors the hardening already shipped in kMail Android (commit 3ddc669, FileNameUtils.kt), adapted to kDrive.

Vulnerable join sites fixed

Site Untrusted input Fix
File.getOfflineFile API path + name Per-segment sanitization of the remote path (toSafeRelativePath), canonical containment check against the per-user/per-drive root before mkdirs(), toSafeFileName() for the final component; returns null on escape (function was already nullable)
File.getPublicShareCache API name toSafeFileName() — output is a single safe component (no separators, never ./..), so the write stays inside filesDir/public_share
File.convertToIOFile — Replaced getOfflineFile(...)!! with a graceful fallback to the id-keyed getCacheFile so a rejected traversal degrades instead of crashing
CloudStorageProvider.createTempFile SAF displayName (external caller) toSafeFileName()
CloudStorageProvider.generateThumbnail API file.name toSafeFileName() (keeps the fileId_ prefix)
SaveExternalFilesActivity.getOutputFile External app fileName via content resolver toSafeFileName()

File.getCacheFile / getConvertedPdfCache already join an Int id and needed no change. Sanitization applies only to local path resolution — the name property itself, uploads and API metadata are untouched.

New utility: utils/FileNameUtils.kt

Port of kMail's FileNameUtils, with kDrive adaptations:

  • String.toSafeFileName() — NFKC normalization, strips [\\/:*?"<>|% and control characters, empty/./.. → "file" fallback, UTF-8 240-byte truncation preserving the extension
  • String.toSafeRelativePath() — sanitizes every segment of a relative path (new, kDrive-specific)
  • File.resolveContainedFileName() / File.resolveContainedPath() — canonical-file + Path.startsWith containment check, reports to Sentry and returns null on escape. Kept non-suspend (unlike kMail's suspend variants): kDrive's getOfflineFile has ~27 synchronous call sites including UI/adapter contexts, and those sites already perform exists()/mkdirs() IO, so canonicalization adds no meaningful cost.

Tests

app/src/test/kotlin/com/infomaniak/drive/utils/FileNameUtilsTest.kt (12 tests, JUnit 5): traversal names, control characters, ./../empty fallback, fullwidth-separator normalization, UTF-8 truncation + extension preservation, containment accept/reject cases, sanitized-path round-trip.

Verification

  • ./gradlew compileStandardDebugKotlin — clean
  • ./gradlew app:testStandardDebugUnitTest — full suite green (3 classes, 0 failures)
  • No strings.xml change (no Loco sync needed)
  • No Realm schema change (no DB_VERSION bump needed)

File names and paths delivered by the API (or by external apps through
SAF / the content resolver) were joined onto local directories with a
plain java.io.File, so '..' or absolute segments resolved outside the
intended folder when creating, writing or reading local files.

Port the toSafeFileName / resolveContainedFileName / resolveContainedPath
hardening introduced in kMail (3ddc669) and apply it to every local
join of an untrusted name:

- File.getOfflineFile: sanitize remote path segments, containment-check
  the resolved folder against the per-user/per-drive root before
  mkdirs(), sanitize the file name; return null on escape
- File.getPublicShareCache: sanitize the share file name
- CloudStorageProvider.createTempFile: sanitize SAF display name
- CloudStorageProvider.generateThumbnail: sanitize remote file name
- SaveExternalFilesActivity.getOutputFile: sanitize external file name
- convertToIOFile: fall back to the id-keyed cache instead of !! when
  the offline path is rejected
Copilot AI balanced review requested due to automatic review settings October 2, 2026 06:40

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Root-level offline files resolve incorrectly, and sanitized-name collisions can overwrite distinct files.

Review effort: Balanced
Findings: 1 High severity · 1 Medium severity

Open (2)
What changed in this PR

Hardens local file-path resolution against traversal from untrusted file names and paths.

Changes:

  • Adds filename/path sanitization and containment utilities.
  • Applies sanitization across offline files, shared files, thumbnails, and SAF temporary files.
  • Adds JUnit 5 security-focused tests.
File Description
FileNameUtils.kt Adds sanitization and containment helpers.
File.kt Secures offline and public-share paths.
CloudStorageProvider.kt Sanitizes SAF and thumbnail filenames.
SaveExternalFilesActivity.kt Sanitizes externally supplied filenames.
FileNameUtilsTest.kt Tests traversal, normalization, truncation, and containment.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines +33 to +39
val normalizedName = Normalizer.normalize(this, Normalizer.Form.NFKC)
.replace(invalidFileNameCharacters, "_")
.trim()
.takeUnless { it.isEmpty() || it == "." || it == ".." }
?: DEFAULT_FILE_NAME

return if (normalizedName.utf8Size <= MAX_FILE_NAME_SIZE_BYTES) normalizedName else computeTruncatedFileName(normalizedName)
Comment on lines +310 to +312
val sanitizedPath = path.toSafeRelativePath()
val child = if (isFolder()) sanitizedPath else sanitizedPath.substringBeforeLast("/")
val folder = rootFolder.resolveContainedPath(child) ?: return null
@sonarqubecloud

sonarqubecloud Bot commented Oct 2, 2026

Copy link
Copy Markdown

@tevincent tevincent closed this Oct 2, 2026
@tevincent
tevincent deleted the fix/path-traversal-file-names branch October 2, 2026 07:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants