Skip to content

fix: prevent Zip Slip path traversal in backup restore#2205

Open
Marnie0415 wants to merge 1 commit into
chen08209:mainfrom
Marnie0415:fix/zip-slip-path-traversal
Open

fix: prevent Zip Slip path traversal in backup restore#2205
Marnie0415 wants to merge 1 commit into
chen08209:mainfrom
Marnie0415:fix/zip-slip-path-traversal

Conversation

@Marnie0415

@Marnie0415 Marnie0415 commented Jul 10, 2026

Copy link
Copy Markdown

Problem

lib/common/task.dart:551 joins zip entry names directly to the restore directory path without path traversal validation:

for (final file in archive.files) {
  final outPath = join(restoreDirPath, posix.normalize(file.name));
  final outputStream = OutputFileStream(outPath);
  file.writeContent(outputStream);
}

posix.normalize only cleans up path syntax — it does not prevent .. traversal. A malicious backup file with an entry named ../../etc/cron.d/backdoor would write outside the restore directory.

Attack scenario

  1. Attacker crafts a malicious .zip backup file with path traversal entries
  2. User imports the backup via the restore function
  3. Files are written outside the application directory

This is a well-known vulnerability class (Zip Slip, CWE-22).

Fix

Resolve the full output path and verify it stays within the restore directory:

final outPath = join(restoreDirPath, file.name);
final resolvedDir = normalize(restoreDirPath);
final resolvedOut = normalize(outPath);
if (!resolvedOut.startsWith(resolvedDir)) {
  throw Exception('Zip entry path escapes target directory: ${file.name}');
}

Design decisions

Why normalize() instead of posix.normalize()?
restoreDirPath is a platform-specific path (backslashes on Windows). posix.normalize only handles forward slashes and would produce incorrect results on Windows. normalize() from package:path/path.dart is platform-aware — it uses the OS path separator and correctly resolves .. on all platforms.

Why normalize both paths?
restoreDirPath comes from appPath.restoreDirPath which is join(homeDirPath, 'restore'). While it's already clean, normalizing both paths ensures consistency — if homeDirPath ever contains .. or redundant separators, the comparison still works correctly.

Why join() + normalize() instead of just join()?
join() does not resolve .. components. join('/restore', '../../etc') produces /restore/../../etc, which the OS would resolve to /etc. normalize() resolves this to /etc in Dart, so the startsWith check catches the escape.

Cross-platform behavior:

  • Linux/macOS: normalize('/home/user/restore/../../etc')/etc — fails check
  • Windows: normalize('C:\\Users\\restore\\..\\..\\Windows')C:\\Windows — fails check
  • Zip entries with forward slashes: join('C:\\restore', '../../etc')C:\\restore\\..\\..\\etcnormalizeC:\\etc — fails check

The _restoreTask function joins the zip entry name directly to the
restore directory path without validating that the resolved path stays
within the target directory. A malicious backup file with entries like
"../../etc/cron.d/backdoor" could write files outside the restore
directory.

Add path resolution check: normalize the full output path and verify
it starts with the normalized restore directory before writing.
@Marnie0415
Marnie0415 force-pushed the fix/zip-slip-path-traversal branch from fca32f5 to 640e432 Compare July 10, 2026 18:28
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.

1 participant