Skip to content
Open
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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
4 changes: 2 additions & 2 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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.
6 changes: 5 additions & 1 deletion __tests__/package-metadata.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
229 changes: 100 additions & 129 deletions ios/RNZipArchive.mm
Original file line number Diff line number Diff line change
Expand Up @@ -37,23 +37,6 @@
static NSString *const kZipErrUnzip = @"ERR_UNZIP";
static NSString *const kZipErrUnsupported = @"ERR_UNSUPPORTED";

@interface RNZipCancelDelegate : NSObject <SSZipArchiveDelegate>
@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;
Expand Down Expand Up @@ -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];
}];
}

Expand All @@ -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];
}];
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
Expand All @@ -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;
Expand All @@ -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;
Expand All @@ -434,13 +464,17 @@ - (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
size:&uncompressedSize
compressedSize:NULL
isDirectory:&isDirectory
isEncrypted:NULL
version:&version
externalFa:&externalFa
error:&entryError]) {
success = NO;
extractError = [NSError errorWithDomain:@"RNZipArchive"
Expand All @@ -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;
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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];
}];
}

Expand Down
Loading
Loading