From 5dafae085440d3708ed6a4f96e80264975df79a2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 29 Aug 2026 10:16:03 +0000 Subject: [PATCH] fix(ios): Zip Slip + skip symlinks on full extract (#357 parity) Replace SSZipArchive full-unzip with the same minizip path used for selective extract. Rejects traversal entries with ERR_UNSAFE_PATH and skips symlink/__MACOSX entries instead of materializing them. Co-authored-by: Perry --- CHANGELOG.md | 5 + SECURITY.md | 4 +- __tests__/package-metadata.test.js | 6 +- ios/RNZipArchive.mm | 229 +++++++++++++---------------- package.json | 2 +- 5 files changed, 113 insertions(+), 133 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 100cfbc..286454a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## [9.4.1] - 2026-08-29 + +### Fixed +- iOS: full `unzip` / `unzipWithPassword` / `unzipAssets` now use the same minizip extract path as selective extract — rejects Zip Slip entries with `ERR_UNSAFE_PATH` and skips symlink entries instead of materializing them (#357 parity with Android) + ## [9.4.0] - 2026-07-25 ### Added diff --git a/SECURITY.md b/SECURITY.md index 50aa171..80a611a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -9,7 +9,7 @@ | **7.x** | Security fixes only through **2027-02-19**. After that, 7.x is unsupported. Stay on 7.x if you are on React Native < 0.70 until you upgrade RN. 7.x will not be deleted or unpublished. | | **< 7** | Unsupported except for critical issues | -Zip Slip / symlink fixes shipped in 9.x will be **evaluated for 7.x backports**. If a patch is warranted, it will be published as `7.x.y`. Those backports are not done yet. +Zip Slip / symlink fixes shipped in 9.x will be **evaluated for 7.x backports**. If a patch is warranted, it will be published as `7.x.y`. **7.1.1** backports Zip Slip validation and symlink skipping for Android and iOS extract paths. ## Reporting a vulnerability @@ -33,4 +33,4 @@ In scope: - **Android Zip Slip** protection: 9.0.0 — extract rejects entries whose path escapes the destination. - **Android symlink extract**: 9.0.2 — `unzip` / `unzipWithPassword` no longer materialize symlink entries. -iOS (verified in `ios/RNZipArchive.mm`): selective extract rejects Zip Slip via `isSafeExtractPath`. Full unzip goes through SSZipArchive; this policy does not claim extra checks there. `ios/` does not skip symlink entries. +iOS (verified in `ios/RNZipArchive.mm`): full and selective extract use minizip with `isSafeExtractPath` (Zip Slip) and skip symlink entries (`shouldSkipZipEntry`), matching Android #357 behavior. Full unzip no longer delegates extract to SSZipArchive. diff --git a/__tests__/package-metadata.test.js b/__tests__/package-metadata.test.js index 6f0205c..64bdf33 100644 --- a/__tests__/package-metadata.test.js +++ b/__tests__/package-metadata.test.js @@ -63,11 +63,15 @@ describe('docs claims vs native source (RNZA-7/15/17/19)', () => { test('SECURITY.md matches the Android Zip Slip / symlink helpers that exist', () => { const security = read('SECURITY.md'); const zipSecurity = read('android/src/main/java/com/rnziparchive/ZipSecurity.java'); + const ios = read('ios/RNZipArchive.mm'); expect(security).toMatch(/9\.x/); expect(security).toMatch(/2027-02-19/); expect(zipSecurity).toMatch(/setExtractSymbolicLinks\(false\)/); expect(zipSecurity).toMatch(/Zip Path Traversal Vulnerability/); - expect(read('ios/RNZipArchive.mm')).toMatch(/isSafeExtractPath/); + expect(ios).toMatch(/isSafeExtractPath/); + expect(ios).toMatch(/shouldSkipZipEntry/); + expect(ios).toMatch(/isSymlinkZipEntryVersion/); + expect(ios).not.toMatch(/SSZipArchive unzipFileAtPath/); }); test('README does not claim old-arch Interop is proven', () => { diff --git a/ios/RNZipArchive.mm b/ios/RNZipArchive.mm index d939eb7..19a562c 100644 --- a/ios/RNZipArchive.mm +++ b/ios/RNZipArchive.mm @@ -37,23 +37,6 @@ static NSString *const kZipErrUnzip = @"ERR_UNZIP"; static NSString *const kZipErrUnsupported = @"ERR_UNSUPPORTED"; -@interface RNZipCancelDelegate : NSObject -@property (nonatomic, weak) RNZipArchive *owner; -@end - -@implementation RNZipCancelDelegate -- (BOOL)zipArchiveShouldUnzipFileAtIndex:(NSInteger)fileIndex - totalFiles:(NSInteger)totalFiles - archivePath:(NSString *)archivePath - fileInfo:(unz_file_info)fileInfo { - (void)fileIndex; - (void)totalFiles; - (void)archivePath; - (void)fileInfo; - return self.owner != nil && !self.owner.cancelled; -} -@end - @implementation RNZipArchive { bool hasListeners; @@ -160,16 +143,12 @@ - (void)unzip:(NSString *)from } [self beginOperation]; [self runAsync:^{ - if (entries != nil && entries.count > 0) { - [self unzipSelectedEntries:from - destinationPath:destinationPath - entries:entries - password:nil - resolve:resolve - reject:reject]; - return; - } - [self unzipFile:from destinationPath:destinationPath password:nil resolve:resolve reject:reject]; + [self extractZipArchive:from + destinationPath:destinationPath + password:nil + filterEntries:(entries != nil && entries.count > 0) ? entries : nil + resolve:resolve + reject:reject]; }]; } @@ -181,16 +160,12 @@ - (void)unzipWithPassword:(NSString *)from reject:(RCTPromiseRejectBlock)reject { [self beginOperation]; [self runAsync:^{ - if (entries != nil && entries.count > 0) { - [self unzipSelectedEntries:from - destinationPath:destinationPath - entries:entries - password:password - resolve:resolve - reject:reject]; - return; - } - [self unzipFile:from destinationPath:destinationPath password:password resolve:resolve reject:reject]; + [self extractZipArchive:from + destinationPath:destinationPath + password:password + filterEntries:(entries != nil && entries.count > 0) ? entries : nil + resolve:resolve + reject:reject]; }]; } @@ -225,6 +200,8 @@ - (void)listContents:(NSString *)source compressedSize:&compressedSize isDirectory:&isDirectory isEncrypted:&isEncrypted + version:NULL + externalFa:NULL error:&entryError]) { unzClose(zip); reject(kZipErrCorruptArchive, entryError ?: @"failed to retrieve info for zip entry", nil); @@ -253,6 +230,8 @@ - (BOOL)readCurrentZipEntry:(unzFile)zip compressedSize:(unsigned long long *)outCompressedSize isDirectory:(BOOL *)outIsDirectory isEncrypted:(BOOL *)outIsEncrypted + version:(uLong *)outVersion + externalFa:(uLong *)outExternalFa error:(NSString **)outError { unz_file_info64 fileInfo; memset(&fileInfo, 0, sizeof(fileInfo)); @@ -302,9 +281,38 @@ - (BOOL)readCurrentZipEntry:(unzFile)zip if (outIsEncrypted != NULL) { *outIsEncrypted = (fileInfo.flag & 1) != 0; } + if (outVersion != NULL) { + *outVersion = fileInfo.version; + } + if (outExternalFa != NULL) { + *outExternalFa = fileInfo.external_fa; + } return YES; } +- (BOOL)isSymlinkZipEntryVersion:(uLong)version externalFa:(uLong)externalFa { + // Matches SSZipArchive _fileIsSymbolicLink — UNIX version + symlink mode in external attributes. + 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; +} + - (NSString *)normalizedZipPath:(NSString *)path { NSString *normalized = [path stringByReplacingOccurrencesOfString:@"\\" withString:@"/"]; while ([normalized hasSuffix:@"/"] && normalized.length > 0) { @@ -345,16 +353,18 @@ - (BOOL)isSafeExtractPath:(NSString *)entryName intoDestination:(NSString *)dest [standardizedFull isEqualToString:[destinationPath stringByStandardizingPath]]; } -- (void)unzipSelectedEntries:(NSString *)from - destinationPath:(NSString *)destinationPath - entries:(NSArray *)entries - password:(NSString *)password - resolve:(RCTPromiseResolveBlock)resolve - reject:(RCTPromiseRejectBlock)reject { +- (void)extractZipArchive:(NSString *)from + destinationPath:(NSString *)destinationPath + password:(NSString *)password + filterEntries:(NSArray *)filterEntries + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject { if ([self rejectIfCancelled:reject]) { return; } - if (entries.count == 0) { + + BOOL selectiveExtract = filterEntries != nil; + if (selectiveExtract && filterEntries.count == 0) { reject(kZipErrInvalidArgs, @"entries must be a non-empty array", nil); return; } @@ -389,7 +399,7 @@ - (void)unzipSelectedEntries:(NSString *)from return; } - // First pass: compute total uncompressed size of matching entries for progress. + // First pass: compute total uncompressed size of entries that will be extracted. unsigned long long totalSize = 0; NSUInteger matchCount = 0; int ret = unzGoToFirstFile(zip); @@ -400,21 +410,41 @@ - (void)unzipSelectedEntries:(NSString *)from } NSString *path = nil; unsigned long long size = 0; - if ([self readCurrentZipEntry:zip - path:&path - size:&size - compressedSize:NULL - isDirectory:NULL - isEncrypted:NULL - error:NULL] && - [self entry:path matchesSelection:entries]) { - matchCount += 1; - totalSize += size; + uLong version = 0; + uLong externalFa = 0; + if (![self readCurrentZipEntry:zip + path:&path + size:&size + compressedSize:NULL + isDirectory:NULL + isEncrypted:NULL + version:&version + externalFa:&externalFa + error:NULL]) { + ret = unzGoToNextFile(zip); + continue; } + if ([self shouldSkipZipEntry:path version:version externalFa:externalFa]) { + ret = unzGoToNextFile(zip); + continue; + } + if (selectiveExtract && ![self entry:path matchesSelection:filterEntries]) { + ret = unzGoToNextFile(zip); + continue; + } + if (![self isSafeExtractPath:path intoDestination:destinationPath]) { + unzClose(zip); + reject(kZipErrUnsafePath, + [NSString stringWithFormat:@"Found Zip Path Traversal Vulnerability with %@", path], + nil); + return; + } + matchCount += 1; + totalSize += size; ret = unzGoToNextFile(zip); } - if (matchCount == 0) { + if (selectiveExtract && matchCount == 0) { unzClose(zip); reject(kZipErrInvalidArgs, @"None of the requested entries were found in the archive", nil); return; @@ -424,7 +454,7 @@ - (void)unzipSelectedEntries:(NSString *)from totalSize = 1; } - // Second pass: extract matching entries. + // Second pass: extract entries (skipping symlinks and __MACOSX, with Zip Slip checks). unsigned long long extractedBytes = 0; BOOL success = YES; NSError *extractError = nil; @@ -434,6 +464,8 @@ - (void)unzipSelectedEntries:(NSString *)from 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 @@ -441,6 +473,8 @@ - (void)unzipSelectedEntries:(NSString *)from compressedSize:NULL isDirectory:&isDirectory isEncrypted:NULL + version:&version + externalFa:&externalFa error:&entryError]) { success = NO; extractError = [NSError errorWithDomain:@"RNZipArchive" @@ -454,12 +488,12 @@ - (void)unzipSelectedEntries:(NSString *)from return; } - if (strPath.length == 0 || ![self entry:strPath matchesSelection:entries]) { + if ([self shouldSkipZipEntry:strPath version:version externalFa:externalFa]) { ret = unzGoToNextFile(zip); continue; } - if ([strPath hasPrefix:@"__MACOSX/"]) { + if (selectiveExtract && ![self entry:strPath matchesSelection:filterEntries]) { ret = unzGoToNextFile(zip); continue; } @@ -579,79 +613,11 @@ - (void)unzipSelectedEntries:(NSString *)from } else { self.progress = 0.0; [self zipArchiveProgressEvent:0 total:1]; - NSString *message = extractError ? extractError.localizedDescription : @"unable to unzip selected entries"; + NSString *message = extractError ? extractError.localizedDescription : @"unable to unzip"; reject(extractCode, message, extractError); } } -- (void)unzipFile:(NSString *)from -destinationPath:(NSString *)destinationPath - password:(NSString *)password - resolve:(RCTPromiseResolveBlock)resolve - reject:(RCTPromiseRejectBlock)reject { - if ([self rejectIfCancelled:reject]) { - return; - } - self.progress = 0.0; - self.processedFilePath = @""; - [self zipArchiveProgressEvent:0 total:1]; // force 0% - - NSError *error = nil; - - // Total uncompressed size, used for byte-weighted progress. If it can't be - // determined, fall back to per-entry progress (entryNumber / total). - NSNumber *payloadSize = [SSZipArchive payloadSizeForArchiveAtPath:from error:nil]; - unsigned long long totalSize = payloadSize ? [payloadSize unsignedLongLongValue] : 0; - - __block unsigned long long extractedBytes = 0; - __weak RNZipArchive *weakSelf = self; - RNZipCancelDelegate *cancelDelegate = [RNZipCancelDelegate new]; - cancelDelegate.owner = self; - - BOOL success = [SSZipArchive unzipFileAtPath:from - toDestination:destinationPath - preserveAttributes:NO - overwrite:YES - nestedZipLevel:0 - password:password - error:&error - delegate:cancelDelegate - progressHandler:^(NSString *entry, unz_file_info zipInfo, long entryNumber, long total) { - RNZipArchive *strongSelf = weakSelf; - if (strongSelf == nil) { - return; - } - strongSelf.processedFilePath = entry; - if (totalSize > 0) { - extractedBytes += zipInfo.uncompressed_size; - [strongSelf zipArchiveProgressEvent:extractedBytes total:totalSize]; - } else { - [strongSelf zipArchiveProgressEvent:entryNumber total:total]; - } - } - completionHandler:nil]; - - if (self.cancelled) { - reject(kZipErrCancelled, @"Operation cancelled", nil); - } else if (success) { - self.progress = 1.0; - [self zipArchiveProgressEvent:1 total:1]; // force 100% - resolve(destinationPath); - } else { - self.progress = 0.0; - [self zipArchiveProgressEvent:0 total:1]; - NSString *errorMessage = error ? [error localizedDescription] : @"unable to unzip"; - NSString *code = kZipErrUnzip; - NSString *lower = errorMessage.lowercaseString; - if ([lower containsString:@"password"]) { - code = kZipErrWrongPassword; - } else if ([lower containsString:@"failed to open zip"]) { - code = kZipErrFileNotFound; - } - reject(code, errorMessage, error); - } -} - - (void)zipFolder:(NSString *)from destinationPath:(NSString *)destinationPath compressionLevel:(double)compressionLevel @@ -976,7 +942,12 @@ - (void)unzipAssets:(NSString *)source [self beginOperation]; [self runAsync:^{ - [self unzipFile:assetPath destinationPath:target password:nil resolve:resolve reject:reject]; + [self extractZipArchive:assetPath + destinationPath:target + password:nil + filterEntries:nil + resolve:resolve + reject:reject]; }]; } diff --git a/package.json b/package.json index 5cb7ab2..2f2702a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "react-native-zip-archive", - "version": "9.4.0", + "version": "9.4.1", "description": "Zip and unzip files in React Native and Expo (iOS & Android)", "main": "index.js", "scripts": {