diff --git a/ios/CodePush.xcodeproj/project.pbxproj b/ios/CodePush.xcodeproj/project.pbxproj index e64a3ebf..5f4f719a 100644 --- a/ios/CodePush.xcodeproj/project.pbxproj +++ b/ios/CodePush.xcodeproj/project.pbxproj @@ -8,6 +8,13 @@ /* Begin PBXBuildFile section */ 08B8B3B8260E70B7ECA85451 /* bspatch_bridge.c in Sources */ = {isa = PBXBuildFile; fileRef = A430CBE260F09A3233110E28 /* bspatch_bridge.c */; }; + BBC7F97A68E454FB38953FC4 /* CodePushDiffManifestTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9645879798B46D35D8D824F8 /* CodePushDiffManifestTests.swift */; }; + CED0A6936F6F8C354302F246 /* CodePushDiffManifest.m in Sources */ = {isa = PBXBuildFile; fileRef = 49166671F067D5F6429B262B /* CodePushDiffManifest.m */; }; + CD32522E04D0F0E86DCC7BBE /* CodePushDiffManifest.m in Sources */ = {isa = PBXBuildFile; fileRef = 49166671F067D5F6429B262B /* CodePushDiffManifest.m */; }; + 0E9314F338AC505D2E33C1A3 /* CodePushDiffManifest.m in Sources */ = {isa = PBXBuildFile; fileRef = 49166671F067D5F6429B262B /* CodePushDiffManifest.m */; }; + 3F6A18E4C1B72D094F5E8A67 /* CodePushErrorUtils.m in Sources */ = {isa = PBXBuildFile; fileRef = 1B762E8F1C9A5E9A006EF800 /* CodePushErrorUtils.m */; }; + C11A2DB29D3D814B1A7891DF /* CodePushDiffManifest.h in Headers */ = {isa = PBXBuildFile; fileRef = 2A092598DCE51279CA317823 /* CodePushDiffManifest.h */; }; + 4693408DCB9ACB8E02BF69B1 /* CodePushDiffManifest.h in Headers */ = {isa = PBXBuildFile; fileRef = 2A092598DCE51279CA317823 /* CodePushDiffManifest.h */; }; 0ABCB5DEFE01A7A15552A498 /* file_for_patch.c in Sources */ = {isa = PBXBuildFile; fileRef = 70779807AB59EA4711737F4E /* file_for_patch.c */; }; 0EC08441DDDD08C96DDE851C /* CodePushSha256Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D23322E9E8EFBE01F1FACFB1 /* CodePushSha256Tests.swift */; }; 13BE3DEE1AC21097009241FE /* CodePush.m in Sources */ = {isa = PBXBuildFile; fileRef = 13BE3DED1AC21097009241FE /* CodePush.m */; }; @@ -185,6 +192,9 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 9645879798B46D35D8D824F8 /* CodePushDiffManifestTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CodePushDiffManifestTests.swift; sourceTree = ""; }; + 2A092598DCE51279CA317823 /* CodePushDiffManifest.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = CodePushDiffManifest.h; path = CodePush/CodePushDiffManifest.h; sourceTree = ""; }; + 49166671F067D5F6429B262B /* CodePushDiffManifest.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = CodePushDiffManifest.m; path = CodePush/CodePushDiffManifest.m; sourceTree = ""; }; 0BF68F85125CF81D7EB65ABB /* bspatch_bridge.h */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.h; path = bspatch_bridge.h; sourceTree = ""; }; 0DC7989C75C72A774EF3685F /* bsdiff_wrapper/bspatch_wrapper.c */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.c.c; path = bsdiff_wrapper/bspatch_wrapper.c; sourceTree = ""; }; 134814201AA4EA6300B7C361 /* libCodePush.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = libCodePush.a; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -439,6 +449,8 @@ 1B762E8F1C9A5E9A006EF800 /* CodePushErrorUtils.m */, 8C69F7C2081D7CEE96691674 /* CodePushErrorUtils.h */, 810D4E6C1B96935000B397E9 /* CodePushPackage.m */, + 2A092598DCE51279CA317823 /* CodePushDiffManifest.h */, + 49166671F067D5F6429B262B /* CodePushDiffManifest.m */, 5421FE301C58AD5A00986A55 /* CodePushTelemetryManager.m */, 540D20111C7684FE00D6EF41 /* CodePushUpdateUtils.m */, 1B23B9131BF9267B000BB2F0 /* RCTConvert+CodePushInstallMode.m */, @@ -470,6 +482,7 @@ isa = PBXGroup; children = ( 95D6DD0EACAC8D095880DFD0 /* BSPatchTests.swift */, + 9645879798B46D35D8D824F8 /* CodePushDiffManifestTests.swift */, E9FA144425AE78B97AD6C870 /* CodePushTests-Bridging-Header.h */, E74D80ECD8DD03C4C0772B7B /* Fixtures */, D23322E9E8EFBE01F1FACFB1 /* CodePushSha256Tests.swift */, @@ -668,6 +681,7 @@ 3221E4762C8ABE1300268379 /* mz_strm_pkcrypt.h in Headers */, 3221E4642C8ABE1300268379 /* mz_strm_split.h in Headers */, 6463C8471EBA0D290095B8CD /* CodePush.h in Headers */, + 4693408DCB9ACB8E02BF69B1 /* CodePushDiffManifest.h in Headers */, 3221E46A2C8ABE1300268379 /* mz_strm.h in Headers */, 3221E4782C8ABE1300268379 /* mz_zip_rw.h in Headers */, 3221E4802C8ABE1400268379 /* mz.h in Headers */, @@ -728,6 +742,7 @@ 3221E4632C8ABE1300268379 /* mz_strm_split.h in Headers */, 3221E4752C8ABE1300268379 /* mz_strm_pkcrypt.h in Headers */, 8482F84E1E24C66300F793DB /* CodePush.h in Headers */, + C11A2DB29D3D814B1A7891DF /* CodePushDiffManifest.h in Headers */, F88664711F4AD1EE0036D01B /* JWTCoding+VersionTwo.h in Headers */, F886646B1F4AD1EE0036D01B /* JWTCoding+ResultTypes.h in Headers */, 3221E4652C8ABE1300268379 /* mz_strm_buf.h in Headers */, @@ -897,6 +912,7 @@ 5498D8F61D21F14100B5EB43 /* CodePushUtils.m in Sources */, 3221E4612C8ABE1300268379 /* mz_zip_rw.c in Sources */, 810D4E6D1B96935000B397E9 /* CodePushPackage.m in Sources */, + CED0A6936F6F8C354302F246 /* CodePushDiffManifest.m in Sources */, 3221E4552C8ABE1300268379 /* mz_strm_pkcrypt.c in Sources */, F88664531F4AD1EE0036D01B /* JWTAlgorithmESBase.m in Sources */, 3221E4532C8ABE1300268379 /* mz_strm_os_posix.c in Sources */, @@ -921,6 +937,7 @@ 6463C8311EBA0CFB0095B8CD /* CodePushErrorUtils.m in Sources */, 3221E46E2C8ABE1300268379 /* mz_crypt.c in Sources */, 6463C8321EBA0CFB0095B8CD /* CodePushPackage.m in Sources */, + CD32522E04D0F0E86DCC7BBE /* CodePushDiffManifest.m in Sources */, 6463C8331EBA0CFB0095B8CD /* CodePushTelemetryManager.m in Sources */, 6463C8341EBA0CFB0095B8CD /* CodePushUpdateUtils.m in Sources */, 3221E45C2C8ABE1300268379 /* mz_strm_zlib.c in Sources */, @@ -943,7 +960,10 @@ buildActionMask = 2147483647; files = ( B5B50F91FAAF80444988E284 /* BSPatchTests.swift in Sources */, + BBC7F97A68E454FB38953FC4 /* CodePushDiffManifestTests.swift in Sources */, 47F66D5AF3C3185E1A3E3B15 /* bspatch_bridge.c in Sources */, + 0E9314F338AC505D2E33C1A3 /* CodePushDiffManifest.m in Sources */, + 3F6A18E4C1B72D094F5E8A67 /* CodePushErrorUtils.m in Sources */, DC983F1C71E0E7131BB343C5 /* libHDiffPatch/HPatch/patch.c in Sources */, A88F11124A2120A8373A8B61 /* bsdiff_wrapper/bspatch_wrapper.c in Sources */, 0ABCB5DEFE01A7A15552A498 /* file_for_patch.c in Sources */, diff --git a/ios/CodePush/CodePushDiffManifest.h b/ios/CodePush/CodePushDiffManifest.h new file mode 100644 index 00000000..ed623f30 --- /dev/null +++ b/ios/CodePush/CodePushDiffManifest.h @@ -0,0 +1,55 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface CodePushPatchedFileEntry : NSObject + +// The only value this client understands at the moment is "bsdiff". +@property (nonatomic, readonly, copy) NSString *algo; +// SHA-256 hex of the file's content in the currently installed package. Checked before patching. +@property (nonatomic, readonly, copy) NSString *baseHash; +// SHA-256 hex the patched output must match. Checked after patching. +@property (nonatomic, readonly, copy) NSString *targetHash; +// Zip-relative path to the patch file, under the reserved patches folder prefix. +@property (nonatomic, readonly, copy) NSString *patch; + +- (instancetype)initWithAlgo:(NSString *)algo + baseHash:(NSString *)baseHash + targetHash:(NSString *)targetHash + patch:(NSString *)patch; + +@end + +@interface CodePushDiffManifest : NSObject + +// No version field, or version 1: original format, file-by-file patching only. +// Version 2: adds support for binary diff patching. +@property (nonatomic, readonly, assign) NSInteger version; +// Relative paths, from the old package, to delete rather than carry over into the new one. +@property (nonatomic, readonly, copy) NSArray *deletedFiles; +// Key: file's relative path in the package being installed. +@property (nonatomic, readonly, copy) NSDictionary *patchedFiles; + +- (instancetype)initWithVersion:(NSInteger)version + deletedFiles:(NSArray *)deletedFiles + patchedFiles:(NSDictionary *)patchedFiles; + +// Parses a diff manifest from its already-deserialized JSON representation. +// Returns nil and sets *error if a required field is missing or malformed. ++ (nullable instancetype)manifestFromJSON:(NSDictionary *)json error:(NSError **)error NS_SWIFT_NAME(init(json:)); + +// Turns a relative path from a diff manifest into an absolute path under +// `folder`. Returns nil and sets *error if the path is malformed, `folder` +// is not a usable directory, or the path would resolve outside `folder`. +// +// Every path in a manifest is untrusted: the manifest and the files it refers +// to come from the downloaded update, which is unpacked before anything +// verifies it. ++ (nullable NSString *)resolvePath:(NSString *)relativePath + withinFolder:(NSString *)folder + error:(NSError * _Nullable * _Nullable)error + NS_SWIFT_NAME(resolvePath(_:withinFolder:)); + +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/CodePush/CodePushDiffManifest.m b/ios/CodePush/CodePushDiffManifest.m new file mode 100644 index 00000000..1488e6d6 --- /dev/null +++ b/ios/CodePush/CodePushDiffManifest.m @@ -0,0 +1,239 @@ +#import "CodePushDiffManifest.h" + +#import "CodePushErrorUtils.h" + +#import + +static NSError *missingFieldError(NSString *fieldName, NSString *context) +{ + return [CodePushErrorUtils errorWithMessage:[NSString stringWithFormat:@"Diff manifest %@ is missing required field \"%@\"", context, fieldName]]; +} + +static NSError *wrongTypedFieldError(NSString *fieldName, NSString *context, id value) +{ + return [CodePushErrorUtils errorWithMessage:[NSString stringWithFormat:@"Diff manifest %@ field \"%@\" must be a string, but is %@", context, fieldName, NSStringFromClass([value class])]]; +} + +static BOOL isAbsent(id value) +{ + return value == nil || [value isKindOfClass:[NSNull class]]; +} + +// Resolves every symlink in `path`. Returns nil if `path` does not exist or +// cannot be read. +static NSString *canonicalPathOfExistingItem(NSString *path) +{ + char pathBuffer[PATH_MAX]; + if (![path getFileSystemRepresentation:pathBuffer maxLength:sizeof(pathBuffer)]) { + return nil; + } + + char resolvedBuffer[PATH_MAX]; + if (realpath(pathBuffer, resolvedBuffer) == NULL) { + return nil; + } + return [[NSFileManager defaultManager] stringWithFileSystemRepresentation:resolvedBuffer length:strlen(resolvedBuffer)]; +} + +// Canonicalize the deepest path component that does exist, then re-append +// the components below it. Returns nil if one of those components is a dangling +// symlink: it would survive canonicalization as its own path, and a write to it +// would still follow the link out of the folder. +static NSString *canonicalPathAllowingMissingComponents(NSString *path) +{ + NSMutableArray *missingComponents = [NSMutableArray array]; + NSString *existingAncestor = path; + NSString *canonicalPath = nil; + + while ((canonicalPath = canonicalPathOfExistingItem(existingAncestor)) == nil) { + NSString *parent = [existingAncestor stringByDeletingLastPathComponent]; + if (parent.length == 0 || [parent isEqualToString:existingAncestor]) { + return nil; + } + [missingComponents insertObject:existingAncestor.lastPathComponent atIndex:0]; + existingAncestor = parent; + } + + for (NSString *component in missingComponents) { + // "." refers to the same directory, so appending it verbatim would + // leave the result unnormalized without changing what it points to. + if ([component isEqualToString:@"."]) { + continue; + } + canonicalPath = [canonicalPath stringByAppendingPathComponent:component]; + + char componentBuffer[PATH_MAX]; + if (![canonicalPath getFileSystemRepresentation:componentBuffer maxLength:sizeof(componentBuffer)]) { + return nil; + } + + struct stat fileInfo; + if (lstat(componentBuffer, &fileInfo) == 0 && S_ISLNK(fileInfo.st_mode)) { + return nil; + } + } + return canonicalPath; +} + +@implementation CodePushPatchedFileEntry + +- (instancetype)initWithAlgo:(NSString *)algo + baseHash:(NSString *)baseHash + targetHash:(NSString *)targetHash + patch:(NSString *)patch +{ + self = [super init]; + if (self) { + _algo = [algo copy]; + _baseHash = [baseHash copy]; + _targetHash = [targetHash copy]; + _patch = [patch copy]; + } + return self; +} + +@end + +@implementation CodePushDiffManifest + +- (instancetype)initWithVersion:(NSInteger)version + deletedFiles:(NSArray *)deletedFiles + patchedFiles:(NSDictionary *)patchedFiles +{ + self = [super init]; + if (self) { + _version = version; + _deletedFiles = [deletedFiles copy]; + _patchedFiles = [patchedFiles copy]; + } + return self; +} + ++ (nullable instancetype)manifestFromJSON:(NSDictionary *)json error:(NSError **)error +{ + if (![json isKindOfClass:[NSDictionary class]]) { + if (error) *error = [CodePushErrorUtils errorWithMessage:[NSString stringWithFormat:@"Diff manifest must be a JSON object, but is %@", NSStringFromClass([json class])]]; + return nil; + } + + // A version we cannot read is a hard failure: silently treating it as 1 + // would skip every patch and install the old bytes under the new hash. + id versionValue = json[@"version"]; + NSInteger version = 1; + if (!isAbsent(versionValue)) { + BOOL isBoolean = versionValue == (id)kCFBooleanTrue || versionValue == (id)kCFBooleanFalse; + double versionDouble = [versionValue isKindOfClass:[NSNumber class]] ? [versionValue doubleValue] : 0; + if (![versionValue isKindOfClass:[NSNumber class]] || isBoolean || versionDouble != trunc(versionDouble)) { + if (error) *error = [CodePushErrorUtils errorWithMessage:[NSString stringWithFormat:@"Diff manifest field \"version\" must be an integer, but is \"%@\"", versionValue]]; + return nil; + } + version = [versionValue integerValue]; + } + + id deletedFilesJSON = json[@"deletedFiles"]; + NSMutableArray *deletedFiles = [NSMutableArray array]; + if (!isAbsent(deletedFilesJSON)) { + if (![deletedFilesJSON isKindOfClass:[NSArray class]]) { + if (error) *error = [CodePushErrorUtils errorWithMessage:[NSString stringWithFormat:@"Diff manifest field \"deletedFiles\" must be an array, but is %@", NSStringFromClass([deletedFilesJSON class])]]; + return nil; + } + for (id deletedFileName in (NSArray *)deletedFilesJSON) { + if (![deletedFileName isKindOfClass:[NSString class]]) { + if (error) *error = [CodePushErrorUtils errorWithMessage:[NSString stringWithFormat:@"Diff manifest field \"deletedFiles\" must hold strings, but holds \"%@\"", deletedFileName]]; + return nil; + } + [deletedFiles addObject:deletedFileName]; + } + } + + id patchedFilesJSON = json[@"patchedFiles"]; + NSMutableDictionary *patchedFiles = [NSMutableDictionary dictionary]; + if (!isAbsent(patchedFilesJSON)) { + if (![patchedFilesJSON isKindOfClass:[NSDictionary class]]) { + if (error) *error = [CodePushErrorUtils errorWithMessage:[NSString stringWithFormat:@"Diff manifest field \"patchedFiles\" must be an object, but is %@", NSStringFromClass([patchedFilesJSON class])]]; + return nil; + } + for (NSString *relativePath in (NSDictionary *)patchedFilesJSON) { + NSString *context = [NSString stringWithFormat:@"patchedFiles[\"%@\"]", relativePath]; + + id entryJSON = patchedFilesJSON[relativePath]; + if (![entryJSON isKindOfClass:[NSDictionary class]]) { + if (error) *error = [CodePushErrorUtils errorWithMessage:[NSString stringWithFormat:@"Diff manifest %@ must be an object, but is %@", context, NSStringFromClass([entryJSON class])]]; + return nil; + } + + for (NSString *fieldName in @[@"algo", @"baseHash", @"targetHash", @"patch"]) { + id fieldValue = entryJSON[fieldName]; + if (![fieldValue isKindOfClass:[NSString class]]) { + if (error) *error = isAbsent(fieldValue) ? missingFieldError(fieldName, context) : wrongTypedFieldError(fieldName, context, fieldValue); + return nil; + } + } + + patchedFiles[relativePath] = [[CodePushPatchedFileEntry alloc] initWithAlgo:entryJSON[@"algo"] + baseHash:entryJSON[@"baseHash"] + targetHash:entryJSON[@"targetHash"] + patch:entryJSON[@"patch"]]; + } + } + + // Only version 2 defines file patching. + // A manifest of any other version that lists patched files is malformed, + // and applying none of them would leave the old bytes behind. + if (version != 2 && patchedFiles.count > 0) { + if (error) *error = [CodePushErrorUtils errorWithMessage:[NSString stringWithFormat:@"Diff manifest declares version %ld but lists %lu patchedFiles, which require version 2", (long)version, (unsigned long)patchedFiles.count]]; + return nil; + } + + return [[CodePushDiffManifest alloc] initWithVersion:version + deletedFiles:deletedFiles + patchedFiles:patchedFiles]; +} + ++ (nullable NSString *)resolvePath:(NSString *)relativePath + withinFolder:(NSString *)folder + error:(NSError **)error +{ + if (relativePath.length == 0) { + if (error) *error = [CodePushErrorUtils errorWithMessage:@"path is empty"]; + return nil; + } + if (relativePath.isAbsolutePath) { + if (error) *error = [CodePushErrorUtils errorWithMessage:@"path escapes expected directory"]; + return nil; + } + // A path the file system cannot represent - an embedded NUL, or one longer + // than PATH_MAX - never reaches a syscall from here. + char pathBuffer[PATH_MAX]; + if (![relativePath getFileSystemRepresentation:pathBuffer maxLength:sizeof(pathBuffer)]) { + if (error) *error = [CodePushErrorUtils errorWithMessage:@"path cannot be represented in the file system"]; + return nil; + } + for (NSString *component in relativePath.pathComponents) { + if ([component isEqualToString:@".."]) { + if (error) *error = [CodePushErrorUtils errorWithMessage:@"path escapes expected directory"]; + return nil; + } + } + + NSString *canonicalFolder = canonicalPathOfExistingItem(folder); + if (canonicalFolder == nil) { + if (error) *error = [CodePushErrorUtils errorWithMessage:@"base folder does not exist or is not accessible"]; + return nil; + } + + NSString *resolved = canonicalPathAllowingMissingComponents([canonicalFolder stringByAppendingPathComponent:relativePath]); + if (resolved == nil) { + if (error) *error = [CodePushErrorUtils errorWithMessage:@"path escapes expected directory"]; + return nil; + } + // Callers treat the result as a file inside the folder, so the folder + // itself (e.g. from "." or a symlink resolving back to it) must not pass. + if (![resolved hasPrefix:[canonicalFolder stringByAppendingString:@"/"]]) { + if (error) *error = [CodePushErrorUtils errorWithMessage:@"path escapes expected directory"]; + return nil; + } + return resolved; +} + +@end diff --git a/ios/CodePush/CodePushPackage.m b/ios/CodePush/CodePushPackage.m index 325ec20b..eb5b331b 100644 --- a/ios/CodePush/CodePushPackage.m +++ b/ios/CodePush/CodePushPackage.m @@ -1,4 +1,5 @@ #import "CodePush.h" +#import "CodePushDiffManifest.h" #import "CodePushErrorUtils.h" #if __has_include() #import @@ -172,9 +173,29 @@ + (void)downloadPackage:(NSDictionary *)updatePackage NSDictionary *manifestJSON = [NSJSONSerialization JSONObjectWithData:data options:kNilOptions error:&error]; - NSArray *deletedFiles = manifestJSON[@"deletedFiles"]; - for (NSString *deletedFileName in deletedFiles) { - NSString *absoluteDeletedFilePath = [newUpdateFolderPath stringByAppendingPathComponent:deletedFileName]; + if (error) { + failCallback(error); + return; + } + + CodePushDiffManifest *diffManifest = [CodePushDiffManifest manifestFromJSON:manifestJSON error:&error]; + if (error) { + failCallback(error); + return; + } + + for (NSString *deletedFileName in diffManifest.deletedFiles) { + // deletedFiles comes from the downloaded update, so it is untrusted: an + // entry that does not name a file inside the new package folder, such as + // "../../etc/passwd", fails the install. + NSError *resolveError = nil; + NSString *absoluteDeletedFilePath = [CodePushDiffManifest resolvePath:deletedFileName + withinFolder:newUpdateFolderPath + error:&resolveError]; + if (absoluteDeletedFilePath == nil) { + failCallback([CodePushErrorUtils errorWithMessage:[NSString stringWithFormat:@"Diff manifest deletedFiles entry \"%@\": %@", deletedFileName, resolveError.localizedDescription]]); + return; + } if ([[NSFileManager defaultManager] fileExistsAtPath:absoluteDeletedFilePath]) { [[NSFileManager defaultManager] removeItemAtPath:absoluteDeletedFilePath error:&error]; diff --git a/ios/CodePushTests/CodePushDiffManifestTests.swift b/ios/CodePushTests/CodePushDiffManifestTests.swift new file mode 100644 index 00000000..1e3820ce --- /dev/null +++ b/ios/CodePushTests/CodePushDiffManifestTests.swift @@ -0,0 +1,290 @@ +import XCTest + +final class CodePushDiffManifestTests: XCTestCase { + + func testManifest_missingVersionField_defaultsToOne() throws { + let manifest = try CodePushDiffManifest(json: [:]) + + XCTAssertEqual(manifest.version, 1) + XCTAssertEqual(manifest.deletedFiles, []) + XCTAssertEqual(manifest.patchedFiles.count, 0) + } + + func testManifest_deletedFilesAndPatchedFiles_areParsed() throws { + let json: [AnyHashable: Any] = [ + "version": 2, + "deletedFiles": ["assets/old.png"], + "patchedFiles": [ + "main.jsbundle": [ + "algo": "bsdiff", + "baseHash": "aaaa", + "targetHash": "bbbb", + "patch": "__hcp_patches/main.jsbundle.bsdiff", + ] + ], + ] + + let manifest = try CodePushDiffManifest(json: json) + + XCTAssertEqual(manifest.version, 2) + XCTAssertEqual(manifest.deletedFiles, ["assets/old.png"]) + + let entry = manifest.patchedFiles["main.jsbundle"] + XCTAssertNotNil(entry) + XCTAssertEqual(entry?.algo, "bsdiff") + XCTAssertEqual(entry?.baseHash, "aaaa") + XCTAssertEqual(entry?.targetHash, "bbbb") + XCTAssertEqual(entry?.patch, "__hcp_patches/main.jsbundle.bsdiff") + } + + func testManifest_patchedFilesEntryMissingRequiredField_throws() { + let json: [AnyHashable: Any] = [ + "version": 2, + "patchedFiles": [ + "main.jsbundle": [ + "algo": "bsdiff", + "baseHash": "aaaa", + // targetHash is missing. + "patch": "__hcp_patches/main.jsbundle.bsdiff", + ] + ], + ] + + XCTAssertThrowsError(try CodePushDiffManifest(json: json)) { error in + // The message has to name the one field that is wrong, not the set + // of fields an entry needs. + XCTAssertTrue(error.localizedDescription.contains("targetHash"), error.localizedDescription) + XCTAssertFalse(error.localizedDescription.contains("baseHash"), error.localizedDescription) + } + } + + func testManifest_patchedFilesEntryWrongTypedField_throws() { + let json: [AnyHashable: Any] = [ + "version": 2, + "patchedFiles": [ + "main.jsbundle": [ + "algo": "bsdiff", + "baseHash": "aaaa", + "targetHash": 42, + "patch": "__hcp_patches/main.jsbundle.bsdiff", + ] + ], + ] + + XCTAssertThrowsError(try CodePushDiffManifest(json: json)) { error in + // A present-but-wrong-typed field is a different defect than an + // absent one, and the message must say so rather than claiming + // the field is missing. + XCTAssertTrue(error.localizedDescription.contains("must be a string"), error.localizedDescription) + XCTAssertFalse(error.localizedDescription.contains("missing required field"), error.localizedDescription) + } + } + + // A version that is not an integer must not fall back to 1 or get + // truncated to a neighboring version: either would skip every patch of a + // version 2 manifest and install the old bytes. + func testManifest_invalidVersionValues_throw() { + XCTAssertThrowsError(try CodePushDiffManifest(json: ["version": "2"])) + XCTAssertThrowsError(try CodePushDiffManifest(json: ["version": 2.7])) + XCTAssertThrowsError(try CodePushDiffManifest(json: ["version": true])) + + let jsonWithPatchedFiles: [AnyHashable: Any] = [ + "version": 2.7, + "patchedFiles": [ + "main.jsbundle": [ + "algo": "bsdiff", + "baseHash": "aaaa", + "targetHash": "bbbb", + "patch": "__hcp_patches/main.jsbundle.bsdiff", + ] + ], + ] + XCTAssertThrowsError(try CodePushDiffManifest(json: jsonWithPatchedFiles)) + } + + func testManifest_patchedFilesWithoutVersionTwo_throws() { + let json: [AnyHashable: Any] = [ + "version": 1, + "patchedFiles": [ + "main.jsbundle": [ + "algo": "bsdiff", + "baseHash": "aaaa", + "targetHash": "bbbb", + "patch": "__hcp_patches/main.jsbundle.bsdiff", + ] + ], + ] + + XCTAssertThrowsError(try CodePushDiffManifest(json: json)) + } + + // A container of the wrong type must not parse as an empty one: that would + // skip the deletions or patches it describes and install the old bytes + // under the new package hash. + func testManifest_wrongTypedContainers_throw() { + XCTAssertThrowsError(try CodePushDiffManifest(json: ["deletedFiles": "assets/old.png"])) + XCTAssertThrowsError(try CodePushDiffManifest(json: ["deletedFiles": [42]])) + XCTAssertThrowsError(try CodePushDiffManifest(json: ["version": 2, "patchedFiles": [["algo": "bsdiff"]]])) + XCTAssertThrowsError(try CodePushDiffManifest(json: ["version": 2, "patchedFiles": ["main.jsbundle": "bsdiff"]])) + } + + // Matches Android, which rejects any version other than 2 that lists patches. + func testManifest_patchedFilesOnUnknownVersion_throws() { + let json: [AnyHashable: Any] = [ + "version": 3, + "patchedFiles": [ + "main.jsbundle": [ + "algo": "bsdiff", + "baseHash": "aaaa", + "targetHash": "bbbb", + "patch": "__hcp_patches/main.jsbundle.bsdiff", + ] + ], + ] + + XCTAssertThrowsError(try CodePushDiffManifest(json: json)) + } + + // MARK: - resolvePath(_:withinFolder:) + + private func makeFolder() throws -> URL { + let folder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true) + addTeardownBlock { try? FileManager.default.removeItem(at: folder) } + return folder + } + + private func assertRejected(_ path: String, withinFolder folder: String, messageContains needle: String, file: StaticString = #filePath, line: UInt = #line) { + XCTAssertThrowsError(try CodePushDiffManifest.resolvePath(path, withinFolder: folder), file: file, line: line) { error in + let message = (error as NSError).localizedDescription + XCTAssertTrue(message.contains(needle), message, file: file, line: line) + } + } + + func testResolvePath_pathThatDoesNotExistYet_resolvesUnderFolder() throws { + let folder = try makeFolder() + + let resolved = try CodePushDiffManifest.resolvePath("assets/new.png", withinFolder: folder.path) + + // The folder itself is compared canonically: on the simulator the + // temporary directory is reached through a symlinked prefix. + XCTAssertEqual(resolved, folder.resolvingSymlinksInPath().appendingPathComponent("assets/new.png").path) + } + + // "." components in the not-yet-existing part of the path must not survive + // into the result verbatim, or callers comparing/logging the resolved path + // would see a subtly different string than a fully normalized one. + func testResolvePath_dotComponentsInMissingSuffix_areNormalizedAway() throws { + let folder = try makeFolder() + + let resolved = try CodePushDiffManifest.resolvePath("assets/./sub/./new.png", withinFolder: folder.path) + + XCTAssertEqual(resolved, folder.resolvingSymlinksInPath().appendingPathComponent("assets/sub/new.png").path) + } + + func testResolvePath_traversalAndAbsolutePaths_areRejected() throws { + let folder = try makeFolder() + + assertRejected("../escaped.txt", withinFolder: folder.path, messageContains: "escapes") + assertRejected("assets/../../escaped.txt", withinFolder: folder.path, messageContains: "escapes") + assertRejected("/etc/passwd", withinFolder: folder.path, messageContains: "escapes") + } + + // An empty relativePath is a malformed manifest entry, not an attempted + // escape - the message must say so, or whoever reads the log goes looking + // for an attack that a bad manifest doesn't represent. + func testResolvePath_emptyPath_isRejectedAsMalformed() throws { + let folder = try makeFolder() + + assertRejected("", withinFolder: folder.path, messageContains: "empty") + } + + // An update zip can contain symlink entries, and they are extracted before + // anything verifies the update's contents. + func testResolvePath_pathThroughSymlinkOutOfFolder_isRejected() throws { + let folder = try makeFolder() + let outsideFolder = try makeFolder() + try FileManager.default.createSymbolicLink( + at: folder.appendingPathComponent("escape"), + withDestinationURL: outsideFolder) + + assertRejected("escape/evil.txt", withinFolder: folder.path, messageContains: "escapes") + } + + func testResolvePath_danglingSymlinkLeaf_isRejected() throws { + let folder = try makeFolder() + let outsideFolder = try makeFolder() + // The link target does not exist, so the link itself is all that can be + // resolved - and writing to it would still land outside the folder. + try FileManager.default.createSymbolicLink( + at: folder.appendingPathComponent("evil.txt"), + withDestinationURL: outsideFolder.appendingPathComponent("evil.txt")) + + assertRejected("evil.txt", withinFolder: folder.path, messageContains: "escapes") + } + + // Unlike the leaf case above, the dangling link here is not the last + // component, which exercises the multi-component branch of + // canonicalPathAllowingMissingComponents rather than the single-component one. + func testResolvePath_danglingSymlinkIntermediateComponent_isRejected() throws { + let folder = try makeFolder() + let outsideFolder = try makeFolder() + try FileManager.default.createSymbolicLink( + at: folder.appendingPathComponent("ghost"), + withDestinationURL: outsideFolder.appendingPathComponent("missing")) + + assertRejected("ghost/f.txt", withinFolder: folder.path, messageContains: "escapes") + } + + // A symlink whose target stays inside the folder must not be rejected - + // resolvePath only defends against escaping the folder, not against + // symlinks in general - and the result must be the link's target, not the + // link itself. + func testResolvePath_symlinkStayingInsideFolder_isAccepted() throws { + let folder = try makeFolder() + let realSubfolder = folder.appendingPathComponent("real") + try FileManager.default.createDirectory(at: realSubfolder, withIntermediateDirectories: true) + try FileManager.default.createSymbolicLink( + at: folder.appendingPathComponent("inner"), + withDestinationURL: realSubfolder) + + let resolved = try CodePushDiffManifest.resolvePath("inner/f.txt", withinFolder: folder.path) + + XCTAssertEqual(resolved, realSubfolder.resolvingSymlinksInPath().appendingPathComponent("f.txt").path) + } + + // A JSON string can hold a NUL, which no file system path can represent - + // and, like the empty-path case above, this is a malformed manifest, not + // an escape attempt. + func testResolvePath_pathThatIsNotRepresentable_isRejected() throws { + let folder = try makeFolder() + + assertRejected("a\0b", withinFolder: folder.path, messageContains: "cannot be represented") + assertRejected(String(repeating: "a", count: 8192), withinFolder: folder.path, messageContains: "cannot be represented") + } + + // A missing base folder is an unusable-environment problem, not a + // manifest attack - conflating the two sends whoever reads the log + // looking for an attacker instead of a missing directory. + func testResolvePath_missingFolder_isRejected() { + let missingFolder = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + + assertRejected("main.jsbundle", withinFolder: missingFolder.path, messageContains: "does not exist") + } + + func testResolvePath_pathResolvingToFolderItself_isRejected() throws { + let folder = try makeFolder() + + assertRejected(".", withinFolder: folder.path, messageContains: "escapes") + assertRejected("./", withinFolder: folder.path, messageContains: "escapes") + } + + func testResolvePath_symlinkResolvingToFolderItself_isRejected() throws { + let folder = try makeFolder() + try FileManager.default.createSymbolicLink( + at: folder.appendingPathComponent("self"), + withDestinationURL: folder) + + assertRejected("self", withinFolder: folder.path, messageContains: "escapes") + } +} diff --git a/ios/CodePushTests/CodePushTests-Bridging-Header.h b/ios/CodePushTests/CodePushTests-Bridging-Header.h index c8ac5747..4ccab848 100644 --- a/ios/CodePushTests/CodePushTests-Bridging-Header.h +++ b/ios/CodePushTests/CodePushTests-Bridging-Header.h @@ -1,2 +1,3 @@ #import "bspatch_bridge.h" #import "CodePushSha256.h" +#import "CodePushDiffManifest.h"