Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 16 additions & 4 deletions android/src/main/java/com/unarchive/UnarchiveModule.kt
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ class UnarchiveModule(reactContext: ReactApplicationContext) :
).filterNotNull()

return allowedRoots.any { root ->
canonicalPath.startsWith(root)
canonicalPath == root || canonicalPath.startsWith(root + File.separator)
}
} catch (e: Exception) {
return false
Expand All @@ -71,10 +71,22 @@ class UnarchiveModule(reactContext: ReactApplicationContext) :
// Zip-slip sanitization per entry
private fun isSafeEntryPath(entryPath: String, tempDir: File): File? {
try {
val destFile = File(tempDir, entryPath).canonicalFile
val tempDirCanonical = tempDir.canonicalPath + File.separator
if (entryPath.isBlank() || entryPath.indexOf('\u0000') >= 0) {
return null
}

val normalizedEntryPath = entryPath.replace('\\', File.separatorChar)
val entryFile = File(normalizedEntryPath)
if (entryFile.isAbsolute) {
return null
}

val destFile = File(tempDir, normalizedEntryPath).canonicalFile
val tempDirCanonical = tempDir.canonicalFile
val tempDirPath = tempDirCanonical.path
val destPath = destFile.path

if (!destFile.path.startsWith(tempDirCanonical)) {
if (destPath != tempDirPath && !destPath.startsWith(tempDirPath + File.separator)) {
return null
}
return destFile
Expand Down
137 changes: 95 additions & 42 deletions ios/Unarchive.mm
Original file line number Diff line number Diff line change
Expand Up @@ -16,41 +16,54 @@ - (instancetype)init {
return self;
}

// Helper method to check a canonical path is equal to or contained by a canonical directory.
- (BOOL)canonicalPath:(NSString *)path isWithinDirectory:(NSString *)directory {
if (!path || !directory) {
return NO;
}

if ([path isEqualToString:directory]) {
return YES;
}

NSString *directoryWithSeparator = [directory stringByAppendingString:@"/"];
return [path hasPrefix:directoryWithSeparator];
}

// Helper method to canonicalize path strings consistently.
- (NSString *)canonicalPathForPath:(NSString *)path {
NSURL *url = [NSURL fileURLWithPath:path];
return [[[url URLByResolvingSymlinksInPath] URLByStandardizingPath] path];
}

// Helper method to validate output path is within app sandbox
- (BOOL)isOutputPathInSandbox:(NSString *)outputPath
error:(NSError **)error {
if (!outputPath) {
if (!outputPath || outputPath.length == 0) {
if (error) {
*error = [NSError errorWithDomain:@"UnarchiveError"
code:-10
userInfo:@{NSLocalizedDescriptionKey: @"Output path is nil"}];
userInfo:@{NSLocalizedDescriptionKey: @"Output path is nil or empty"}];
}
return NO;
}

// Canonicalize the output path
NSURL *outputURL = [NSURL fileURLWithPath:outputPath];
NSURL *canonicalOutputURL = [[outputURL URLByResolvingSymlinksInPath] URLByStandardizingPath];
NSString *canonicalOutput = [canonicalOutputURL path];
NSString *canonicalOutput = [self canonicalPathForPath:outputPath];

// Get allowed sandbox directories
NSString *documentsPath = [NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES) firstObject];
NSString *cachesPath = [NSSearchPathForDirectoriesInDomains(NSCachesDirectory, NSUserDomainMask, YES) firstObject];
NSString *tmpPath = NSTemporaryDirectory();

// Canonicalize sandbox paths
NSURL *documentsURL = [[NSURL fileURLWithPath:documentsPath] URLByStandardizingPath];
NSURL *cachesURL = [[NSURL fileURLWithPath:cachesPath] URLByStandardizingPath];
NSURL *tmpURL = [[NSURL fileURLWithPath:tmpPath] URLByStandardizingPath];
NSString *canonicalDocuments = [self canonicalPathForPath:documentsPath];
NSString *canonicalCaches = [self canonicalPathForPath:cachesPath];
NSString *canonicalTmp = [self canonicalPathForPath:tmpPath];

NSString *canonicalDocuments = [documentsURL path];
NSString *canonicalCaches = [cachesURL path];
NSString *canonicalTmp = [tmpURL path];

// Check if output path is within any allowed directory
BOOL isInDocuments = [canonicalOutput hasPrefix:canonicalDocuments];
BOOL isInCaches = [canonicalOutput hasPrefix:canonicalCaches];
BOOL isInTmp = [canonicalOutput hasPrefix:canonicalTmp];
// Check if output path is within any allowed directory using path-boundary checks.
BOOL isInDocuments = [self canonicalPath:canonicalOutput isWithinDirectory:canonicalDocuments];
BOOL isInCaches = [self canonicalPath:canonicalOutput isWithinDirectory:canonicalCaches];
BOOL isInTmp = [self canonicalPath:canonicalOutput isWithinDirectory:canonicalTmp];

if (!isInDocuments && !isInCaches && !isInTmp) {
#if DEBUG
Expand All @@ -77,7 +90,7 @@ - (BOOL)isOutputPathInSandbox:(NSString *)outputPath
- (BOOL)isSafePath:(NSString *)entryPath
withinBaseURL:(NSURL *)baseURL
error:(NSError **)error {
if (!entryPath || !baseURL) {
if (!entryPath || entryPath.length == 0 || !baseURL) {
if (error) {
*error = [NSError errorWithDomain:@"UnarchiveError"
code:-1
Expand All @@ -86,26 +99,35 @@ - (BOOL)isSafePath:(NSString *)entryPath
return NO;
}

// Normalize entry path and remove leading slashes or dots
NSString *normalizedEntry = [entryPath stringByStandardizingPath];
while ([normalizedEntry hasPrefix:@"/"] || [normalizedEntry hasPrefix:@"../"]) {
if ([normalizedEntry hasPrefix:@"/"]) {
normalizedEntry = [normalizedEntry substringFromIndex:1];
} else if ([normalizedEntry hasPrefix:@"../"]) {
normalizedEntry = [normalizedEntry substringFromIndex:3];
NSString *normalizedEntry = [entryPath stringByReplacingOccurrencesOfString:@"\\" withString:@"/"];

// Reject absolute Unix paths, Windows drive-letter paths, UNC paths, and entries
// that normalize to the extraction root instead of a child entry. Never strip
// traversal components because extraction libraries receive the original name.
NSRegularExpression *windowsDrive = [NSRegularExpression regularExpressionWithPattern:@"^[A-Za-z]:"
options:0
error:nil];
BOOL hasWindowsDrive = [windowsDrive firstMatchInString:normalizedEntry
options:0
range:NSMakeRange(0, normalizedEntry.length)] != nil;
if ([normalizedEntry hasPrefix:@"/"] || [normalizedEntry hasPrefix:@"//"] ||
hasWindowsDrive || [normalizedEntry isEqualToString:@"."]) {
if (error) {
*error = [NSError errorWithDomain:@"UnarchiveError"
code:-2
userInfo:@{
NSLocalizedDescriptionKey: @"Archive contains unsafe absolute path",
@"entryPath": entryPath
}];
}
return NO;
}

// Construct the full destination path
NSURL *destinationURL = [baseURL URLByAppendingPathComponent:normalizedEntry];
NSURL *canonicalDestination = [destinationURL URLByStandardizingPath];
NSURL *canonicalBase = [baseURL URLByStandardizingPath];

// Check if canonical destination is within canonical base
NSString *destPath = [canonicalDestination path];
NSString *basePath = [canonicalBase path];
NSString *basePath = [[baseURL URLByStandardizingPath] path];
NSString *destinationPath = [[basePath stringByAppendingPathComponent:normalizedEntry] stringByStandardizingPath];

if (![destPath hasPrefix:basePath]) {
if (![self canonicalPath:destinationPath isWithinDirectory:basePath] ||
[destinationPath isEqualToString:basePath]) {
#if DEBUG
NSLog(@"[Unarchive] ZIP-SLIP detected: Entry '%@' would escape base directory", entryPath);
#endif
Expand Down Expand Up @@ -631,19 +653,50 @@ - (void)extractCBZFile:(NSString *)archivePath
NSLog(@"[Unarchive] Starting CBZ extraction: %@", [archivePath lastPathComponent]);
#endif

// Note - SSZipArchive performs its own path validation, but we add post-extraction validation
NSError * __autoreleasing error = nil;
__block NSError *unsafeEntryError = nil;

// Validate each ZIP entry name before accepting the extraction result.
BOOL success = [SSZipArchive unzipFileAtPath:archivePath
toDestination:tempPath];
toDestination:tempPath
preserveAttributes:YES
overwrite:YES
nestedZipLevel:0
password:nil
error:&error
delegate:nil
progressHandler:^(NSString *entry,
unz_file_info zipInfo,
long entryNumber,
long total) {
(void)zipInfo;
(void)entryNumber;
(void)total;
if (!unsafeEntryError) {
NSError * __autoreleasing pathError = nil;
NSURL *tempBaseURL = [NSURL fileURLWithPath:tempPath];
if (![self isSafePath:entry withinBaseURL:tempBaseURL error:&pathError]) {
unsafeEntryError = pathError;
}
}
}
completionHandler:nil];

if (!success) {
if (!success || unsafeEntryError) {
#if DEBUG
NSLog(@"[Unarchive] Error: Failed to extract CBZ archive");
NSLog(@"[Unarchive] Error: Failed to extract CBZ archive: %@",
unsafeEntryError ? unsafeEntryError.localizedDescription : error.localizedDescription);
#endif
[fileManager removeItemAtPath:tempPath error:nil];
NSString *errorCode = unsafeEntryError ? @"UNSAFE_PATH" : @"EXTRACTION_ERROR";
NSString *errorMessage = unsafeEntryError
? @"Archive contains unsafe path that attempts to escape extraction directory"
: @"Failed to extract CBZ archive using SSZipArchive";
NSError *reportedError = unsafeEntryError ?: error;
[self rejectOnce:reject
code:@"EXTRACTION_ERROR"
message:@"Failed to extract CBZ archive using SSZipArchive"
error:nil
code:errorCode
message:errorMessage
error:reportedError
invoked:cbInvoked.get()];
_activeExtraction.store(false);
_currentTempPath = nil; // Clear temp path
Expand Down Expand Up @@ -686,7 +739,7 @@ - (void)extractCBZFile:(NSString *)archivePath
NSURL *canonicalFileURL = [[fileURL URLByResolvingSymlinksInPath] URLByStandardizingPath];
NSString *canonicalFilePath = [canonicalFileURL path];

if (![canonicalFilePath hasPrefix:canonicalTempPath]) {
if (![self canonicalPath:canonicalFilePath isWithinDirectory:canonicalTempPath]) {
#if DEBUG
NSLog(@"[Unarchive] Error: File escaped temp directory: %@ (canonical: %@), temp: %@", [fileURL path], canonicalFilePath, canonicalTempPath);
#endif
Expand Down
Loading