diff --git a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj index 2b73ef84c..892b17bc3 100644 --- a/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj +++ b/iOS_SDK/OneSignalSDK/OneSignal.xcodeproj/project.pbxproj @@ -84,6 +84,8 @@ A5048F01A1B2C3D4E5F6000A /* OSFeatureFlagsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5048F01A1B2C3D4E5F60009 /* OSFeatureFlagsTests.swift */; }; A5048F01A1B2C3D4E5F6100B /* OSFeatureFlagsRefreshServiceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */; }; A5062F01A1B2C3D4E5F60002 /* OSDeviceGestureDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5062F01A1B2C3D4E5F60001 /* OSDeviceGestureDetector.swift */; }; + A5426001A1B2C3D4E5F60002 /* OSInputGuard.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5426001A1B2C3D4E5F60001 /* OSInputGuard.swift */; }; + A5426001A1B2C3D4E5F60004 /* OSInputGuardTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5426001A1B2C3D4E5F60003 /* OSInputGuardTests.swift */; }; A5062F01A1B2C3D4E5F60004 /* OSDeviceGestureDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5062F01A1B2C3D4E5F60003 /* OSDeviceGestureDetectorTests.swift */; }; C781A33FED62B4B54221A09A /* OSLogCrashHandlerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */; }; 32D3A6EA8AD44274B5CE378A /* FileLogStoreRetentionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9CC252C94ECB485E8D0380E9 /* FileLogStoreRetentionTests.swift */; }; @@ -211,6 +213,7 @@ 3CC063E02B6D7F2A002BB07F /* OneSignalUserMocks.h in Headers */ = {isa = PBXBuildFile; fileRef = 3CC063DF2B6D7F2A002BB07F /* OneSignalUserMocks.h */; settings = {ATTRIBUTES = (Public, ); }; }; 3CC063E62B6D7F96002BB07F /* OneSignalUserMocks.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CC063E52B6D7F96002BB07F /* OneSignalUserMocks.swift */; }; 3CC063EE2B6D7FE8002BB07F /* OneSignalUserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CC063ED2B6D7FE8002BB07F /* OneSignalUserTests.swift */; }; + A5426001A1B2C3D4E5F60006 /* InputGuardUserTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5426001A1B2C3D4E5F60005 /* InputGuardUserTests.swift */; }; 3CC063EF2B6D7FE8002BB07F /* OneSignalUser.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = DE69E19B282ED8060090BB3D /* OneSignalUser.framework */; }; 3CC890352C5BF9A7002CB4CC /* UserConcurrencyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3CC890342C5BF9A7002CB4CC /* UserConcurrencyTests.swift */; }; 3CC9A6342AFA1FDE008F68FD /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = 3CC9A6332AFA1FDD008F68FD /* PrivacyInfo.xcprivacy */; }; @@ -1459,6 +1462,7 @@ 3CC063E52B6D7F96002BB07F /* OneSignalUserMocks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneSignalUserMocks.swift; sourceTree = ""; }; 3CC063EB2B6D7FE8002BB07F /* OneSignalUserTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = OneSignalUserTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 3CC063ED2B6D7FE8002BB07F /* OneSignalUserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OneSignalUserTests.swift; sourceTree = ""; }; + A5426001A1B2C3D4E5F60005 /* InputGuardUserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InputGuardUserTests.swift; sourceTree = ""; }; 3CC890342C5BF9A7002CB4CC /* UserConcurrencyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserConcurrencyTests.swift; sourceTree = ""; }; 3CC9A6332AFA1FDD008F68FD /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; 3CC9A6352AFA26E7008F68FD /* PrivacyInfo.xcprivacy */ = {isa = PBXFileReference; lastKnownFileType = text.xml; path = PrivacyInfo.xcprivacy; sourceTree = ""; }; @@ -1827,6 +1831,8 @@ A5048F01A1B2C3D4E5F60009 /* OSFeatureFlagsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSFeatureFlagsTests.swift; sourceTree = ""; }; A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSFeatureFlagsRefreshServiceTests.swift; sourceTree = ""; }; A5062F01A1B2C3D4E5F60001 /* OSDeviceGestureDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSDeviceGestureDetector.swift; sourceTree = ""; }; + A5426001A1B2C3D4E5F60001 /* OSInputGuard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSInputGuard.swift; sourceTree = ""; }; + A5426001A1B2C3D4E5F60003 /* OSInputGuardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSInputGuardTests.swift; sourceTree = ""; }; A5062F01A1B2C3D4E5F60003 /* OSDeviceGestureDetectorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSDeviceGestureDetectorTests.swift; sourceTree = ""; }; 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OSLogCrashHandlerTests.swift; sourceTree = ""; }; 9CC252C94ECB485E8D0380E9 /* FileLogStoreRetentionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileLogStoreRetentionTests.swift; sourceTree = ""; }; @@ -2322,6 +2328,7 @@ 3C448BA12936B474002F96BC /* OSBackgroundTaskManager.swift */, 4710EA522B8FCFB200435356 /* OSDispatchQueue.swift */, A5062F01A1B2C3D4E5F60001 /* OSDeviceGestureDetector.swift */, + A5426001A1B2C3D4E5F60001 /* OSInputGuard.swift */, DEFB3E642BB7346D00E65DAD /* OSLiveActivities.swift */, DEFB3E662BB735B500E65DAD /* OSStubLiveActivities.swift */, ); @@ -2456,6 +2463,7 @@ 3CDE664A2BFC2A55006DA114 /* OneSignalUserTests-Bridging-Header.h */, 3CF11E3E2C6D61AC002856F5 /* Executors */, 3CC063ED2B6D7FE8002BB07F /* OneSignalUserTests.swift */, + A5426001A1B2C3D4E5F60005 /* InputGuardUserTests.swift */, 3CA93BC6300B0100000724B3 /* SubscriptionModelConcurrencyTests.swift */, 3CC890342C5BF9A7002CB4CC /* UserConcurrencyTests.swift */, 3CB331672F281679000E1801 /* CustomEventsIntegrationTests.swift */, @@ -2628,6 +2636,7 @@ A5048F01A1B2C3D4E5F60009 /* OSFeatureFlagsTests.swift */, A5048F01A1B2C3D4E5F6100A /* OSFeatureFlagsRefreshServiceTests.swift */, A5062F01A1B2C3D4E5F60003 /* OSDeviceGestureDetectorTests.swift */, + A5426001A1B2C3D4E5F60003 /* OSInputGuardTests.swift */, 3B6A59620B83538CEFF77269 /* OSLogCrashHandlerTests.swift */, 9CC252C94ECB485E8D0380E9 /* FileLogStoreRetentionTests.swift */, 3C23A21A2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift */, @@ -4511,6 +4520,7 @@ A5048F01A1B2C3D4E5F6000D /* OSFeatureManagerImpl.swift in Sources */, A5048F01A1B2C3D4E5F60008 /* OSFeatureFlagsRefreshService.swift in Sources */, A5062F01A1B2C3D4E5F60002 /* OSDeviceGestureDetector.swift in Sources */, + A5426001A1B2C3D4E5F60002 /* OSInputGuard.swift in Sources */, 3C4F9E4428A4466C009F453A /* OSOperationRepo.swift in Sources */, 3C14E3B02FAE54C006ED053 /* IOSLogger.swift in Sources */, 698F58A488FCE503DFD5247F /* OSLogCrashHandler.swift in Sources */, @@ -4599,6 +4609,7 @@ 3CF11E3D2C6D6155002856F5 /* UserExecutorTests.swift in Sources */, 3C67F77A2BEB2B710085A0F0 /* SwitchUserIntegrationTests.swift in Sources */, 3CC063EE2B6D7FE8002BB07F /* OneSignalUserTests.swift in Sources */, + A5426001A1B2C3D4E5F60006 /* InputGuardUserTests.swift in Sources */, 3CA93BC7300B0100000724B3 /* SubscriptionModelConcurrencyTests.swift in Sources */, 3CC890352C5BF9A7002CB4CC /* UserConcurrencyTests.swift in Sources */, 3CB3316A2F281692000E1801 /* OSCustomEventsExecutorTests.swift in Sources */, @@ -4683,6 +4694,7 @@ A5048F01A1B2C3D4E5F6000A /* OSFeatureFlagsTests.swift in Sources */, A5048F01A1B2C3D4E5F6100B /* OSFeatureFlagsRefreshServiceTests.swift in Sources */, A5062F01A1B2C3D4E5F60004 /* OSDeviceGestureDetectorTests.swift in Sources */, + A5426001A1B2C3D4E5F60004 /* OSInputGuardTests.swift in Sources */, C781A33FED62B4B54221A09A /* OSLogCrashHandlerTests.swift in Sources */, 32D3A6EA8AD44274B5CE378A /* FileLogStoreRetentionTests.swift in Sources */, 3C23A21B2FCE0A52001D32E3 /* OneSignalIdentifiersFallbackTests.swift in Sources */, diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/OneSignalInAppMessages.m b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/OneSignalInAppMessages.m index f9540813c..223a173f7 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/OneSignalInAppMessages.m +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessages/OneSignalInAppMessages.m @@ -28,6 +28,7 @@ of this software and associated documentation files (the "Software"), to deal #import "OneSignalInAppMessages.h" #import "OSMessagingController.h" #import "OSInAppMessageMigrationController.h" +#import @implementation OneSignalInAppMessages @@ -77,10 +78,10 @@ + (void)addTrigger:(NSString * _Nonnull)key withValue:(NSString * _Nonnull)value if ([OSPrivacyConsentController shouldLogMissingPrivacyConsentErrorWithMethodName:@"addTrigger:withValue:"]) return; - if (!key) { - [OneSignalLog onesignalLog:ONE_S_LL_ERROR message:@"Attempted to set a trigger with a nil key."]; + if ([OSInputGuard isMissing:key api:@"addTrigger: key"]) + return; + if (!value && [OSInputGuard isMissing:nil api:@"addTrigger: value"]) return; - } [OSMessagingController.sharedInstance addTriggers:@{key : value}]; } @@ -90,6 +91,9 @@ + (void)addTriggers:(NSDictionary * _Nonnull)triggers { if ([OSPrivacyConsentController shouldLogMissingPrivacyConsentErrorWithMethodName:@"addTriggers:"]) return; + if ([OSInputGuard hasMissingEntries:triggers api:@"addTriggers" allowEmptyValue:YES]) + return; + [OSMessagingController.sharedInstance addTriggers:triggers]; } @@ -98,10 +102,8 @@ + (void)removeTrigger:(NSString * _Nonnull)key { if ([OSPrivacyConsentController shouldLogMissingPrivacyConsentErrorWithMethodName:@"removeTriggerForKey:"]) return; - if (!key) { - [OneSignalLog onesignalLog:ONE_S_LL_ERROR message:@"Attempted to remove a trigger with a nil key."]; + if ([OSInputGuard isMissing:key api:@"removeTrigger: key"]) return; - } [OSMessagingController.sharedInstance removeTriggersForKeys:@[key]]; } @@ -111,6 +113,9 @@ + (void)removeTriggers:(NSArray * _Nonnull)keys { if ([OSPrivacyConsentController shouldLogMissingPrivacyConsentErrorWithMethodName:@"removeTriggerForKey:"]) return; + if ([OSInputGuard isMissingAny:keys api:@"removeTriggers: key"]) + return; + [OSMessagingController.sharedInstance removeTriggersForKeys:keys]; } diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift index ff466300f..a63499cf1 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMIntegrationTests.swift @@ -151,4 +151,21 @@ final class IAMIntegrationTests: XCTestCase { XCTAssertFalse(OSMessagingController.sharedInstance().isInAppMessageShowing) XCTAssertEqual(OSMessagingController.sharedInstance().messageDisplayQueue.count, 0) } + + func testBlankTriggerKeysLeaveExistingTriggers() { + let controller = OSMessagingController.sharedInstance() + OneSignalInAppMessages.addTrigger("guard-kept", withValue: "yes") + OneSignalInAppMessages.addTrigger("", withValue: "no") + OneSignalInAppMessages.addTriggers(["": "y", "blank-batch": "x"]) + OneSignalInAppMessages.removeTrigger("") + OneSignalInAppMessages.removeTriggers(["guard-kept", ""]) + + let triggers = controller.triggerController.getTriggers() + XCTAssertEqual(triggers["guard-kept"] as? String, "yes") + XCTAssertNil(triggers[""]) + XCTAssertNil(triggers["blank-batch"]) + + OneSignalInAppMessages.removeTrigger("guard-kept") + controller.earlySessionTriggers.remove("guard-kept") + } } diff --git a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMRequestTests.m b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMRequestTests.m index 6d5eb4509..36e910d56 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMRequestTests.m +++ b/iOS_SDK/OneSignalSDK/OneSignalInAppMessagesTests/IAMRequestTests.m @@ -28,6 +28,14 @@ of this software and associated documentation files (the "Software"), to deal #import #import "OSInAppMessageBridgeEvent.h" #import "OSInAppMessagingRequests.h" +#import "OneSignalInAppMessages.h" +#import "OSMessagingController.h" +#import "OSTriggerController.h" + +@interface OSMessagingController (InputGuardTests) +@property (strong, nonatomic, nonnull) NSMutableSet *earlySessionTriggers; +@property (strong, nonatomic, nonnull) OSTriggerController *triggerController; +@end @interface IAMRequestTests : XCTestCase @@ -179,4 +187,16 @@ - (void)testInAppMessageClicked_withNilArguments { })); } +- (void)testNilTriggerValueIsNotStored { + [OneSignalInAppMessages addTrigger:@"nil-kept" withValue:@"yes"]; + [OneSignalInAppMessages addTrigger:@"nil-value" withValue:nil]; + + NSDictionary *triggers = [OSMessagingController.sharedInstance.triggerController getTriggers]; + XCTAssertEqualObjects(triggers[@"nil-kept"], @"yes"); + XCTAssertNil(triggers[@"nil-value"]); + + [OneSignalInAppMessages removeTrigger:@"nil-kept"]; + [OSMessagingController.sharedInstance.earlySessionTriggers removeObject:@"nil-kept"]; +} + @end diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSInputGuard.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSInputGuard.swift new file mode 100644 index 000000000..20a2efa34 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCore/Source/OSInputGuard.swift @@ -0,0 +1,98 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import Foundation +import OneSignalCore + +@objc(OSInputGuard) +public final class OSInputGuard: NSObject { + @objc(isMissing:api:) + public static func isMissing(_ value: String?, _ api: String) -> Bool { + // A NUL cannot be stored in a text column, so it is never a usable value. + if let value, value.contains("\u{0000}") { + OneSignalLog.onesignalLog(.LL_ERROR, message: "\(api) contains a null byte") + return true + } + if let value, !value.isEmpty { + return false + } + OneSignalLog.onesignalLog(.LL_ERROR, message: "\(api) is required") + return true + } + + @objc(isMissingAny:api:) + public static func isMissingAny(_ values: NSArray?, _ api: String) -> Bool { + guard let values else { + return isMissing(nil, api) + } + for item in values where isMissing(item as? String, api) { + return true + } + return false + } + + /// `allowEmptyValue` keeps "" and a value containing a null byte. A null value is still rejected. + @objc(hasMissingEntries:api:allowEmptyValue:) + public static func hasMissingEntries( + _ values: NSDictionary?, + _ api: String, + allowEmptyValue: Bool + ) -> Bool { + guard let values else { + return isMissing(nil, api) + } + for (key, item) in values { + if isMissing(key as? String, "\(api): key") { + return true + } + if allowEmptyValue { + if item is NSNull { + return isMissing(nil, "\(api): value") + } + continue + } + if isMissing(item as? String, "\(api): value") { + return true + } + } + return false + } + + @nonobjc + public static func isMissingAny(_ values: [String]?, _ api: String) -> Bool { + isMissingAny(values as NSArray?, api) + } + + @nonobjc + public static func hasMissingEntries( + _ values: [String: String]?, + _ api: String, + allowEmptyValue: Bool + ) -> Bool { + hasMissingEntries(values as NSDictionary?, api, allowEmptyValue: allowEmptyValue) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSInputGuardTests.swift b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSInputGuardTests.swift new file mode 100644 index 000000000..2f610d093 --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalOSCoreTests/OSInputGuardTests.swift @@ -0,0 +1,66 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import XCTest +@testable import OneSignalOSCore + +final class OSInputGuardTests: XCTestCase { + func testIsMissingRejectsNilAndEmptyAndKeepsWhitespace() { + XCTAssertTrue(OSInputGuard.isMissing(nil, "login: externalId")) + XCTAssertTrue(OSInputGuard.isMissing("", "login: externalId")) + XCTAssertFalse(OSInputGuard.isMissing(" ", "login: externalId")) + XCTAssertFalse(OSInputGuard.isMissing("user", "login: externalId")) + XCTAssertTrue(OSInputGuard.isMissing("\u{0000}: 1", "login: externalId")) + XCTAssertTrue(OSInputGuard.isMissing("abc\u{0000}", "addAlias: id")) + } + + func testIsMissingAnyRejectsABlankEntryAndANilCollection() { + XCTAssertTrue(OSInputGuard.isMissingAny(nil as NSArray?, "removeTags: key")) + XCTAssertTrue(OSInputGuard.isMissingAny(nil as [String]?, "removeTags: key")) + XCTAssertTrue(OSInputGuard.isMissingAny(["ok", ""], "removeTags: key")) + XCTAssertFalse(OSInputGuard.isMissingAny(["ok", " "], "removeTags: key")) + } + + func testHasMissingEntriesRejectsBlankKeysAndBlankAliasIds() { + XCTAssertTrue(OSInputGuard.hasMissingEntries(nil as [String: String]?, "addAliases", allowEmptyValue: false)) + XCTAssertTrue(OSInputGuard.hasMissingEntries(["": "id"], "addAliases", allowEmptyValue: false)) + XCTAssertTrue(OSInputGuard.hasMissingEntries(["label": ""], "addAliases", allowEmptyValue: false)) + XCTAssertFalse(OSInputGuard.hasMissingEntries(["label": "id"], "addAliases", allowEmptyValue: false)) + XCTAssertFalse(OSInputGuard.hasMissingEntries(["label": " "], "addAliases", allowEmptyValue: false)) + XCTAssertTrue(OSInputGuard.hasMissingEntries(["external_id": "\u{0000}: 1"], "addAliases", allowEmptyValue: false)) + } + + func testHasMissingEntriesAllowsAnEmptyTagValueAndRejectsNull() { + XCTAssertFalse(OSInputGuard.hasMissingEntries(["key": ""], "addTags", allowEmptyValue: true)) + XCTAssertTrue(OSInputGuard.hasMissingEntries(["": "value"], "addTags", allowEmptyValue: true)) + let tags = NSMutableDictionary() + tags["key"] = NSNull() + XCTAssertTrue(OSInputGuard.hasMissingEntries(tags, "addTags", allowEmptyValue: true)) + XCTAssertFalse(OSInputGuard.hasMissingEntries(["key": "a\u{0000}b"], "addTags", allowEmptyValue: true)) + XCTAssertTrue(OSInputGuard.hasMissingEntries(["a\u{0000}": "value"], "addTags", allowEmptyValue: true)) + } +} diff --git a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift index 4852a2b99..e99553aea 100644 --- a/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift +++ b/iOS_SDK/OneSignalSDK/OneSignalUser/Source/OneSignalUserManagerImpl.swift @@ -333,10 +333,7 @@ public class OneSignalUserManagerImpl: NSObject, OneSignalUserManager { return } start() - guard externalId != "" else { - OneSignalLog.onesignalLog(.LL_ERROR, message: "OneSignal.User login called with empty externalId. This is not allowed.") - return - } + if OSInputGuard.isMissing(externalId, "login: externalId") { return } OneSignalLog.onesignalLog(.LL_VERBOSE, message: "OneSignal.User login called with externalId: \(externalId)") // Logging into an identified user from an anonymous user @@ -720,6 +717,7 @@ extension OneSignalUserManagerImpl: OSUser { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "addAlias") else { return } + if OSInputGuard.isMissing(label, "addAlias: label") || OSInputGuard.isMissing(id, "addAlias: id") { return } user.addAliases([label: id]) } @@ -727,6 +725,7 @@ extension OneSignalUserManagerImpl: OSUser { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "addAliases") else { return } + if OSInputGuard.hasMissingEntries(aliases, "addAliases", allowEmptyValue: false) { return } user.addAliases(aliases) } @@ -734,6 +733,7 @@ extension OneSignalUserManagerImpl: OSUser { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "removeAlias") else { return } + if OSInputGuard.isMissing(label, "removeAlias: label") { return } user.removeAliases([label]) } @@ -741,6 +741,7 @@ extension OneSignalUserManagerImpl: OSUser { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "removeAliases") else { return } + if OSInputGuard.isMissingAny(labels, "removeAliases: label") { return } user.removeAliases(labels) } @@ -748,6 +749,7 @@ extension OneSignalUserManagerImpl: OSUser { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "addTag") else { return } + if OSInputGuard.isMissing(key, "addTag: key") { return } user.addTags([key: value]) } @@ -755,6 +757,7 @@ extension OneSignalUserManagerImpl: OSUser { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "addTags") else { return } + if OSInputGuard.hasMissingEntries(tags, "addTags", allowEmptyValue: true) { return } user.addTags(tags) } @@ -762,6 +765,7 @@ extension OneSignalUserManagerImpl: OSUser { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "removeTag") else { return } + if OSInputGuard.isMissing(tag, "removeTag: key") { return } user.removeTags([tag]) } @@ -769,6 +773,7 @@ extension OneSignalUserManagerImpl: OSUser { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "removeTags") else { return } + if OSInputGuard.isMissingAny(tags, "removeTags: key") { return } user.removeTags(tags) } @@ -783,6 +788,7 @@ extension OneSignalUserManagerImpl: OSUser { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "addEmail") else { return } + if OSInputGuard.isMissing(email, "addEmail: email") { return } // Check if is valid email? // Check if this email already exists on this User? createUserIfNil() @@ -806,6 +812,7 @@ extension OneSignalUserManagerImpl: OSUser { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "removeEmail") else { return } + if OSInputGuard.isMissing(email, "removeEmail: email") { return } // Check if is valid email? createUserIfNil() self.subscriptionModelStore.remove(email) @@ -815,6 +822,7 @@ extension OneSignalUserManagerImpl: OSUser { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "addSmsNumber") else { return } + if OSInputGuard.isMissing(number, "addSms: smsNumber") { return } // Check if is valid SMS? // Check if this SMS already exists on this User? createUserIfNil() @@ -838,6 +846,7 @@ extension OneSignalUserManagerImpl: OSUser { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "removeSmsNumber") else { return } + if OSInputGuard.isMissing(number, "removeSms: smsNumber") { return } // Check if is valid SMS? createUserIfNil() self.subscriptionModelStore.remove(number) @@ -862,6 +871,7 @@ extension OneSignalUserManagerImpl: OSUser { guard !OneSignalConfig.shouldAwaitAppIdAndLogMissingPrivacyConsent(forMethod: "trackEvent") else { return } + if OSInputGuard.isMissing(name, "trackEvent: name") { return } let processedProperties = properties ?? [:] diff --git a/iOS_SDK/OneSignalSDK/OneSignalUserTests/InputGuardUserTests.swift b/iOS_SDK/OneSignalSDK/OneSignalUserTests/InputGuardUserTests.swift new file mode 100644 index 000000000..6f11246af --- /dev/null +++ b/iOS_SDK/OneSignalSDK/OneSignalUserTests/InputGuardUserTests.swift @@ -0,0 +1,147 @@ +/* + Modified MIT License + + Copyright 2026 OneSignal + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + 1. The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + 2. All copies of substantial portions of the Software may only be used in connection + with services provided by OneSignal. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN + THE SOFTWARE. + */ + +import OneSignalCore +import OneSignalCoreMocks +import OneSignalUserMocks +import XCTest +@testable import OneSignalOSCore +@_spi(OneSignalInternal) @testable import OneSignalUser + +final class InputGuardUserTests: XCTestCase { + private var previousAppId: String? + + override func setUpWithError() throws { + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + previousAppId = OneSignalIdentifiers.currentAppId + OneSignalIdentifiers.currentAppId = "b2f7f966-d8cc-11e4-bed1-df8f05be55ba" + OneSignalCoreImpl.setSharedClient(MockOneSignalClient()) + OSOperationRepo.sharedInstance.paused = true + } + + override func tearDownWithError() throws { + OneSignalIdentifiers.currentAppId = previousAppId + OneSignalCoreMocks.clearUserDefaults() + OneSignalUserMocks.reset() + } + + func testBlankAliasLabelIsNotStoredAndEmptyRemoveLeavesIt() { + let manager = OneSignalUserManagerImpl.sharedInstance + manager.user.addAliases(["": "legacy-id"]) + manager.addAlias(label: "", id: "alias-id") + manager.addAlias(label: "kept-label", id: "real-id") + manager.addAlias(label: "kept-label", id: "") + manager.addAliases(["blank-label": "x", "": "y"]) + + XCTAssertEqual(manager.user.identityModel.aliases[""], "legacy-id") + XCTAssertEqual(manager.user.identityModel.aliases["kept-label"], "real-id") + XCTAssertNil(manager.user.identityModel.aliases["blank-label"]) + + manager.removeAlias("") + manager.removeAliases([""]) + XCTAssertEqual(manager.user.identityModel.aliases[""], "legacy-id") + + manager.addAlias(label: " ", id: "space-id") + XCTAssertEqual(manager.user.identityModel.aliases[" "], "space-id") + manager.removeAlias(" ") + XCTAssertNil(manager.user.identityModel.aliases[" "]) + } + + func testBlankTagKeyRejectsTheBatchAndEmptyRemoveLeavesIt() { + let manager = OneSignalUserManagerImpl.sharedInstance + manager.user.addTags(["": "legacy"]) + manager.addTags(["": "nope", "blank-batch": "nope"]) + manager.addTag(key: "empty-value", value: "") + + XCTAssertEqual(manager.getTags()[""], "legacy") + XCTAssertNil(manager.getTags()["blank-batch"]) + XCTAssertEqual(manager.getTags()["empty-value"], "") + + manager.removeTag("") + manager.removeTags([""]) + XCTAssertEqual(manager.getTags()[""], "legacy") + manager.removeTag("empty-value") + XCTAssertNil(manager.getTags()["empty-value"]) + } + + func testEmptyEmailAndSmsAreNotStored() { + let manager = OneSignalUserManagerImpl.sharedInstance + manager.addEmail("") + XCTAssertNil(manager.subscriptionModelStore.getModel(key: "")) + manager.addSms("") + XCTAssertNil(manager.subscriptionModelStore.getModel(key: "")) + + manager.subscriptionModelStore.add( + id: "", + model: OSSubscriptionModel( + type: .email, + address: "", + subscriptionId: nil, + reachable: true, + isDisabled: false, + changeNotifier: OSEventProducer() + ), + hydrating: false + ) + manager.removeEmail("") + XCTAssertNotNil(manager.subscriptionModelStore.getModel(key: "")) + manager.removeSms("") + XCTAssertNotNil(manager.subscriptionModelStore.getModel(key: "")) + } + + func testNullByteIsNotStoredExceptAsAnEmptyTagValue() { + let manager = OneSignalUserManagerImpl.sharedInstance + manager.login(externalId: "\u{0000}: 1", token: nil) + XCTAssertNil(manager.externalId) + + manager.addAlias(label: "nul-id", id: "\u{0000}: 1") + manager.addAlias(label: "\u{0000}", id: "1") + XCTAssertNil(manager.user.identityModel.aliases["nul-id"]) + XCTAssertNil(manager.user.identityModel.aliases["\u{0000}"]) + + manager.addTag(key: "nul-value", value: "a\u{0000}b") + XCTAssertEqual(manager.getTags()["nul-value"], "a\u{0000}b") + manager.addTags(["\u{0000}": "nope", "sibling": "nope"]) + XCTAssertNil(manager.getTags()["sibling"]) + manager.removeTag("nul-value") + } + + func testBlankEventNameIsNotEnqueued() { + let manager = OneSignalUserManagerImpl.sharedInstance + let repo = OSOperationRepo.sharedInstance + manager.trackEvent(name: "kept-event", properties: nil) + repo.flushAndWait() + let before = repo.deltaQueue.filter { $0.name == OS_CUSTOM_EVENT_DELTA }.count + + manager.trackEvent(name: "", properties: nil) + repo.flushAndWait() + let after = repo.deltaQueue.filter { $0.name == OS_CUSTOM_EVENT_DELTA }.count + XCTAssertEqual(after, before) + XCTAssertGreaterThan(before, 0) + } +} diff --git a/iOS_SDK/OneSignalSDK/Source/OneSignal.m b/iOS_SDK/OneSignalSDK/Source/OneSignal.m index d8d58d094..6e55af523 100755 --- a/iOS_SDK/OneSignalSDK/Source/OneSignal.m +++ b/iOS_SDK/OneSignalSDK/Source/OneSignal.m @@ -243,6 +243,10 @@ + (void)logout { Note: wrappers may call this method with a null appId. */ + (void)initialize:(nonnull NSString*)newAppId withLaunchOptions:(nullable NSDictionary*)launchOptions { + // "" is rejected. nil still means the app id has not been provided. + if (newAppId != nil && [OSInputGuard isMissing:newAppId api:@"initialize: appId"]) { + return; + } [OSRemoteLoggingController configureFromCacheForAppId:newAppId ?: OneSignalIdentifiers.storedAppId]; [self setAppId:newAppId]; [self setLaunchOptions:launchOptions]; @@ -258,7 +262,12 @@ + (void)initialize:(nonnull NSString*)newAppId withLaunchOptions:(nullable NSDic + (void)setAppId:(nullable NSString*)newAppId { [OneSignalLog onesignalLog:ONE_S_LL_VERBOSE message:[NSString stringWithFormat:@"setAppId called with appId: %@!", newAppId]]; - if (!newAppId || newAppId.length == 0) { + // "" is rejected. nil still falls back to the cached app id. + if (newAppId != nil && [OSInputGuard isMissing:newAppId api:@"initialize: appId"]) { + return; + } + + if (!newAppId) { NSString* cachedAppId = OneSignalIdentifiers.storedAppId; if (cachedAppId) { [OneSignalLog onesignalLog:ONE_S_LL_INFO message:[NSString stringWithFormat:@"Initializing OneSignal with cached appId: '%@'.", cachedAppId]];