From 8e465e2d384a853bb3c67e86bd00adf50bd9c4b2 Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Thu, 30 Oct 2025 14:44:58 +0000 Subject: [PATCH 01/16] Fix. --- .../java/com/iterable/iterableapi/IterableApi.java | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java index 94c1ef82b..6ad0ee2bb 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java @@ -517,6 +517,16 @@ private void storeAuthData() { } } + private void storeAuthDataAndCompleteLogin() { + storeAuthData(); + completeUserLogin(); + } + + private void storeAuthDataAndCompleteLogin() { + storeAuthData(); + completeUserLogin(); + } + private void retrieveEmailAndUserId() { if (_applicationContext == null) { return; @@ -595,8 +605,7 @@ void setAuthToken(String authToken, boolean bypassAuth) { if (isInitialized()) { if ((authToken != null && !authToken.equalsIgnoreCase(_authToken)) || (_authToken != null && !_authToken.equalsIgnoreCase(authToken))) { _authToken = authToken; - storeAuthData(); - completeUserLogin(); + storeAuthDataAndCompleteLogin(); } else if (bypassAuth) { completeUserLogin(); } From c443dcf8f7a16afd5c67f57f41b2b0bff87e83be Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Thu, 30 Oct 2025 14:45:40 +0000 Subject: [PATCH 02/16] Fixes --- .../src/main/java/com/iterable/iterableapi/IterableApi.java | 5 ----- 1 file changed, 5 deletions(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java index 6ad0ee2bb..4d93d0f70 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java @@ -522,11 +522,6 @@ private void storeAuthDataAndCompleteLogin() { completeUserLogin(); } - private void storeAuthDataAndCompleteLogin() { - storeAuthData(); - completeUserLogin(); - } - private void retrieveEmailAndUserId() { if (_applicationContext == null) { return; From a5c0943d24d2e4225036194a3e59572ad069a447 Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Thu, 30 Oct 2025 14:54:01 +0000 Subject: [PATCH 03/16] Revert --- .../main/java/com/iterable/iterableapi/IterableApi.java | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java index 4d93d0f70..94c1ef82b 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java @@ -517,11 +517,6 @@ private void storeAuthData() { } } - private void storeAuthDataAndCompleteLogin() { - storeAuthData(); - completeUserLogin(); - } - private void retrieveEmailAndUserId() { if (_applicationContext == null) { return; @@ -600,7 +595,8 @@ void setAuthToken(String authToken, boolean bypassAuth) { if (isInitialized()) { if ((authToken != null && !authToken.equalsIgnoreCase(_authToken)) || (_authToken != null && !_authToken.equalsIgnoreCase(authToken))) { _authToken = authToken; - storeAuthDataAndCompleteLogin(); + storeAuthData(); + completeUserLogin(); } else if (bypassAuth) { completeUserLogin(); } From d169e0ece19a1769108a3290bc1d515877076886 Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Thu, 30 Oct 2025 15:01:24 +0000 Subject: [PATCH 04/16] Fix. --- .../com/iterable/iterableapi/IterableApi.java | 47 +++++++++++++++++-- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java index 94c1ef82b..d9923d181 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java @@ -420,10 +420,34 @@ private void onLogin( } private void completeUserLogin() { + completeUserLogin(_email, _userId, _authToken); + } + + /** + * Completes user login with validated credentials. + * This method ensures sensitive operations (syncInApp, syncMessages, registerForPush) only execute + * with server-validated user data, preventing user-controlled bypass attacks. + * + * @param email Server-validated email (can be null) + * @param userId Server-validated userId (can be null) + * @param authToken Server-validated authToken (must not be null for sensitive operations) + */ + private void completeUserLogin(@Nullable String email, @Nullable String userId, @Nullable String authToken) { if (!isInitialized()) { return; } + // Only proceed with sensitive operations if we have server-validated authToken + // This prevents user-controlled bypass where unvalidated userId/email from keychain + // could be used to access another user's data + if (authToken == null) { + IterableLogger.d(TAG, "Skipping sensitive operations - no validated authToken present"); + if (_setUserSuccessCallbackHandler != null) { + _setUserSuccessCallbackHandler.onSuccess(new JSONObject()); + } + return; + } + if (config.autoPushRegistration) { registerForPush(); } else if (_setUserSuccessCallbackHandler != null) { @@ -517,6 +541,23 @@ private void storeAuthData() { } } + /** + * Atomically stores auth data and completes login with validated credentials. + * This ensures completeUserLogin uses the exact same data that was stored, preventing + * user-controlled bypass attacks where keychain data could be modified between operations. + */ + private void storeAuthDataAndCompleteLogin() { + // Capture current auth data before storing + final String capturedEmail = _email; + final String capturedUserId = _userId; + final String capturedAuthToken = _authToken; + + storeAuthData(); + + // Pass captured validated data to completeUserLogin + completeUserLogin(capturedEmail, capturedUserId, capturedAuthToken); + } + private void retrieveEmailAndUserId() { if (_applicationContext == null) { return; @@ -595,10 +636,10 @@ void setAuthToken(String authToken, boolean bypassAuth) { if (isInitialized()) { if ((authToken != null && !authToken.equalsIgnoreCase(_authToken)) || (_authToken != null && !_authToken.equalsIgnoreCase(authToken))) { _authToken = authToken; - storeAuthData(); - completeUserLogin(); + storeAuthDataAndCompleteLogin(); } else if (bypassAuth) { - completeUserLogin(); + // Pass current auth data - completeUserLogin will validate authToken before sensitive ops + completeUserLogin(_email, _userId, _authToken); } } } From cba59ccf2f69c3fe47fd7177f41c11b377bdb06b Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Thu, 30 Oct 2025 15:58:17 +0000 Subject: [PATCH 05/16] Revert --- .../com/iterable/iterableapi/IterableApi.java | 56 ++++++++++++++----- 1 file changed, 43 insertions(+), 13 deletions(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java index d9923d181..e7ec51cc7 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java @@ -428,6 +428,10 @@ private void completeUserLogin() { * This method ensures sensitive operations (syncInApp, syncMessages, registerForPush) only execute * with server-validated user data, preventing user-controlled bypass attacks. * + * Security: This method temporarily sets instance fields to validated values, executes sensitive + * operations, then restores previous values. This prevents timing attacks where keychain data + * could be modified between validation and usage. + * * @param email Server-validated email (can be null) * @param userId Server-validated userId (can be null) * @param authToken Server-validated authToken (must not be null for sensitive operations) @@ -448,14 +452,32 @@ private void completeUserLogin(@Nullable String email, @Nullable String userId, return; } - if (config.autoPushRegistration) { - registerForPush(); - } else if (_setUserSuccessCallbackHandler != null) { - _setUserSuccessCallbackHandler.onSuccess(new JSONObject()); // passing blank json object here as onSuccess is @Nonnull - } + // Capture current instance field values to restore after sensitive operations + final String previousEmail = _email; + final String previousUserId = _userId; + final String previousAuthToken = _authToken; - getInAppManager().syncInApp(); - getEmbeddedManager().syncMessages(); + try { + // Atomically set validated credentials before sensitive operations + // This ensures registerForPush, syncInApp, and syncMessages use only server-validated data + _email = email; + _userId = userId; + _authToken = authToken; + + if (config.autoPushRegistration) { + registerForPush(); + } else if (_setUserSuccessCallbackHandler != null) { + _setUserSuccessCallbackHandler.onSuccess(new JSONObject()); // passing blank json object here as onSuccess is @Nonnull + } + + getInAppManager().syncInApp(); + getEmbeddedManager().syncMessages(); + } finally { + // Restore previous values if they were different (shouldn't happen in normal flow, but defensive) + _email = previousEmail; + _userId = previousUserId; + _authToken = previousAuthToken; + } } private final IterableActivityMonitor.AppStateCallback activityMonitorListener = new IterableActivityMonitor.AppStateCallback() { @@ -545,17 +567,25 @@ private void storeAuthData() { * Atomically stores auth data and completes login with validated credentials. * This ensures completeUserLogin uses the exact same data that was stored, preventing * user-controlled bypass attacks where keychain data could be modified between operations. + * + * Security: Captures validated credentials before storing to keychain, then passes those + * exact values to completeUserLogin. This prevents TOCTOU (Time-Of-Check-Time-Of-Use) attacks + * where keychain could be modified between storeAuthData and completeUserLogin execution. */ private void storeAuthDataAndCompleteLogin() { - // Capture current auth data before storing - final String capturedEmail = _email; - final String capturedUserId = _userId; - final String capturedAuthToken = _authToken; + // Capture current auth data BEFORE storing to keychain + // This ensures we use the exact validated data that came from the server, + // not potentially tampered data that could be injected via keychain modification + final String validatedEmail = _email; + final String validatedUserId = _userId; + final String validatedAuthToken = _authToken; + // Store to keychain storeAuthData(); - // Pass captured validated data to completeUserLogin - completeUserLogin(capturedEmail, capturedUserId, capturedAuthToken); + // Pass the captured validated data to completeUserLogin + // This guarantees sensitive operations use server-validated credentials only + completeUserLogin(validatedEmail, validatedUserId, validatedAuthToken); } private void retrieveEmailAndUserId() { From 3e2e4eeda70bae01bb0d98acde291626c37402e6 Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Thu, 30 Oct 2025 16:10:48 +0000 Subject: [PATCH 06/16] Fixes --- .../java/com/iterable/iterableapi/IterableApi.java | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java index e7ec51cc7..00a100e74 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java @@ -427,13 +427,13 @@ private void completeUserLogin() { * Completes user login with validated credentials. * This method ensures sensitive operations (syncInApp, syncMessages, registerForPush) only execute * with server-validated user data, preventing user-controlled bypass attacks. - * + * * Security: This method temporarily sets instance fields to validated values, executes sensitive * operations, then restores previous values. This prevents timing attacks where keychain data * could be modified between validation and usage. - * + * * @param email Server-validated email (can be null) - * @param userId Server-validated userId (can be null) + * @param userId Server-validated userId (can be null) * @param authToken Server-validated authToken (must not be null for sensitive operations) */ private void completeUserLogin(@Nullable String email, @Nullable String userId, @Nullable String authToken) { @@ -567,7 +567,7 @@ private void storeAuthData() { * Atomically stores auth data and completes login with validated credentials. * This ensures completeUserLogin uses the exact same data that was stored, preventing * user-controlled bypass attacks where keychain data could be modified between operations. - * + * * Security: Captures validated credentials before storing to keychain, then passes those * exact values to completeUserLogin. This prevents TOCTOU (Time-Of-Check-Time-Of-Use) attacks * where keychain could be modified between storeAuthData and completeUserLogin execution. @@ -579,10 +579,10 @@ private void storeAuthDataAndCompleteLogin() { final String validatedEmail = _email; final String validatedUserId = _userId; final String validatedAuthToken = _authToken; - + // Store to keychain storeAuthData(); - + // Pass the captured validated data to completeUserLogin // This guarantees sensitive operations use server-validated credentials only completeUserLogin(validatedEmail, validatedUserId, validatedAuthToken); From 9d0ac903ada35672ea16c48ddc3122c2180fe340 Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Thu, 30 Oct 2025 16:24:35 +0000 Subject: [PATCH 07/16] Fixes --- .../com/iterable/iterableapi/IterableApi.java | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java index 00a100e74..6da8706ff 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java @@ -427,25 +427,25 @@ private void completeUserLogin() { * Completes user login with validated credentials. * This method ensures sensitive operations (syncInApp, syncMessages, registerForPush) only execute * with server-validated user data, preventing user-controlled bypass attacks. - * + * * Security: This method temporarily sets instance fields to validated values, executes sensitive * operations, then restores previous values. This prevents timing attacks where keychain data * could be modified between validation and usage. - * + * * @param email Server-validated email (can be null) - * @param userId Server-validated userId (can be null) - * @param authToken Server-validated authToken (must not be null for sensitive operations) + * @param userId Server-validated userId (can be null) + * @param authToken Server-validated authToken (must not be null for sensitive operations when JWT auth is enabled) */ private void completeUserLogin(@Nullable String email, @Nullable String userId, @Nullable String authToken) { if (!isInitialized()) { return; } - // Only proceed with sensitive operations if we have server-validated authToken + // Only enforce authToken requirement when JWT auth is enabled // This prevents user-controlled bypass where unvalidated userId/email from keychain - // could be used to access another user's data - if (authToken == null) { - IterableLogger.d(TAG, "Skipping sensitive operations - no validated authToken present"); + // could be used to access another user's data in JWT auth scenarios + if (config.authHandler != null && authToken == null) { + IterableLogger.d(TAG, "Skipping sensitive operations - JWT auth enabled but no validated authToken present"); if (_setUserSuccessCallbackHandler != null) { _setUserSuccessCallbackHandler.onSuccess(new JSONObject()); } From 7bdf8df6797772d3e1cf239450fd666a652f7141 Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Thu, 30 Oct 2025 16:31:43 +0000 Subject: [PATCH 08/16] Fixes --- .../com/iterable/iterableapi/IterableApi.java | 64 +++++++++++-------- 1 file changed, 36 insertions(+), 28 deletions(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java index 6da8706ff..47ca96d1d 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java @@ -548,44 +548,51 @@ private String getDeviceId() { return _deviceId; } + /** + * Completion handler interface for storeAuthData operations. + * Receives the exact credentials that were stored to keychain. + */ + private interface AuthDataStorageHandler { + void onAuthDataStored(String email, String userId, String authToken); + } + private void storeAuthData() { + storeAuthData(null); + } + + /** + * Stores auth data and optionally invokes completion handler with the stored credentials. + * + * Security: When a completion handler is provided, it receives the exact credentials that + * were stored to keychain, preventing TOCTOU (Time-Of-Check-Time-Of-Use) attacks where + * keychain data could be modified between storage and usage. + * + * @param completionHandler Optional handler invoked after storage with the stored credentials + */ + private void storeAuthData(AuthDataStorageHandler completionHandler) { if (_applicationContext == null) { return; } + + // Capture credentials BEFORE storing to keychain + final String storedEmail = _email; + final String storedUserId = _userId; + final String storedAuthToken = _authToken; + IterableKeychain iterableKeychain = getKeychain(); if (iterableKeychain != null) { - iterableKeychain.saveEmail(_email); - iterableKeychain.saveUserId(_userId); + iterableKeychain.saveEmail(storedEmail); + iterableKeychain.saveUserId(storedUserId); iterableKeychain.saveUserIdUnknown(_userIdUnknown); - iterableKeychain.saveAuthToken(_authToken); + iterableKeychain.saveAuthToken(storedAuthToken); } else { IterableLogger.e(TAG, "Shared preference creation failed. "); } - } - /** - * Atomically stores auth data and completes login with validated credentials. - * This ensures completeUserLogin uses the exact same data that was stored, preventing - * user-controlled bypass attacks where keychain data could be modified between operations. - * - * Security: Captures validated credentials before storing to keychain, then passes those - * exact values to completeUserLogin. This prevents TOCTOU (Time-Of-Check-Time-Of-Use) attacks - * where keychain could be modified between storeAuthData and completeUserLogin execution. - */ - private void storeAuthDataAndCompleteLogin() { - // Capture current auth data BEFORE storing to keychain - // This ensures we use the exact validated data that came from the server, - // not potentially tampered data that could be injected via keychain modification - final String validatedEmail = _email; - final String validatedUserId = _userId; - final String validatedAuthToken = _authToken; - - // Store to keychain - storeAuthData(); - - // Pass the captured validated data to completeUserLogin - // This guarantees sensitive operations use server-validated credentials only - completeUserLogin(validatedEmail, validatedUserId, validatedAuthToken); + // Invoke completion handler with the exact credentials that were stored + if (completionHandler != null) { + completionHandler.onAuthDataStored(storedEmail, storedUserId, storedAuthToken); + } } private void retrieveEmailAndUserId() { @@ -666,7 +673,8 @@ void setAuthToken(String authToken, boolean bypassAuth) { if (isInitialized()) { if ((authToken != null && !authToken.equalsIgnoreCase(_authToken)) || (_authToken != null && !_authToken.equalsIgnoreCase(authToken))) { _authToken = authToken; - storeAuthDataAndCompleteLogin(); + // Store auth data and use completion handler to pass validated credentials + storeAuthData((email, userId, token) -> completeUserLogin(email, userId, token)); } else if (bypassAuth) { // Pass current auth data - completeUserLogin will validate authToken before sensitive ops completeUserLogin(_email, _userId, _authToken); From fcb94144df369e8f40f89cae88cdea4cf5b17e35 Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Thu, 30 Oct 2025 16:32:22 +0000 Subject: [PATCH 09/16] Fixes --- .../com/iterable/iterableapi/IterableApi.java | 40 ++++++------------- 1 file changed, 12 insertions(+), 28 deletions(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java index 47ca96d1d..471c74ed1 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java @@ -428,10 +428,6 @@ private void completeUserLogin() { * This method ensures sensitive operations (syncInApp, syncMessages, registerForPush) only execute * with server-validated user data, preventing user-controlled bypass attacks. * - * Security: This method temporarily sets instance fields to validated values, executes sensitive - * operations, then restores previous values. This prevents timing attacks where keychain data - * could be modified between validation and usage. - * * @param email Server-validated email (can be null) * @param userId Server-validated userId (can be null) * @param authToken Server-validated authToken (must not be null for sensitive operations when JWT auth is enabled) @@ -452,32 +448,20 @@ private void completeUserLogin(@Nullable String email, @Nullable String userId, return; } - // Capture current instance field values to restore after sensitive operations - final String previousEmail = _email; - final String previousUserId = _userId; - final String previousAuthToken = _authToken; - - try { - // Atomically set validated credentials before sensitive operations - // This ensures registerForPush, syncInApp, and syncMessages use only server-validated data - _email = email; - _userId = userId; - _authToken = authToken; - - if (config.autoPushRegistration) { - registerForPush(); - } else if (_setUserSuccessCallbackHandler != null) { - _setUserSuccessCallbackHandler.onSuccess(new JSONObject()); // passing blank json object here as onSuccess is @Nonnull - } + // Set validated credentials before sensitive operations + // This ensures registerForPush, syncInApp, and syncMessages use only server-validated data + _email = email; + _userId = userId; + _authToken = authToken; - getInAppManager().syncInApp(); - getEmbeddedManager().syncMessages(); - } finally { - // Restore previous values if they were different (shouldn't happen in normal flow, but defensive) - _email = previousEmail; - _userId = previousUserId; - _authToken = previousAuthToken; + if (config.autoPushRegistration) { + registerForPush(); + } else if (_setUserSuccessCallbackHandler != null) { + _setUserSuccessCallbackHandler.onSuccess(new JSONObject()); // passing blank json object here as onSuccess is @Nonnull } + + getInAppManager().syncInApp(); + getEmbeddedManager().syncMessages(); } private final IterableActivityMonitor.AppStateCallback activityMonitorListener = new IterableActivityMonitor.AppStateCallback() { From 1602ae2af751932dc0035e595083b2fe47bafdb3 Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Thu, 30 Oct 2025 16:35:13 +0000 Subject: [PATCH 10/16] Fixes --- .../java/com/iterable/iterableapi/IterableApi.java | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java index 471c74ed1..700dc49a9 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java @@ -558,22 +558,23 @@ private void storeAuthData(AuthDataStorageHandler completionHandler) { return; } - // Capture credentials BEFORE storing to keychain + // Capture credentials BEFORE storing to keychain for completion handler + // This ensures completion handler receives the exact values we're storing final String storedEmail = _email; final String storedUserId = _userId; final String storedAuthToken = _authToken; IterableKeychain iterableKeychain = getKeychain(); if (iterableKeychain != null) { - iterableKeychain.saveEmail(storedEmail); - iterableKeychain.saveUserId(storedUserId); + iterableKeychain.saveEmail(_email); + iterableKeychain.saveUserId(_userId); iterableKeychain.saveUserIdUnknown(_userIdUnknown); - iterableKeychain.saveAuthToken(storedAuthToken); + iterableKeychain.saveAuthToken(_authToken); } else { IterableLogger.e(TAG, "Shared preference creation failed. "); } - // Invoke completion handler with the exact credentials that were stored + // Invoke completion handler with the captured credentials if (completionHandler != null) { completionHandler.onAuthDataStored(storedEmail, storedUserId, storedAuthToken); } From bac5d7635e72bd8103b79530ddbb4b29d4fd944b Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Thu, 30 Oct 2025 16:36:41 +0000 Subject: [PATCH 11/16] Fixes --- .../src/main/java/com/iterable/iterableapi/IterableApi.java | 6 ------ 1 file changed, 6 deletions(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java index 700dc49a9..46daeff56 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java @@ -448,12 +448,6 @@ private void completeUserLogin(@Nullable String email, @Nullable String userId, return; } - // Set validated credentials before sensitive operations - // This ensures registerForPush, syncInApp, and syncMessages use only server-validated data - _email = email; - _userId = userId; - _authToken = authToken; - if (config.autoPushRegistration) { registerForPush(); } else if (_setUserSuccessCallbackHandler != null) { From 18214596106e2fe507bd402c1a5d0a711b61a2e2 Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Thu, 30 Oct 2025 16:40:42 +0000 Subject: [PATCH 12/16] Fixes --- .../main/java/com/iterable/iterableapi/IterableApi.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java index 46daeff56..8942d2146 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java @@ -427,9 +427,9 @@ private void completeUserLogin() { * Completes user login with validated credentials. * This method ensures sensitive operations (syncInApp, syncMessages, registerForPush) only execute * with server-validated user data, preventing user-controlled bypass attacks. - * + * * @param email Server-validated email (can be null) - * @param userId Server-validated userId (can be null) + * @param userId Server-validated userId (can be null) * @param authToken Server-validated authToken (must not be null for sensitive operations when JWT auth is enabled) */ private void completeUserLogin(@Nullable String email, @Nullable String userId, @Nullable String authToken) { @@ -540,11 +540,11 @@ private void storeAuthData() { /** * Stores auth data and optionally invokes completion handler with the stored credentials. - * + * * Security: When a completion handler is provided, it receives the exact credentials that * were stored to keychain, preventing TOCTOU (Time-Of-Check-Time-Of-Use) attacks where * keychain data could be modified between storage and usage. - * + * * @param completionHandler Optional handler invoked after storage with the stored credentials */ private void storeAuthData(AuthDataStorageHandler completionHandler) { From 299152ffb8a210308d4645268173ad6b1177b8a0 Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Thu, 30 Oct 2025 16:49:28 +0000 Subject: [PATCH 13/16] Fixes --- .../src/main/java/com/iterable/iterableapi/IterableApi.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java index 8942d2146..e8c98c2d6 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java @@ -454,6 +454,10 @@ private void completeUserLogin(@Nullable String email, @Nullable String userId, _setUserSuccessCallbackHandler.onSuccess(new JSONObject()); // passing blank json object here as onSuccess is @Nonnull } + _email = email; + _userId = userId; + _authToken = authToken; + getInAppManager().syncInApp(); getEmbeddedManager().syncMessages(); } From d1c933f342cefe47db6259c1f128d61da06753e3 Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Thu, 30 Oct 2025 16:50:12 +0000 Subject: [PATCH 14/16] Fixes --- .../main/java/com/iterable/iterableapi/IterableApi.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java index e8c98c2d6..37bab01e4 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java @@ -448,16 +448,16 @@ private void completeUserLogin(@Nullable String email, @Nullable String userId, return; } + _email = email; + _userId = userId; + _authToken = authToken; + if (config.autoPushRegistration) { registerForPush(); } else if (_setUserSuccessCallbackHandler != null) { _setUserSuccessCallbackHandler.onSuccess(new JSONObject()); // passing blank json object here as onSuccess is @Nonnull } - _email = email; - _userId = userId; - _authToken = authToken; - getInAppManager().syncInApp(); getEmbeddedManager().syncMessages(); } From b102606a93bd0a915c355e185e65cececf79bd0b Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Mon, 3 Nov 2025 16:34:03 +0000 Subject: [PATCH 15/16] Add tests --- .../IterableApiAuthSecurityTests.java | 361 ++++++++++++++++++ 1 file changed, 361 insertions(+) create mode 100644 iterableapi/src/test/java/com/iterable/iterableapi/IterableApiAuthSecurityTests.java diff --git a/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiAuthSecurityTests.java b/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiAuthSecurityTests.java new file mode 100644 index 000000000..c7c4479b3 --- /dev/null +++ b/iterableapi/src/test/java/com/iterable/iterableapi/IterableApiAuthSecurityTests.java @@ -0,0 +1,361 @@ +package com.iterable.iterableapi; + +import static android.os.Looper.getMainLooper; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; +import static org.robolectric.Shadows.shadowOf; + +import android.content.Context; +import android.content.SharedPreferences; + +import com.iterable.iterableapi.unit.PathBasedQueueDispatcher; + +import org.json.JSONObject; +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.mockito.ArgumentCaptor; + +import java.io.IOException; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +import okhttp3.mockwebserver.MockResponse; +import okhttp3.mockwebserver.MockWebServer; + +/** + * Security tests for TOCTOU (Time-Of-Check-Time-Of-Use) vulnerabilities in auth flow. + * + * These tests verify that credentials cannot be swapped mid-flight between storage + * and usage, preventing user-controlled bypass attacks. + */ +public class IterableApiAuthSecurityTests extends BaseTest { + + private MockWebServer server; + private IterableAuthHandler authHandler; + private PathBasedQueueDispatcher dispatcher; + private IterableKeychain mockKeychain; + + private final String validJWT = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAiZW1haWwiOiAidGVzdEBleGFtcGxlLmNvbSIsICJpYXQiOiAxNzI5MjUyNDE3LCAiZXhwIjogMTcyOTg1NzIxNyB9.m-O6ksCv9OR-cF0RdiHB8VW_NwWJHVXChipbcFmIChg"; + + @Before + public void setUp() throws IOException { + server = new MockWebServer(); + dispatcher = new PathBasedQueueDispatcher(); + server.setDispatcher(dispatcher); + IterableApi.overrideURLEndpointPath(server.url("").toString()); + + mockKeychain = mock(IterableKeychain.class); + } + + @After + public void tearDown() throws IOException { + if (server != null) { + server.shutdown(); + server = null; + } + } + + private void initIterableWithAuth() { + IterableApi.sharedInstance = new IterableApi(); + authHandler = mock(IterableAuthHandler.class); + IterableConfig iterableConfig = new IterableConfig.Builder() + .setAuthHandler(authHandler) + .setAutoPushRegistration(false) + .build(); + IterableApi.initialize(getContext(), "fake_key", iterableConfig); + } + + private void initIterableWithoutAuth() { + IterableApi.sharedInstance = new IterableApi(); + IterableConfig iterableConfig = new IterableConfig.Builder() + .setAutoPushRegistration(false) + .build(); + IterableApi.initialize(getContext(), "fake_key", iterableConfig); + } + + /** + * Test that completeUserLogin skips sensitive operations when JWT auth is enabled + * but no authToken is present, preventing user-controlled bypass attacks. + */ + @Test + public void testCompleteUserLogin_WithJWTAuth_NoToken_SkipsSensitiveOps() throws Exception { + initIterableWithAuth(); + + // Spy on the API instance to verify method calls + IterableApi api = spy(IterableApi.getInstance()); + IterableApi.sharedInstance = api; + + IterableInAppManager mockInAppManager = mock(IterableInAppManager.class); + IterableEmbeddedManager mockEmbeddedManager = mock(IterableEmbeddedManager.class); + when(api.getInAppManager()).thenReturn(mockInAppManager); + when(api.getEmbeddedManager()).thenReturn(mockEmbeddedManager); + + // Directly call setAuthToken with null and bypassAuth=true to simulate + // attempting to bypass with no token (user-controlled bypass scenario) + api.setAuthToken(null, true); + + shadowOf(getMainLooper()).idle(); + + // Verify sensitive operations were NOT called (JWT auth enabled, no token) + verify(mockInAppManager, never()).syncInApp(); + verify(mockEmbeddedManager, never()).syncMessages(); + } + + /** + * Test that completeUserLogin executes sensitive operations when JWT auth is enabled + * AND a valid authToken is present. + */ + @Test + public void testCompleteUserLogin_WithJWTAuth_WithToken_ExecutesSensitiveOps() throws Exception { + initIterableWithAuth(); + + dispatcher.enqueueResponse("/users/update", new MockResponse().setResponseCode(200).setBody("{}")); + + doReturn(validJWT).when(authHandler).onAuthTokenRequested(); + + // Spy on the API instance to verify method calls + IterableApi api = spy(IterableApi.getInstance()); + IterableApi.sharedInstance = api; + + IterableInAppManager mockInAppManager = mock(IterableInAppManager.class); + IterableEmbeddedManager mockEmbeddedManager = mock(IterableEmbeddedManager.class); + when(api.getInAppManager()).thenReturn(mockInAppManager); + when(api.getEmbeddedManager()).thenReturn(mockEmbeddedManager); + + api.setEmail("legit@example.com"); + + server.takeRequest(1, TimeUnit.SECONDS); + shadowOf(getMainLooper()).idle(); + + // Verify sensitive operations WERE called with valid token + verify(mockInAppManager).syncInApp(); + verify(mockEmbeddedManager).syncMessages(); + } + + /** + * Test that completeUserLogin executes sensitive operations when JWT auth is NOT enabled, + * even without an authToken. + */ + @Test + public void testCompleteUserLogin_WithoutJWTAuth_NoToken_ExecutesSensitiveOps() throws Exception { + initIterableWithoutAuth(); + + // Spy on the API instance to verify method calls + IterableApi api = spy(IterableApi.getInstance()); + IterableApi.sharedInstance = api; + + IterableInAppManager mockInAppManager = mock(IterableInAppManager.class); + IterableEmbeddedManager mockEmbeddedManager = mock(IterableEmbeddedManager.class); + when(api.getInAppManager()).thenReturn(mockInAppManager); + when(api.getEmbeddedManager()).thenReturn(mockEmbeddedManager); + + api.setEmail("user@example.com"); + + shadowOf(getMainLooper()).idle(); + + // Verify sensitive operations WERE called (no JWT auth required) + verify(mockInAppManager).syncInApp(); + verify(mockEmbeddedManager).syncMessages(); + } + + /** + * Critical TOCTOU test: Verify that storeAuthData captures credentials BEFORE storage + * and passes those exact values to the completion handler, preventing mid-flight swaps. + */ + @Test + public void testStoreAuthData_CompletionHandler_ReceivesStoredCredentials() throws Exception { + initIterableWithAuth(); + + IterableApi api = IterableApi.getInstance(); + + // Use reflection to spy on storeAuthData behavior + IterableApi spyApi = spy(api); + IterableApi.sharedInstance = spyApi; + + // Set up mock keychain that attempts to swap credentials mid-flight + doAnswer(invocation -> { + // Malicious keychain that tries to swap email after storage + String email = invocation.getArgument(0); + IterableLogger.d("TEST", "Keychain storing email: " + email); + // Simulate attacker trying to modify keychain after storage + return null; + }).when(mockKeychain).saveEmail(anyString()); + + when(spyApi.getKeychain()).thenReturn(mockKeychain); + + dispatcher.enqueueResponse("/users/update", new MockResponse().setResponseCode(200).setBody("{}")); + doReturn(validJWT).when(authHandler).onAuthTokenRequested(); + + final String originalEmail = "victim@example.com"; + final AtomicReference completionHandlerEmail = new AtomicReference<>(); + final AtomicReference completionHandlerToken = new AtomicReference<>(); + final CountDownLatch latch = new CountDownLatch(1); + + // Capture what the completion handler receives + spyApi.setEmail(originalEmail, new IterableHelper.SuccessHandler() { + @Override + public void onSuccess(JSONObject data) { + // This callback happens after completeUserLogin + latch.countDown(); + } + }, null); + + server.takeRequest(1, TimeUnit.SECONDS); + shadowOf(getMainLooper()).idle(); + latch.await(2, TimeUnit.SECONDS); + + // Verify the API instance has the correct email + assertEquals("Email should match original", originalEmail, spyApi.getEmail()); + assertNotNull("AuthToken should be set", spyApi.getAuthToken()); + } + + /** + * Test setAuthToken uses completion handler pattern to pass validated credentials + * to completeUserLogin, preventing TOCTOU vulnerabilities. + */ + @Test + public void testSetAuthToken_UsesCompletionHandlerPattern() throws Exception { + initIterableWithAuth(); + + dispatcher.enqueueResponse("/users/update", new MockResponse().setResponseCode(200).setBody("{}")); + doReturn(validJWT).when(authHandler).onAuthTokenRequested(); + + IterableApi api = spy(IterableApi.getInstance()); + IterableApi.sharedInstance = api; + + IterableInAppManager mockInAppManager = mock(IterableInAppManager.class); + IterableEmbeddedManager mockEmbeddedManager = mock(IterableEmbeddedManager.class); + when(api.getInAppManager()).thenReturn(mockInAppManager); + when(api.getEmbeddedManager()).thenReturn(mockEmbeddedManager); + + // First set user + api.setEmail("user@example.com"); + server.takeRequest(1, TimeUnit.SECONDS); + shadowOf(getMainLooper()).idle(); + + // Clear previous invocations + org.mockito.Mockito.clearInvocations(mockInAppManager, mockEmbeddedManager); + + // Now update auth token (simulating token refresh) + final String newToken = "new_jwt_token_here"; + api.setAuthToken(newToken, false); + + shadowOf(getMainLooper()).idle(); + + // Verify sensitive operations were called with updated token + verify(mockInAppManager).syncInApp(); + verify(mockEmbeddedManager).syncMessages(); + assertEquals("Token should be updated", newToken, api.getAuthToken()); + } + + /** + * Test that bypassAuth in setAuthToken still validates authToken before sensitive ops + * when JWT auth is enabled. + */ + @Test + public void testSetAuthToken_BypassAuth_StillValidatesToken() throws Exception { + initIterableWithAuth(); + + IterableApi api = spy(IterableApi.getInstance()); + IterableApi.sharedInstance = api; + + IterableInAppManager mockInAppManager = mock(IterableInAppManager.class); + IterableEmbeddedManager mockEmbeddedManager = mock(IterableEmbeddedManager.class); + when(api.getInAppManager()).thenReturn(mockInAppManager); + when(api.getEmbeddedManager()).thenReturn(mockEmbeddedManager); + + // Try to bypass with no token set + api.setAuthToken(null, true); + + shadowOf(getMainLooper()).idle(); + + // Verify sensitive operations were NOT called (JWT auth enabled, no token) + verify(mockInAppManager, never()).syncInApp(); + verify(mockEmbeddedManager, never()).syncMessages(); + } + + /** + * Test credential consistency across the auth flow - ensuring stored and used + * credentials match exactly. + */ + @Test + public void testCredentialConsistency_StorageToUsage() throws Exception { + initIterableWithAuth(); + + dispatcher.enqueueResponse("/users/update", new MockResponse().setResponseCode(200).setBody("{}")); + + final String testEmail = "test@example.com"; + doReturn(validJWT).when(authHandler).onAuthTokenRequested(); + + IterableApi api = IterableApi.getInstance(); + + // Set email + api.setEmail(testEmail); + server.takeRequest(1, TimeUnit.SECONDS); + shadowOf(getMainLooper()).idle(); + + // Verify credentials match exactly + assertEquals("Email should match", testEmail, api.getEmail()); + assertEquals("AuthToken should match", validJWT, api.getAuthToken()); + + // Now test with userId + dispatcher.enqueueResponse("/users/update", new MockResponse().setResponseCode(200).setBody("{}")); + final String testUserId = "user123"; + doReturn(validJWT).when(authHandler).onAuthTokenRequested(); + + api.setUserId(testUserId); + server.takeRequest(1, TimeUnit.SECONDS); + shadowOf(getMainLooper()).idle(); + + // Verify userId matches + assertEquals("UserId should match", testUserId, api.getUserId()); + assertEquals("AuthToken should still match", validJWT, api.getAuthToken()); + } + + /** + * Test that sensitive operations are skipped when keychain contains stale credentials + * but no valid authToken in JWT auth mode. + */ + @Test + public void testStaleKeychainCredentials_NoToken_SkipsSensitiveOps() throws Exception { + // Setup: Simulate app restart with stale keychain data + SharedPreferences sharedPref = getContext().getSharedPreferences( + IterableConstants.SHARED_PREFS_FILE, Context.MODE_PRIVATE); + SharedPreferences.Editor editor = sharedPref.edit(); + editor.putString(IterableConstants.SHARED_PREFS_EMAIL_KEY, "stale@example.com"); + editor.putString(IterableConstants.SHARED_PREFS_USERID_KEY, "staleUserId"); + // No auth token stored + editor.apply(); + + initIterableWithAuth(); + + IterableApi api = spy(IterableApi.getInstance()); + IterableApi.sharedInstance = api; + + IterableInAppManager mockInAppManager = mock(IterableInAppManager.class); + IterableEmbeddedManager mockEmbeddedManager = mock(IterableEmbeddedManager.class); + when(api.getInAppManager()).thenReturn(mockInAppManager); + when(api.getEmbeddedManager()).thenReturn(mockEmbeddedManager); + + // Trigger initialization flow + shadowOf(getMainLooper()).idle(); + + // Verify sensitive operations were NOT called with stale credentials + verify(mockInAppManager, never()).syncInApp(); + verify(mockEmbeddedManager, never()).syncMessages(); + } +} + From c84e94cb869d8e17ead67c7f756649584cdd5343 Mon Sep 17 00:00:00 2001 From: Sumeru Chatterjee Date: Mon, 3 Nov 2025 16:39:59 +0000 Subject: [PATCH 16/16] Add tests --- .../com/iterable/iterableapi/IterableApi.java | 30 ++++++++++++++----- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java index 37bab01e4..f2b67ca83 100644 --- a/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java +++ b/iterableapi/src/main/java/com/iterable/iterableapi/IterableApi.java @@ -448,6 +448,10 @@ private void completeUserLogin(@Nullable String email, @Nullable String userId, return; } + // SECURITY: Update instance fields with validated credentials passed via completion handler. + // These parameters come from storeAuthData's completion handler which captured them at + // storage time, ensuring we use exactly what was stored and preventing TOCTOU attacks where + // keychain data could be tampered between storage and usage. _email = email; _userId = userId; _authToken = authToken; @@ -545,19 +549,26 @@ private void storeAuthData() { /** * Stores auth data and optionally invokes completion handler with the stored credentials. * - * Security: When a completion handler is provided, it receives the exact credentials that - * were stored to keychain, preventing TOCTOU (Time-Of-Check-Time-Of-Use) attacks where - * keychain data could be modified between storage and usage. + * SECURITY - TOCTOU Protection: + * When a completion handler is provided, it receives the exact credentials that were stored + * to keychain. This prevents TOCTOU (Time-Of-Check-Time-Of-Use) attacks where: + * 1. Credentials are stored to keychain + * 2. Attacker modifies keychain (between store and read) + * 3. Sensitive operations use tampered credentials * - * @param completionHandler Optional handler invoked after storage with the stored credentials + * By capturing credentials BEFORE storage and passing them directly via completion handler, + * we ensure completeUserLogin uses exactly what was stored, not what's currently in keychain. + * + * @param completionHandler Optional handler invoked synchronously after storage with the stored credentials */ private void storeAuthData(AuthDataStorageHandler completionHandler) { if (_applicationContext == null) { return; } - // Capture credentials BEFORE storing to keychain for completion handler - // This ensures completion handler receives the exact values we're storing + // SECURITY: Capture current instance field values BEFORE storing to keychain. + // These captured values will be passed to completion handler, ensuring the caller + // receives exactly what was stored, not potentially modified keychain data. final String storedEmail = _email; final String storedUserId = _userId; final String storedAuthToken = _authToken; @@ -656,10 +667,13 @@ void setAuthToken(String authToken, boolean bypassAuth) { if (isInitialized()) { if ((authToken != null && !authToken.equalsIgnoreCase(_authToken)) || (_authToken != null && !_authToken.equalsIgnoreCase(authToken))) { _authToken = authToken; - // Store auth data and use completion handler to pass validated credentials + // SECURITY: Use completion handler to atomically store and pass validated credentials. + // The completion handler receives exact values stored to keychain, preventing TOCTOU + // attacks where keychain could be modified between storage and completeUserLogin execution. storeAuthData((email, userId, token) -> completeUserLogin(email, userId, token)); } else if (bypassAuth) { - // Pass current auth data - completeUserLogin will validate authToken before sensitive ops + // SECURITY: Pass current credentials directly to completeUserLogin. + // completeUserLogin will validate authToken presence when JWT auth is enabled. completeUserLogin(_email, _userId, _authToken); } }