Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
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
4 changes: 4 additions & 0 deletions FirebaseMessaging/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
delegates. (#15987)
- [changed] **Breaking Change**: Scene delegates take priority over app delegates for
automatic deep link routing. (#15987)
- [fixed] Fixed an issue where deep links were not correctly routed in apps utilizing scene
delegates. (#15987)
- [changed] Enforce `NSSecureCoding` when archiving and unarchiving registration
tokens in the keychain. (#16511)

# 12.19.0
- [fixed] Fix an issue where `messaging:didReceiveRegistrationToken:` was no
Expand Down
1 change: 1 addition & 0 deletions FirebaseMessaging/Sources/FIRMessagingCode.h
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,7 @@ typedef NS_ENUM(NSInteger, FIRMessagingMessageCode) {
kFIRMessagingMessageCodeTokenStore000 = 35000,
kFIRMessagingMessageCodeTokenStore001 = 35001,
kFIRMessagingMessageCodeTokenStoreExceptionUnarchivingTokenInfo = 35015,
kFIRMessagingMessageCodeTokenStoreErrorArchivingTokenInfo = 35016,

// DO NOT USE 16000, 18004

Expand Down
57 changes: 35 additions & 22 deletions FirebaseMessaging/Sources/Token/FIRMessagingTokenInfo.m
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,6 @@ + (BOOL)supportsSecureCoding {
}

- (nullable instancetype)initWithCoder:(NSCoder *)aDecoder {
BOOL needsMigration = NO;
// These value cannot be nil

NSString *authorizedEntity = [aDecoder decodeObjectOfClass:[NSString class]
Expand All @@ -187,29 +186,43 @@ - (nullable instancetype)initWithCoder:(NSCoder *)aDecoder {
NSString *firebaseAppID = [aDecoder decodeObjectOfClass:[NSString class]
forKey:kFIRInstanceIDFirebaseAppIDKey];

NSSet *classes = [[NSSet alloc] initWithArray:@[ FIRMessagingAPNSInfo.class ]];
FIRMessagingAPNSInfo *rawAPNSInfo = [aDecoder decodeObjectOfClasses:classes
forKey:kFIRInstanceIDAPNSInfoKey];
if (rawAPNSInfo && ![rawAPNSInfo isKindOfClass:[FIRMessagingAPNSInfo class]]) {
// If the decoder fails to decode a FIRMessagingAPNSInfo, check if this was archived by a
// FirebaseMessaging 10.18.0 or earlier.
// TODO(#12246) This block may be replaced with `rawAPNSInfo = nil` once we're confident all
// users have upgraded to at least 10.19.0. Perhaps, after privacy manifests have been required
// for awhile?
@try {
NSKeyedUnarchiver *unarchiver =
[[NSKeyedUnarchiver alloc] initForReadingFromData:(NSData *)rawAPNSInfo error:nil];
unarchiver.requiresSecureCoding = NO;
[unarchiver setClass:[FIRMessagingAPNSInfo class] forClassName:@"FIRInstanceIDAPNSInfo"];
rawAPNSInfo = [unarchiver decodeObjectForKey:NSKeyedArchiveRootObjectKey];
[unarchiver finishDecoding];
needsMigration = YES;
} @catch (NSException *exception) {
// `apns_info` has two on-disk shapes. FirebaseMessaging 10.19.0 and later encode APNSInfo
// directly. 10.18.0 and earlier wrote a nested NSKeyedArchiver blob instead, which materializes
// as NSMutableData; secure coding requires naming that subclass explicitly rather than relying
// on NSData covering it.
NSSet *APNSInfoClasses = [[NSSet alloc]
initWithArray:@[ FIRMessagingAPNSInfo.class, NSData.class, NSMutableData.class ]];
id decodedAPNSInfo = [aDecoder decodeObjectOfClasses:APNSInfoClasses
forKey:kFIRInstanceIDAPNSInfoKey];
Comment thread
ncooke3 marked this conversation as resolved.

FIRMessagingAPNSInfo *rawAPNSInfo = nil;
BOOL needsMigration = NO;
if ([decodedAPNSInfo isKindOfClass:[FIRMessagingAPNSInfo class]]) {
rawAPNSInfo = decodedAPNSInfo;
} else if ([decodedAPNSInfo isKindOfClass:[NSData class]]) {
// A 10.18.0-or-earlier record. The nested blob names the class `FIRInstanceIDAPNSInfo`, so
// map it the same way FIRMessagingTokenStore maps `FIRInstanceIDTokenInfo` on the outer
// archive. Secure coding stays on: the blob was written insecurely, but FIRMessagingAPNSInfo
// conforms to NSSecureCoding, so it can still be read under the strict decoder.
NSError *APNSInfoError = nil;
NSKeyedUnarchiver *APNSInfoUnarchiver =
[[NSKeyedUnarchiver alloc] initForReadingFromData:decodedAPNSInfo error:&APNSInfoError];
if (APNSInfoUnarchiver) {
APNSInfoUnarchiver.requiresSecureCoding = YES;
[APNSInfoUnarchiver setClass:[FIRMessagingAPNSInfo class]
forClassName:@"FIRInstanceIDAPNSInfo"];
rawAPNSInfo = [APNSInfoUnarchiver decodeObjectOfClass:[FIRMessagingAPNSInfo class]
forKey:NSKeyedArchiveRootObjectKey];
[APNSInfoUnarchiver finishDecoding];
}
if (!rawAPNSInfo) {
FIRMessagingLoggerInfo(kFIRMessagingMessageCodeTokenInfoBadAPNSInfo,
@"Could not parse raw APNS Info while parsing archived token info.");
rawAPNSInfo = nil;
} @finally {
@"Could not parse APNS info archived by FirebaseMessaging 10.18.0 or "
@"earlier; error: %@",
APNSInfoError ?: APNSInfoUnarchiver.error);
}
Comment thread
ncooke3 marked this conversation as resolved.
// Either way the record is in the retired format, so have the store rewrite it.
needsMigration = YES;
}

NSDate *cacheTime = [aDecoder decodeObjectOfClass:[NSDate class]
Expand Down
58 changes: 43 additions & 15 deletions FirebaseMessaging/Sources/Token/FIRMessagingTokenStore.m
Original file line number Diff line number Diff line change
Expand Up @@ -100,13 +100,26 @@ + (nullable FIRMessagingTokenInfo *)tokenInfoFromKeychainItem:(NSData *)item {
// Check if it is saved as an archived FIRMessagingTokenInfo, otherwise return nil.
FIRMessagingTokenInfo *tokenInfo = nil;
if (item) {
NSError *error = nil;
@try {
NSKeyedUnarchiver *unarchiver = [[NSKeyedUnarchiver alloc] initForReadingFromData:item
error:nil];
unarchiver.requiresSecureCoding = NO;
[unarchiver setClass:[FIRMessagingTokenInfo class] forClassName:@"FIRInstanceIDTokenInfo"];
tokenInfo = [unarchiver decodeObjectForKey:NSKeyedArchiveRootObjectKey];
[unarchiver finishDecoding];
error:&error];
if (unarchiver) {
unarchiver.requiresSecureCoding = YES;
[unarchiver setClass:[FIRMessagingTokenInfo class] forClassName:@"FIRInstanceIDTokenInfo"];
tokenInfo = [unarchiver decodeObjectOfClass:[FIRMessagingTokenInfo class]
forKey:NSKeyedArchiveRootObjectKey];
if (!tokenInfo && unarchiver.error) {
FIRMessagingLoggerDebug(kFIRMessagingMessageCodeTokenStoreExceptionUnarchivingTokenInfo,
@"Failed to decode token info from Keychain item; error: %@",
unarchiver.error);
}
[unarchiver finishDecoding];
Comment thread
ncooke3 marked this conversation as resolved.
} else {
FIRMessagingLoggerDebug(kFIRMessagingMessageCodeTokenStoreExceptionUnarchivingTokenInfo,
@"Unable to parse token info from Keychain item; error: %@", error);
tokenInfo = nil;
}
} @catch (NSException *exception) {
Comment thread
ncooke3 marked this conversation as resolved.
FIRMessagingLoggerDebug(kFIRMessagingMessageCodeTokenStoreExceptionUnarchivingTokenInfo,
@"Unable to parse token info from Keychain item; item was in an "
Expand All @@ -127,12 +140,23 @@ - (void)saveTokenInfo:(FIRMessagingTokenInfo *)tokenInfo
tokenInfo.cacheTime = [NSDate date];
// Always write to the Keychain, so that the cacheTime is up-to-date.
NSData *tokenInfoData;
// TODO(chliangGoogle: Use the new API and secureCoding protocol.
[NSKeyedArchiver setClassName:@"FIRInstanceIDTokenInfo" forClass:[FIRMessagingTokenInfo class]];
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
tokenInfoData = [NSKeyedArchiver archivedDataWithRootObject:tokenInfo];
#pragma clang diagnostic pop
NSError *error = nil;
tokenInfoData = [NSKeyedArchiver archivedDataWithRootObject:tokenInfo
requiringSecureCoding:YES
error:&error];
if (!tokenInfoData) {
FIRMessagingLoggerDebug(kFIRMessagingMessageCodeTokenStoreErrorArchivingTokenInfo,
@"Failed to securely archive token info: %@", error);
if (handler) {
// The keychain write below delivers its handler on the main queue. Match that here so a
// caller sees one calling context regardless of which step failed.
dispatch_async(dispatch_get_main_queue(), ^{
handler(error);
});
}
return;
}
NSString *account = FIRMessagingAppIdentifier();
NSString *service = [[self class] serviceKeyForAuthorizedEntity:tokenInfo.authorizedEntity
scope:tokenInfo.scope];
Expand All @@ -141,14 +165,18 @@ - (void)saveTokenInfo:(FIRMessagingTokenInfo *)tokenInfo

- (void)saveTokenInfoInCache:(FIRMessagingTokenInfo *)tokenInfo {
tokenInfo.cacheTime = [NSDate date];
// TODO(chliangGoogle): Use the new API and secureCoding protocol.
// Always write to the Keychain, so that the cacheTime is up-to-date.
NSData *tokenInfoData;
[NSKeyedArchiver setClassName:@"FIRInstanceIDTokenInfo" forClass:[FIRMessagingTokenInfo class]];
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
tokenInfoData = [NSKeyedArchiver archivedDataWithRootObject:tokenInfo];
#pragma clang diagnostic pop
NSError *error = nil;
tokenInfoData = [NSKeyedArchiver archivedDataWithRootObject:tokenInfo
requiringSecureCoding:YES
error:&error];
if (!tokenInfoData) {
FIRMessagingLoggerDebug(kFIRMessagingMessageCodeTokenStoreErrorArchivingTokenInfo,
@"Failed to securely archive token info for cache: %@", error);
return;
}
NSString *account = FIRMessagingAppIdentifier();
NSString *service = [[self class] serviceKeyForAuthorizedEntity:tokenInfo.authorizedEntity
scope:tokenInfo.scope];
Expand Down
127 changes: 127 additions & 0 deletions FirebaseMessaging/Tests/UnitTests/FIRMessagingLegacyArchiveFixtures.h
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
/*
* Copyright 2026 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

#import <Foundation/Foundation.h>

#import "FirebaseMessaging/Sources/Token/FIRMessagingTokenInfo.h"

NS_ASSUME_NONNULL_BEGIN

/// Flavours of a corrupt nested `apns_info` blob, ordered by how far into `NSKeyedUnarchiver`
/// they get before failing.
typedef NS_ENUM(NSInteger, FIRMessagingCorruptPayloadKind) {
/// Not a property list at all.
FIRMessagingCorruptPayloadKindNotAPropertyList,
/// A valid property list that is not a keyed archive.
FIRMessagingCorruptPayloadKindPropertyListButNotAnArchive,
/// A real keyed archive, truncated partway through.
FIRMessagingCorruptPayloadKindTruncatedArchive,
};

/// Reproduces the `<= 10.18.0` *encoding* format, in which `apns_info` was written as a nested
/// `NSKeyedArchiver` blob rather than as a directly encoded object.
///
/// Verified line-by-line against `-[FIRMessagingTokenInfo encodeWithCoder:]` at tag `10.18.0`:
///
/// ```
/// git show 10.18.0:FirebaseMessaging/Sources/Token/FIRMessagingTokenInfo.m
/// ```
///
/// Note that `token_type` is deliberately absent: that key was introduced after 10.18.0.
///
/// > IMMUTABLE FIXTURE: do not "modernize" this encoder. It exists to emit bytes byte-for-byte
/// > equivalent to what 10.18.0 wrote. If a test using it fails, fix the production decoder
/// > rather than this mock.
@interface FIRMessagingTokenInfo_Legacy10_18 : FIRMessagingTokenInfo
@end

/// Reproduces the `12.x` *decoding* logic, used to prove that an older SDK can still read
/// archives written by the current one.
///
/// Verified line-by-line against `-[FIRMessagingTokenInfo initWithCoder:]` at tag `12.19.0`:
///
/// ```
/// git show 12.19.0:FirebaseMessaging/Sources/Token/FIRMessagingTokenInfo.m
/// ```
///
/// The only intentional deviations, neither of which affects decoded output:
/// 1. Values are assigned with KVC because a subclass cannot reach the parent's ivars.
/// 2. The `FIRMessagingLoggerInfo` call in the `@catch` is omitted.
///
/// > IMMUTABLE FIXTURE: do not "modernize" this decoder, and in particular do not delete the
/// > nested-blob branch. It is a record of what 12.x shipped.
@interface FIRMessagingTokenInfo_Legacy12 : FIRMessagingTokenInfo
@end

/// A stand-in for the "gadget class" in an Objective-C deserialization attack: it conforms to
/// `NSCoding` but deliberately not to `NSSecureCoding`, and it records whether the runtime ever
/// handed it a decoder.
///
/// A `requiresSecureCoding = YES` unarchiver must refuse to construct it. A
/// `requiresSecureCoding = NO` unarchiver will happily run its `-initWithCoder:`, which is the
/// primitive the real vulnerability was built on.
@interface FIRMessagingArchiveGadget : NSObject <NSCoding>

/// YES once `-initWithCoder:` has run on any instance. Reset this in `-setUp`; it is global
/// because the point is to observe construction from code that never returns the object.
@property(class, nonatomic, assign) BOOL wasDecoded;

@end

/// Read and write paths lifted from released SDKs, so compatibility tests exercise the genuine
/// legacy logic instead of an approximation of it.
@interface FIRMessagingLegacyArchiveFixtures : NSObject

/// Archives `tokenInfo` the way `-[FIRMessagingTokenStore saveTokenInfo:handler:]` did at tag
/// `12.19.0`: the deprecated insecure API, with the root object renamed to
/// `FIRInstanceIDTokenInfo`.
+ (NSData *)archiveWrittenBy12:(FIRMessagingTokenInfo *)tokenInfo;

/// Archives `tokenInfo` in the `<= 10.18.0` on-disk format. `tokenInfo` must be a
/// `FIRMessagingTokenInfo_Legacy10_18` so the legacy `encodeWithCoder:` runs.
+ (NSData *)archiveWrittenBy10_18:(FIRMessagingTokenInfo_Legacy10_18 *)tokenInfo;

/// Decodes `item` exactly as `+[FIRMessagingTokenStore tokenInfoFromKeychainItem:]` did at tag
/// `12.19.0`, routing the root object to `FIRMessagingTokenInfo_Legacy12` so that 12.x's
/// `initWithCoder:` is what actually runs.
+ (nullable FIRMessagingTokenInfo *)tokenInfoReadBy12:(NSData *)item;

/// A keychain item whose root object is a `FIRMessagingArchiveGadget` rather than a token info:
/// what a hostile process sharing the keychain access group would plant to attack the *outer*
/// unarchiver in `+[FIRMessagingTokenStore tokenInfoFromKeychainItem:]`.
+ (NSData *)archiveOfGadget;

/// A keychain item in the `<= 10.18.0` shape whose nested `apns_info` blob carries a
/// `FIRMessagingArchiveGadget` instead of an APNSInfo: the same attack aimed at the *nested*
/// unarchiver in `-[FIRMessagingTokenInfo initWithCoder:]`, which is the one the legacy fallback
/// keeps reachable.
+ (NSData *)archiveWithGadgetInNestedAPNSInfoForAuthorizedEntity:(NSString *)authorizedEntity
scope:(NSString *)scope
token:(NSString *)token;

/// A keychain item in the `<= 10.18.0` shape whose nested `apns_info` blob is corrupt rather than
/// hostile -- bit rot, a partial write, or a bug in some other writer. Distinct from the gadget
/// case because these fail at different depths of `NSKeyedUnarchiver`, and the shallow ones are
/// the ones that can raise rather than return an error.
+ (NSData *)archiveWithCorruptNestedAPNSInfoForAuthorizedEntity:(NSString *)authorizedEntity
scope:(NSString *)scope
token:(NSString *)token
payloadKind:
(FIRMessagingCorruptPayloadKind)kind;

@end

NS_ASSUME_NONNULL_END
Loading
Loading