From 58ba3b6f22aca6b3d222a582febd2725f72f9940 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 29 Aug 2026 10:16:57 +0000 Subject: [PATCH] fix(security): Zip Slip + symlink hardening for v7.1.1 Android: add ZipSecurity helper; validate paths and disable symlink extraction on unzip/unzipWithPassword/unzipAssets instead of extractAll. iOS: replace SSZipArchive full-unzip with minizip extract that rejects Zip Slip entries and skips symlink/__MACOSX entries. Co-authored-by: Perry --- RNZipArchive.podspec | 5 +- .../com/rnziparchive/RNZipArchiveModule.java | 70 +-- .../java/com/rnziparchive/ZipSecurity.java | 44 ++ ios/RNZipArchive.h | 2 +- ios/RNZipArchive.m | 259 --------- ios/RNZipArchive.mm | 516 ++++++++++++++++++ package.json | 2 +- 7 files changed, 594 insertions(+), 304 deletions(-) create mode 100644 android/src/main/java/com/rnziparchive/ZipSecurity.java delete mode 100644 ios/RNZipArchive.m create mode 100644 ios/RNZipArchive.mm diff --git a/RNZipArchive.podspec b/RNZipArchive.podspec index d61fbd31..5f8239d6 100644 --- a/RNZipArchive.podspec +++ b/RNZipArchive.podspec @@ -15,9 +15,12 @@ Pod::Spec.new do |s| s.dependency 'React-Core' s.dependency 'SSZipArchive', '~>2.5.5' + s.pod_target_xcconfig = { + 'HEADER_SEARCH_PATHS' => '$(inherited) "$(PODS_ROOT)/SSZipArchive" "$(PODS_ROOT)/SSZipArchive/SSZipArchive/minizip"' + } s.subspec 'Core' do |ss| - ss.source_files = 'ios/*.{h,m}' + ss.source_files = 'ios/*.{h,m,mm}' ss.public_header_files = ['ios/RNZipArchive.h'] end end diff --git a/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java b/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java index 14bd68fd..131abb2f 100644 --- a/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java +++ b/android/src/main/java/com/rnziparchive/RNZipArchiveModule.java @@ -38,7 +38,6 @@ import net.lingala.zip4j.model.enums.CompressionLevel; import net.lingala.zip4j.model.enums.EncryptionMethod; import net.lingala.zip4j.model.enums.AesKeyStrength; -import net.lingala.zip4j.progress.ProgressMonitor; import java.nio.charset.Charset; @@ -80,6 +79,7 @@ public void run() { zipFile.setPassword(password.toCharArray()); } else { promise.reject("RNZipArchiveError", String.format("Zip file: %s is not password protected", zipFilePath)); + return; } List fileHeaderList = zipFile.getFileHeaders(); @@ -90,16 +90,10 @@ public void run() { for (int i = 0; i < totalFiles; i++) { FileHeader fileHeader = (FileHeader) fileHeaderList.get(i); - File fout = new File(destDirectory, fileHeader.getFileName()); - String canonicalPath = fout.getCanonicalPath(); - String destDirCanonicalPath = (new File(destDirectory).getCanonicalPath()) + File.separator; - - if (!canonicalPath.startsWith(destDirCanonicalPath)) { - throw new SecurityException(String.format("Found Zip Path Traversal Vulnerability with %s", canonicalPath)); - } + ZipSecurity.validateExtractPath(destDirectory, fileHeader.getFileName()); if (!fileHeader.isDirectory()) { - zipFile.extractFile(fileHeader, destDirectory); + zipFile.extractFile(fileHeader, destDirectory, ZipSecurity.createExtractParameters()); extractedFileNames.add(fileHeader.getFileName()); } updateProgress(i + 1, totalFiles, zipFilePath); @@ -127,10 +121,6 @@ public void run() { } try { - // Find the total uncompressed size of every file in the zip, so we can - // get an accurate progress measurement - final long totalUncompressedBytes = getUncompressedSize(zipFilePath, charset); - File destDir = new File(destDirectory); if (!destDir.exists()) { //noinspection ResultOfMethodCallIgnored @@ -139,12 +129,7 @@ public void run() { updateProgress(0, 1, zipFilePath); // force 0% - // We use arrays here so we can update values - // from inside the callback - final long[] extractedBytes = {0}; - final int[] lastPercentage = {0}; - - net.lingala.zip4j.ZipFile zipFile = null; + net.lingala.zip4j.ZipFile zipFile; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { zipFile = new net.lingala.zip4j.ZipFile(zipFilePath); zipFile.setCharset(Charset.forName(charset)); @@ -152,26 +137,32 @@ public void run() { zipFile = new net.lingala.zip4j.ZipFile(zipFilePath); } - ProgressMonitor progressMonitor = zipFile.getProgressMonitor(); - - zipFile.setRunInThread(true); - zipFile.extractAll(destDirectory); - - while (!progressMonitor.getState().equals(ProgressMonitor.State.READY)) { - updateProgress(progressMonitor.getWorkCompleted(), progressMonitor.getTotalWork(), zipFilePath); - - Thread.sleep(100); + List fileHeaderList = zipFile.getFileHeaders(); + long totalUncompressedBytes = 0; + for (FileHeader header : fileHeaderList) { + long size = header.getUncompressedSize(); + if (size > 0) { + totalUncompressedBytes += size; + } + } + if (totalUncompressedBytes == 0) { + totalUncompressedBytes = 1; } - if (progressMonitor.getResult().equals(ProgressMonitor.Result.SUCCESS)) { - zipFile.close(); - updateProgress(1, 1, zipFilePath); // force 100% - promise.resolve(destDirectory); - } else if (progressMonitor.getResult().equals(ProgressMonitor.Result.ERROR)) { - throw new Exception("Error occurred. Error message: " + progressMonitor.getException().getMessage()); - } else if (progressMonitor.getResult().equals(ProgressMonitor.Result.CANCELLED)) { - throw new Exception("Task cancelled"); + long extractedBytes = 0; + for (FileHeader fileHeader : fileHeaderList) { + ZipSecurity.validateExtractPath(destDirectory, fileHeader.getFileName()); + zipFile.extractFile(fileHeader, destDirectory, ZipSecurity.createExtractParameters()); + long size = fileHeader.getUncompressedSize(); + if (size > 0) { + extractedBytes += size; + } + updateProgress(extractedBytes, totalUncompressedBytes, zipFilePath); } + + zipFile.close(); + updateProgress(1, 1, zipFilePath); // force 100% + promise.resolve(destDirectory); } catch (Exception ex) { updateProgress(0, 1, zipFilePath); // force 0% promise.reject("RNZipArchiveError", "Failed to extract file " + ex.getLocalizedMessage()); @@ -238,12 +229,7 @@ public void run() { Log.i("rnziparchive", "Extracting: " + entry.getName()); fout = new File(destDirectory, entry.getName()); - String canonicalPath = fout.getCanonicalPath(); - String destDirCanonicalPath = (new File(destDirectory).getCanonicalPath()) + File.separator; - - if (!canonicalPath.startsWith(destDirCanonicalPath)) { - throw new SecurityException(String.format("Found Zip Path Traversal Vulnerability with %s", canonicalPath)); - } + ZipSecurity.validateExtractPath(destDirectory, entry.getName()); if (!fout.exists()) { //noinspection ResultOfMethodCallIgnored diff --git a/android/src/main/java/com/rnziparchive/ZipSecurity.java b/android/src/main/java/com/rnziparchive/ZipSecurity.java new file mode 100644 index 00000000..6cf80dd8 --- /dev/null +++ b/android/src/main/java/com/rnziparchive/ZipSecurity.java @@ -0,0 +1,44 @@ +package com.rnziparchive; + +import java.io.File; +import java.io.IOException; + +import net.lingala.zip4j.model.UnzipParameters; + +/** + * Validates zip extraction paths to prevent Zip Slip / path traversal attacks. + */ +public final class ZipSecurity { + + private ZipSecurity() { + // utility class + } + + /** + * Returns extraction parameters with symlink extraction disabled. zip4j enables symlink + * extraction by default but does not validate that a symlink's resolved target stays inside + * the destination directory, allowing archives to plant links escaping the extraction root + * (see issue #357). With symlinks disabled, zip4j skips symlink entries entirely. + */ + public static UnzipParameters createExtractParameters() { + UnzipParameters params = new UnzipParameters(); + params.setExtractSymbolicLinks(false); + return params; + } + + /** + * Ensures that extracting {@code entryName} into {@code destDirectory} would not escape the + * destination directory (e.g. via {@code ../} or absolute paths). + */ + public static void validateExtractPath(String destDirectory, String entryName) throws IOException { + File destDir = new File(destDirectory); + File fout = new File(destDir, entryName); + + String canonicalPath = fout.getCanonicalPath(); + String destDirCanonicalPath = destDir.getCanonicalPath() + File.separator; + + if (!canonicalPath.startsWith(destDirCanonicalPath)) { + throw new SecurityException(String.format("Found Zip Path Traversal Vulnerability with %s", canonicalPath)); + } + } +} diff --git a/ios/RNZipArchive.h b/ios/RNZipArchive.h index acb92a71..5199dc02 100644 --- a/ios/RNZipArchive.h +++ b/ios/RNZipArchive.h @@ -10,7 +10,7 @@ #import #import -@interface RNZipArchive : RCTEventEmitter +@interface RNZipArchive : RCTEventEmitter @property (nonatomic) NSString *processedFilePath; @property (nonatomic) float progress; diff --git a/ios/RNZipArchive.m b/ios/RNZipArchive.m deleted file mode 100644 index 391757cf..00000000 --- a/ios/RNZipArchive.m +++ /dev/null @@ -1,259 +0,0 @@ -// -// RNZipArchive.m -// RNZipArchive -// -// Created by Perry Poon on 8/26/15. -// Copyright (c) 2015 Perry Poon. All rights reserved. -// - -#import "RNZipArchive.h" -#import - -#if __has_include() -#import -#else -#import "RCTBridge.h" -#import "RCTEventDispatcher.h" -#endif - -@implementation RNZipArchive -{ - bool hasListeners; -} - -@synthesize bridge = _bridge; - -RCT_EXPORT_MODULE(); - -// Will be called when this module's first listener is added. --(void)startObserving { - hasListeners = YES; - // Set up any upstream listeners or background tasks as necessary -} - -// Will be called when this module's last listener is removed, or on dealloc. --(void)stopObserving { - hasListeners = NO; - // Remove upstream listeners, stop unnecessary background tasks -} - -- (NSArray *)supportedEvents -{ - return @[@"zipArchiveProgressEvent"]; -} - -RCT_EXPORT_METHOD(isPasswordProtected:(NSString *)file - resolver:(RCTPromiseResolveBlock)resolve - rejecter:(RCTPromiseRejectBlock)reject) { - - BOOL isPasswordProtected = [SSZipArchive isFilePasswordProtectedAtPath:file]; - resolve([NSNumber numberWithBool:isPasswordProtected]); -} - -RCT_EXPORT_METHOD(unzip:(NSString *)from - destinationPath:(NSString *)destinationPath - charset:(NSString *)charset - resolver:(RCTPromiseResolveBlock)resolve - rejecter:(RCTPromiseRejectBlock)reject) { - self.progress = 0.0; - self.processedFilePath = @""; - [self zipArchiveProgressEvent:0 total:1]; // force 0% - - NSError *error = nil; - - BOOL success = [SSZipArchive unzipFileAtPath:from toDestination:destinationPath preserveAttributes:NO overwrite:YES password:nil error:&error delegate:self]; - - self.progress = 1.0; - [self zipArchiveProgressEvent:1 total:1]; // force 100% - - if (success) { - resolve(destinationPath); - } else { - reject(@"unzip_error", [error localizedDescription], error); - } -} - -RCT_EXPORT_METHOD(unzipWithPassword:(NSString *)from - destinationPath:(NSString *)destinationPath - password:(NSString *)password - resolver:(RCTPromiseResolveBlock)resolve - rejecter:(RCTPromiseRejectBlock)reject) { - self.progress = 0.0; - self.processedFilePath = @""; - [self zipArchiveProgressEvent:0 total:1]; // force 0% - - NSError *error = nil; - - BOOL success = [SSZipArchive unzipFileAtPath:from toDestination:destinationPath preserveAttributes:NO overwrite:YES password:password error:&error delegate:self]; - - self.progress = 1.0; - [self zipArchiveProgressEvent:1 total:1]; // force 100% - - if (success) { - resolve(destinationPath); - } else { - NSString *errorMessage = error ? [error localizedDescription] : @"unable to unzip"; - reject(@"unzip_error", errorMessage, error); - } -} - -RCT_EXPORT_METHOD(zipFolder:(NSString *)from - destinationPath:(NSString *)destinationPath - compressionLevel:(double)compressionLevel - resolver:(RCTPromiseResolveBlock)resolve - rejecter:(RCTPromiseRejectBlock)reject) { - self.progress = 0.0; - self.processedFilePath = @""; - [self zipArchiveProgressEvent:0 total:1]; // force 0% - - BOOL success; - [self setProgressHandler]; - - success = [SSZipArchive createZipFileAtPath:destinationPath - withContentsOfDirectory:from - keepParentDirectory:NO - compressionLevel:compressionLevel - password:nil - AES:NO - progressHandler:self.progressHandler]; - - self.progress = 1.0; - [self zipArchiveProgressEvent:1 total:1]; // force 100% - - if (success) { - resolve(destinationPath); - } else { - NSError *error = nil; - reject(@"zip_error", @"unable to zip", error); - } -} - -RCT_EXPORT_METHOD(zipFiles:(NSArray *)from - destinationPath:(NSString *)destinationPath - compressionLevel:(double)compressionLevel - resolver:(RCTPromiseResolveBlock)resolve - rejecter:(RCTPromiseRejectBlock)reject) { - self.progress = 0.0; - self.processedFilePath = @""; - [self zipArchiveProgressEvent:0 total:1]; // force 0% - - BOOL success; - [self setProgressHandler]; - - success = [SSZipArchive createZipFileAtPath:destinationPath withFilesAtPaths:from]; - - self.progress = 1.0; - [self zipArchiveProgressEvent:1 total:1]; // force 100% - - if (success) { - resolve(destinationPath); - } else { - NSError *error = nil; - reject(@"zip_error", @"unable to zip", error); - } -} - -RCT_EXPORT_METHOD(zipFolderWithPassword:(NSString *)from - destinationPath:(NSString *)destinationPath - password:(NSString *)password - encryptionType:(NSString *)encryptionType - compressionLevel:(double)compressionLevel - resolver:(RCTPromiseResolveBlock)resolve - rejecter:(RCTPromiseRejectBlock)reject) { - self.progress = 0.0; - self.processedFilePath = @""; - [self zipArchiveProgressEvent:0 total:1]; // force 0% - - BOOL success; - [self setProgressHandler]; - BOOL useAES = encryptionType && ![encryptionType isEqualToString:@"STANDARD"]; - success = [SSZipArchive createZipFileAtPath:destinationPath - withContentsOfDirectory:from - keepParentDirectory:NO - compressionLevel:compressionLevel - password:password - AES:useAES - progressHandler:self.progressHandler]; - - self.progress = 1.0; - [self zipArchiveProgressEvent:1 total:1]; // force 100% - - if (success) { - resolve(destinationPath); - } else { - NSError *error = nil; - reject(@"zip_error", @"unable to zip", error); - } -} - -RCT_EXPORT_METHOD(zipFilesWithPassword:(NSArray *)from - destinationPath:(NSString *)destinationPath - password:(NSString *)password - encryptionType:(NSString *)encryptionType - compressionLevel:(double)compressionLevel - resolver:(RCTPromiseResolveBlock)resolve - rejecter:(RCTPromiseRejectBlock)reject) { - self.progress = 0.0; - self.processedFilePath = @""; - [self zipArchiveProgressEvent:0 total:1]; // force 0% - - BOOL success; - [self setProgressHandler]; - // Note: withFilesAtPaths doesn't have AES class method, using password only - success = [SSZipArchive createZipFileAtPath:destinationPath withFilesAtPaths:from withPassword:password]; - - self.progress = 1.0; - [self zipArchiveProgressEvent:1 total:1]; // force 100% - - if (success) { - resolve(destinationPath); - } else { - NSError *error = nil; - reject(@"zip_error", @"unable to zip", error); - } -} - - -RCT_EXPORT_METHOD(getUncompressedSize:(NSString *)path - charset:(NSString *)charset - resolver:(RCTPromiseResolveBlock)resolve - rejecter:(RCTPromiseRejectBlock)reject) { - NSError *error = nil; - NSNumber *wantedFileSize = [SSZipArchive payloadSizeForArchiveAtPath:path error:&error]; - - if (error == nil) { - resolve(wantedFileSize); - } else { -// reject(@"get_uncompressed_size_error", [error localizedDescription], error); - resolve(@-1); - } -} - -- (dispatch_queue_t)methodQueue { - return dispatch_queue_create("com.mockingbot.ReactNative.ZipArchiveQueue", DISPATCH_QUEUE_SERIAL); -} - -- (void)zipArchiveProgressEvent:(unsigned long long)loaded total:(unsigned long long)total { - self.progress = (float)loaded / (float)total; - [self dispatchProgessEvent:self.progress processedFilePath:self.processedFilePath]; -} - -- (void)zipArchiveDidUnzipFileAtIndex:(NSInteger)fileIndex totalFiles:(NSInteger)totalFiles archivePath:(NSString *)archivePath unzippedFilePath:(NSString *)processedFilePath { - self.processedFilePath = processedFilePath; - [self dispatchProgessEvent:self.progress processedFilePath:self.processedFilePath]; -} - -- (void)setProgressHandler { - __weak RNZipArchive *weakSelf = self; - self.progressHandler = ^(NSUInteger entryNumber, NSUInteger total) { - [weakSelf zipArchiveProgressEvent:entryNumber total:total]; - }; -} - -- (void)dispatchProgessEvent:(float)progress processedFilePath:(NSString *)processedFilePath { - if (hasListeners) { - [self sendEventWithName:@"zipArchiveProgressEvent" body:@{@"progress": @(progress), @"filePath": processedFilePath}]; - } -} - -@end diff --git a/ios/RNZipArchive.mm b/ios/RNZipArchive.mm new file mode 100644 index 00000000..3f0a71a8 --- /dev/null +++ b/ios/RNZipArchive.mm @@ -0,0 +1,516 @@ +// +// RNZipArchive.mm +// RNZipArchive +// +// Created by Perry Poon on 8/26/15. +// Copyright (c) 2015 Perry Poon. All rights reserved. +// + +#import "RNZipArchive.h" +#if __has_include() +#import +#elif __has_include("mz_compat.h") +#import "mz_compat.h" +#else +#import "unzip.h" +#endif +#import + +#if __has_include() +#import +#else +#import "RCTBridge.h" +#import "RCTEventDispatcher.h" +#endif + +@implementation RNZipArchive +{ + bool hasListeners; +} + +@synthesize bridge = _bridge; + +RCT_EXPORT_MODULE(); + +-(void)startObserving { + hasListeners = YES; +} + +-(void)stopObserving { + hasListeners = NO; +} + +- (NSArray *)supportedEvents +{ + return @[@"zipArchiveProgressEvent"]; +} + +RCT_EXPORT_METHOD(isPasswordProtected:(NSString *)file + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) { + + BOOL isPasswordProtected = [SSZipArchive isFilePasswordProtectedAtPath:file]; + resolve([NSNumber numberWithBool:isPasswordProtected]); +} + +RCT_EXPORT_METHOD(unzip:(NSString *)from + destinationPath:(NSString *)destinationPath + charset:(NSString *)charset + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) { + (void)charset; + [self extractZipArchive:from destinationPath:destinationPath password:nil resolve:resolve reject:reject]; +} + +RCT_EXPORT_METHOD(unzipWithPassword:(NSString *)from + destinationPath:(NSString *)destinationPath + password:(NSString *)password + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) { + [self extractZipArchive:from destinationPath:destinationPath password:password resolve:resolve reject:reject]; +} + +RCT_EXPORT_METHOD(zipFolder:(NSString *)from + destinationPath:(NSString *)destinationPath + compressionLevel:(double)compressionLevel + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) { + self.progress = 0.0; + self.processedFilePath = @""; + [self zipArchiveProgressEvent:0 total:1]; + + BOOL success; + [self setProgressHandler]; + + success = [SSZipArchive createZipFileAtPath:destinationPath + withContentsOfDirectory:from + keepParentDirectory:NO + compressionLevel:compressionLevel + password:nil + AES:NO + progressHandler:self.progressHandler]; + + self.progress = 1.0; + [self zipArchiveProgressEvent:1 total:1]; + + if (success) { + resolve(destinationPath); + } else { + reject(@"zip_error", @"unable to zip", nil); + } +} + +RCT_EXPORT_METHOD(zipFiles:(NSArray *)from + destinationPath:(NSString *)destinationPath + compressionLevel:(double)compressionLevel + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) { + (void)compressionLevel; + self.progress = 0.0; + self.processedFilePath = @""; + [self zipArchiveProgressEvent:0 total:1]; + + BOOL success; + [self setProgressHandler]; + + success = [SSZipArchive createZipFileAtPath:destinationPath withFilesAtPaths:from]; + + self.progress = 1.0; + [self zipArchiveProgressEvent:1 total:1]; + + if (success) { + resolve(destinationPath); + } else { + reject(@"zip_error", @"unable to zip", nil); + } +} + +RCT_EXPORT_METHOD(zipFolderWithPassword:(NSString *)from + destinationPath:(NSString *)destinationPath + password:(NSString *)password + encryptionType:(NSString *)encryptionType + compressionLevel:(double)compressionLevel + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) { + self.progress = 0.0; + self.processedFilePath = @""; + [self zipArchiveProgressEvent:0 total:1]; + + BOOL success; + [self setProgressHandler]; + BOOL useAES = encryptionType && ![encryptionType isEqualToString:@"STANDARD"]; + success = [SSZipArchive createZipFileAtPath:destinationPath + withContentsOfDirectory:from + keepParentDirectory:NO + compressionLevel:compressionLevel + password:password + AES:useAES + progressHandler:self.progressHandler]; + + self.progress = 1.0; + [self zipArchiveProgressEvent:1 total:1]; + + if (success) { + resolve(destinationPath); + } else { + reject(@"zip_error", @"unable to zip", nil); + } +} + +RCT_EXPORT_METHOD(zipFilesWithPassword:(NSArray *)from + destinationPath:(NSString *)destinationPath + password:(NSString *)password + encryptionType:(NSString *)encryptionType + compressionLevel:(double)compressionLevel + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) { + (void)encryptionType; + (void)compressionLevel; + self.progress = 0.0; + self.processedFilePath = @""; + [self zipArchiveProgressEvent:0 total:1]; + + BOOL success; + [self setProgressHandler]; + success = [SSZipArchive createZipFileAtPath:destinationPath withFilesAtPaths:from withPassword:password]; + + self.progress = 1.0; + [self zipArchiveProgressEvent:1 total:1]; + + if (success) { + resolve(destinationPath); + } else { + reject(@"zip_error", @"unable to zip", nil); + } +} + +RCT_EXPORT_METHOD(getUncompressedSize:(NSString *)path + charset:(NSString *)charset + resolver:(RCTPromiseResolveBlock)resolve + rejecter:(RCTPromiseRejectBlock)reject) { + (void)charset; + (void)reject; + NSError *error = nil; + NSNumber *wantedFileSize = [SSZipArchive payloadSizeForArchiveAtPath:path error:&error]; + + if (error == nil) { + resolve(wantedFileSize); + } else { + resolve(@-1); + } +} + +- (BOOL)readCurrentZipEntry:(unzFile)zip + path:(NSString **)outPath + size:(unsigned long long *)outSize + isDirectory:(BOOL *)outIsDirectory + version:(uLong *)outVersion + externalFa:(uLong *)outExternalFa + error:(NSString **)outError { + unz_file_info64 fileInfo; + memset(&fileInfo, 0, sizeof(fileInfo)); + int ret = unzGetCurrentFileInfo64(zip, &fileInfo, NULL, 0, NULL, 0, NULL, 0); + if (ret != UNZ_OK) { + if (outError != NULL) { + *outError = @"failed to retrieve info for zip entry"; + } + return NO; + } + + size_t nameLen = (size_t)fileInfo.size_filename; + char *filename = (char *)malloc(nameLen + 1); + if (filename == NULL) { + if (outError != NULL) { + *outError = @"out of memory while reading zip entry"; + } + return NO; + } + unzGetCurrentFileInfo64(zip, &fileInfo, filename, nameLen + 1, NULL, 0, NULL, 0); + filename[nameLen] = '\0'; + + NSString *path = [NSString stringWithUTF8String:filename]; + if (path == nil) { + path = [[NSString alloc] initWithBytes:filename + length:nameLen + encoding:NSISOLatin1StringEncoding]; + } + BOOL isDirectory = NO; + if (nameLen > 0 && (filename[nameLen - 1] == '/' || filename[nameLen - 1] == '\\')) { + isDirectory = YES; + } + free(filename); + + if (outPath != NULL) { + *outPath = path ?: @""; + } + if (outSize != NULL) { + *outSize = (unsigned long long)fileInfo.uncompressed_size; + } + if (outIsDirectory != NULL) { + *outIsDirectory = isDirectory; + } + if (outVersion != NULL) { + *outVersion = fileInfo.version; + } + if (outExternalFa != NULL) { + *outExternalFa = fileInfo.external_fa; + } + return YES; +} + +- (BOOL)isSymlinkZipEntryVersion:(uLong)version externalFa:(uLong)externalFa { + const uLong ZipUNIXVersion = 3; + const uLong BSD_SFMT = 0170000; + const uLong BSD_IFLNK = 0120000; + return ((version >> 8) == ZipUNIXVersion) && BSD_IFLNK == (BSD_SFMT & (externalFa >> 16)); +} + +- (BOOL)shouldSkipZipEntry:(NSString *)entryName + version:(uLong)version + externalFa:(uLong)externalFa { + if (entryName.length == 0) { + return YES; + } + if ([entryName hasPrefix:@"__MACOSX/"]) { + return YES; + } + if ([self isSymlinkZipEntryVersion:version externalFa:externalFa]) { + return YES; + } + return NO; +} + +- (BOOL)isSafeExtractPath:(NSString *)entryName intoDestination:(NSString *)destinationPath { + if (entryName.length == 0) { + return NO; + } + NSString *fullPath = [destinationPath stringByAppendingPathComponent:entryName]; + NSString *standardizedDest = [[destinationPath stringByStandardizingPath] stringByAppendingString:@"/"]; + NSString *standardizedFull = [fullPath stringByStandardizingPath]; + return [standardizedFull hasPrefix:standardizedDest] || + [standardizedFull isEqualToString:[destinationPath stringByStandardizingPath]]; +} + +- (void)extractZipArchive:(NSString *)from + destinationPath:(NSString *)destinationPath + password:(NSString *)password + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { + if (password.length > 0 && ![SSZipArchive isFilePasswordProtectedAtPath:from]) { + reject(@"unzip_error", + [NSString stringWithFormat:@"Zip file: %@ is not password protected", from], + nil); + return; + } + + self.progress = 0.0; + self.processedFilePath = @""; + [self zipArchiveProgressEvent:0 total:1]; + + NSFileManager *fileManager = [NSFileManager defaultManager]; + if (![fileManager fileExistsAtPath:destinationPath]) { + [fileManager createDirectoryAtPath:destinationPath + withIntermediateDirectories:YES + attributes:nil + error:nil]; + } + + zipFile zip = unzOpen(from.fileSystemRepresentation); + if (zip == NULL) { + reject(@"unzip_error", @"failed to open zip file", nil); + return; + } + + unsigned long long totalSize = 0; + int ret = unzGoToFirstFile(zip); + while (ret == UNZ_OK) { + NSString *path = nil; + unsigned long long size = 0; + uLong version = 0; + uLong externalFa = 0; + if ([self readCurrentZipEntry:zip + path:&path + size:&size + isDirectory:NULL + version:&version + externalFa:&externalFa + error:NULL]) { + if ([self shouldSkipZipEntry:path version:version externalFa:externalFa]) { + ret = unzGoToNextFile(zip); + continue; + } + if (![self isSafeExtractPath:path intoDestination:destinationPath]) { + unzClose(zip); + reject(@"unzip_error", + [NSString stringWithFormat:@"Found Zip Path Traversal Vulnerability with %@", path], + nil); + return; + } + totalSize += size; + } + ret = unzGoToNextFile(zip); + } + if (totalSize == 0) { + totalSize = 1; + } + + unsigned long long extractedBytes = 0; + BOOL success = YES; + NSError *extractError = nil; + ret = unzGoToFirstFile(zip); + while (ret == UNZ_OK) { + NSString *strPath = nil; + unsigned long long uncompressedSize = 0; + BOOL isDirectory = NO; + uLong version = 0; + uLong externalFa = 0; + NSString *entryError = nil; + if (![self readCurrentZipEntry:zip + path:&strPath + size:&uncompressedSize + isDirectory:&isDirectory + version:&version + externalFa:&externalFa + error:&entryError]) { + success = NO; + extractError = [NSError errorWithDomain:@"RNZipArchive" + code:-1 + userInfo:@{NSLocalizedDescriptionKey: entryError ?: @"failed to retrieve info for zip entry"}]; + break; + } + + if ([self shouldSkipZipEntry:strPath version:version externalFa:externalFa]) { + ret = unzGoToNextFile(zip); + continue; + } + + if (![self isSafeExtractPath:strPath intoDestination:destinationPath]) { + success = NO; + extractError = [NSError errorWithDomain:@"RNZipArchive" + code:-1 + userInfo:@{NSLocalizedDescriptionKey: + [NSString stringWithFormat:@"Found Zip Path Traversal Vulnerability with %@", strPath]}]; + break; + } + + self.processedFilePath = strPath; + NSString *fullPath = [destinationPath stringByAppendingPathComponent:strPath]; + + if (isDirectory) { + [fileManager createDirectoryAtPath:fullPath + withIntermediateDirectories:YES + attributes:nil + error:nil]; + extractedBytes += uncompressedSize; + [self zipArchiveProgressEvent:extractedBytes total:totalSize]; + ret = unzGoToNextFile(zip); + continue; + } + + [fileManager createDirectoryAtPath:[fullPath stringByDeletingLastPathComponent] + withIntermediateDirectories:YES + attributes:nil + error:nil]; + + if (password.length > 0) { + ret = unzOpenCurrentFilePassword(zip, [password cStringUsingEncoding:NSUTF8StringEncoding]); + } else { + ret = unzOpenCurrentFile(zip); + } + if (ret != UNZ_OK) { + success = NO; + extractError = [NSError errorWithDomain:@"RNZipArchive" + code:-1 + userInfo:@{NSLocalizedDescriptionKey: password.length > 0 + ? @"wrong password or failed to open encrypted zip entry" + : @"failed to open file in zip archive"}]; + break; + } + + FILE *out = fopen(fullPath.fileSystemRepresentation, "wb"); + if (out == NULL) { + unzCloseCurrentFile(zip); + success = NO; + extractError = [NSError errorWithDomain:@"RNZipArchive" + code:-1 + userInfo:@{NSLocalizedDescriptionKey: @"failed to write extracted file"}]; + break; + } + + unsigned char buffer[4096]; + int readBytes; + do { + readBytes = unzReadCurrentFile(zip, buffer, sizeof(buffer)); + if (readBytes < 0) { + success = NO; + extractError = [NSError errorWithDomain:@"RNZipArchive" + code:-1 + userInfo:@{NSLocalizedDescriptionKey: @"failed to read zip entry"}]; + break; + } + if (readBytes > 0) { + if (fwrite(buffer, 1, (size_t)readBytes, out) != (size_t)readBytes) { + success = NO; + extractError = [NSError errorWithDomain:@"RNZipArchive" + code:-1 + userInfo:@{NSLocalizedDescriptionKey: @"failed to write extracted file"}]; + break; + } + } + } while (readBytes > 0); + + fclose(out); + int closeRet = unzCloseCurrentFile(zip); + if (success && closeRet != UNZ_OK) { + success = NO; + extractError = [NSError errorWithDomain:@"RNZipArchive" + code:-1 + userInfo:@{NSLocalizedDescriptionKey: @"failed to extract zip entry (wrong password or corrupt archive)"}]; + } + + if (!success) { + break; + } + + extractedBytes += uncompressedSize; + [self zipArchiveProgressEvent:extractedBytes total:totalSize]; + ret = unzGoToNextFile(zip); + } + + unzClose(zip); + + if (success) { + self.progress = 1.0; + [self zipArchiveProgressEvent:1 total:1]; + resolve(destinationPath); + } else { + self.progress = 0.0; + [self zipArchiveProgressEvent:0 total:1]; + NSString *message = extractError ? extractError.localizedDescription : @"unable to unzip"; + reject(@"unzip_error", message, extractError); + } +} + +- (dispatch_queue_t)methodQueue { + return dispatch_queue_create("com.mockingbot.ReactNative.ZipArchiveQueue", DISPATCH_QUEUE_SERIAL); +} + +- (void)zipArchiveProgressEvent:(unsigned long long)loaded total:(unsigned long long)total { + self.progress = (float)loaded / (float)total; + [self dispatchProgessEvent:self.progress processedFilePath:self.processedFilePath]; +} + +- (void)setProgressHandler { + __weak RNZipArchive *weakSelf = self; + self.progressHandler = ^(NSUInteger entryNumber, NSUInteger total) { + [weakSelf zipArchiveProgressEvent:entryNumber total:total]; + }; +} + +- (void)dispatchProgessEvent:(float)progress processedFilePath:(NSString *)processedFilePath { + if (hasListeners) { + [self sendEventWithName:@"zipArchiveProgressEvent" body:@{@"progress": @(progress), @"filePath": processedFilePath}]; + } +} + +@end diff --git a/package.json b/package.json index a59bfdb8..23b39827 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-zip-archive", - "version": "7.1.0", + "version": "7.1.1", "description": "A little wrapper on ZipArchive for react-native", "main": "index.js", "scripts": {