From 3b0d69507c62259fee108bbe38c6f08b2cd56cc2 Mon Sep 17 00:00:00 2001 From: MxKevinBeqo Date: Thu, 9 Jul 2026 15:12:13 +0200 Subject: [PATCH 1/8] fix: access of keychain items and introduce chunked storage of large items/blobs --- .../SessionCookieStore.swift | 187 +++++++++++++++--- 1 file changed, 160 insertions(+), 27 deletions(-) diff --git a/ios/Modules/NativeCookieModule/SessionCookieStore.swift b/ios/Modules/NativeCookieModule/SessionCookieStore.swift index 9312e2c..031e20d 100644 --- a/ios/Modules/NativeCookieModule/SessionCookieStore.swift +++ b/ios/Modules/NativeCookieModule/SessionCookieStore.swift @@ -1,27 +1,29 @@ import Foundation public class SessionCookieStore { - + // MARK: - Private properties private static let bundleIdentifier = Bundle.main.bundleIdentifier ?? "com.mendix.app" private static let storageKey = bundleIdentifier + "sessionCookies" private static let queue = DispatchQueue(label: bundleIdentifier + ".session-cookie-store", qos: .utility) - + private static let chunkSize: Int = 64 * 1024 + private static let maxChunks: Int = 1000 + // MARK: - Public API public static func restore() { - + guard let cookies = get(key: storageKey) else { NSLog("SessionCookieStore: No cookies to restore") return } - + let storage = HTTPCookieStorage.shared let existing = Set(storage.cookies ?? []) cookies.filter { !existing.contains($0) }.forEach { storage.setCookie($0) } - - clear() // Clear stored cookies after restoration to avoid any side effects + + clear() } - + public static func persist() { queue.async { let sessionCookies = HTTPCookieStorage.shared.cookies?.filter { isSessionCookie($0) } ?? [] @@ -33,53 +35,184 @@ public class SessionCookieStore { set(key: storageKey, cookies: sessionCookies) } } - + +#if DEBUG + /// Persist variant that calls `completion` once the keychain write finishes. + /// Only compiled in DEBUG builds; used by the harness test bridge to await completion. + public static func persist(completion: @escaping () -> Void) { + queue.async { + let sessionCookies = HTTPCookieStorage.shared.cookies?.filter { isSessionCookie($0) } ?? [] + guard !sessionCookies.isEmpty else { + clear() + NSLog("SessionCookieStore: Clear existing session cookies from storage") + completion() + return + } + set(key: storageKey, cookies: sessionCookies) + completion() + } + } +#endif + public static func clear() { + clearAllChunks(key: storageKey) clear(key: storageKey) } - + // MARK: - Private API private static func isSessionCookie(_ cookie: HTTPCookie) -> Bool { return cookie.expiresDate == nil } - + private static func set(key: String, cookies: [HTTPCookie]) { do { let data = try NSKeyedArchiver.archivedData(withRootObject: cookies, requiringSecureCoding: false) - let storeQuery = [kSecClass: kSecClassGenericPassword, kSecAttrAccount: key, kSecValueData: data] as CFDictionary - SecItemDelete(storeQuery) - let status = SecItemAdd(storeQuery, nil) - if status != noErr { - NSLog("SessionCookieStore: Failed to persist session cookies with status: \(status)") + + clear(key: key) + clearAllChunks(key: key) + + if data.count <= chunkSize { + let storeQuery = [kSecClass: kSecClassGenericPassword, kSecAttrAccount: key, kSecValueData: data] as CFDictionary + let status = SecItemAdd(storeQuery, nil) + if status != noErr { + NSLog("SessionCookieStore: Failed to persist session cookies with status: \(status)") + } + } else { + let chunkCount = min((data.count + chunkSize - 1) / chunkSize, maxChunks) + + var writtenChunkKeys: [String] = [] + var failed = false + + for i in 0.. [HTTPCookie]? { - do { - let query: [CFString: Any] = [kSecClass: kSecClassGenericPassword, kSecAttrAccount: key, kSecReturnData: true] - var item: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &item) - if status == errSecSuccess, let data = item as? Data { + let countKey = key + "_chunkcount" + let countQuery: [CFString: Any] = [ + kSecClass: kSecClassGenericPassword, + kSecAttrAccount: countKey, + kSecReturnData: true, + kSecUseAuthenticationUI: kSecUseAuthenticationUIFail + ] + var countRef: CFTypeRef? + let countStatus = SecItemCopyMatching(countQuery as CFDictionary, &countRef) + + if countStatus == errSecSuccess, + let countData = countRef as? Data, + let countStr = String(data: countData, encoding: .utf8), + let chunkCount = Int(countStr) { + + var assembled = Data() + for i in 0.. Date: Thu, 9 Jul 2026 15:12:46 +0200 Subject: [PATCH 2/8] test: add tests and helper Debug-only APIs --- .../__tests__/session-cookie-store.harness.ts | 219 ++++++++++++++++++ .../NativeCookieModule.swift | 74 +++++- ios/TurboModules/MxCookie/MxCookie.mm | 35 +++ src/cookie/NativeMxCookie.ts | 7 + src/cookie/index.ts | 18 ++ 5 files changed, 352 insertions(+), 1 deletion(-) create mode 100644 example/__tests__/session-cookie-store.harness.ts diff --git a/example/__tests__/session-cookie-store.harness.ts b/example/__tests__/session-cookie-store.harness.ts new file mode 100644 index 0000000..50ab0c6 --- /dev/null +++ b/example/__tests__/session-cookie-store.harness.ts @@ -0,0 +1,219 @@ +import { beforeEach, describe, expect, test } from 'react-native-harness'; +import { NativeCookie, NativeCookieTestHelpers } from 'mendix-native'; + +// Cookie sizing constants. +// 10 cookies × 7 000-char value → serialised blob ≈ 70–80 KB, above the 64 KB chunk threshold. +const LARGE_COUNT = 10; +const LARGE_VALUE_SIZE = 7_000; +// 3 cookies with tiny values → blob well under 64 KB (single-item path). +const SMALL_COUNT = 3; +const SMALL_VALUE_SIZE = 10; + +describe('SessionCookieStore', () => { + beforeEach(async () => { + await NativeCookie.clearAll(); + }); + + // --------------------------------------------------------------------------- + // Small-blob (single-item keychain format, ≤ 64 KB) + // --------------------------------------------------------------------------- + + describe('small-blob round-trip (single-item format)', () => { + test('persists and restores small cookies', async () => { + await NativeCookieTestHelpers.seedTestCookies( + SMALL_COUNT, + SMALL_VALUE_SIZE + ); + await NativeCookieTestHelpers.persistSessionCookies(); + await NativeCookieTestHelpers.clearHTTPCookies(); + + const names = await NativeCookieTestHelpers.restoreSessionCookies(); + + expect(names.length).toBe(SMALL_COUNT); + for (let i = 0; i < SMALL_COUNT; i++) { + expect(names).toContain(`testCookie${i}`); + } + }); + + test('single-item write does not create a chunk commit-marker', async () => { + await NativeCookieTestHelpers.seedTestCookies( + SMALL_COUNT, + SMALL_VALUE_SIZE + ); + await NativeCookieTestHelpers.persistSessionCookies(); + + const chunkCount = await NativeCookieTestHelpers.getKeychainChunkCount(); + + expect(chunkCount).toBe(0); + }); + + test('keychain is empty after restore (cleared on read)', async () => { + await NativeCookieTestHelpers.seedTestCookies( + SMALL_COUNT, + SMALL_VALUE_SIZE + ); + await NativeCookieTestHelpers.persistSessionCookies(); + await NativeCookieTestHelpers.clearHTTPCookies(); + await NativeCookieTestHelpers.restoreSessionCookies(); + + // A second restore should find nothing. + await NativeCookieTestHelpers.clearHTTPCookies(); + const names = await NativeCookieTestHelpers.restoreSessionCookies(); + + expect(names.length).toBe(0); + }); + }); + + // --------------------------------------------------------------------------- + // Large-blob (chunked keychain format, > 64 KB) + // --------------------------------------------------------------------------- + + describe('large-blob round-trip (chunked format)', () => { + test('persists and restores large cookies', async () => { + await NativeCookieTestHelpers.seedTestCookies( + LARGE_COUNT, + LARGE_VALUE_SIZE + ); + await NativeCookieTestHelpers.persistSessionCookies(); + await NativeCookieTestHelpers.clearHTTPCookies(); + + const names = await NativeCookieTestHelpers.restoreSessionCookies(); + + expect(names.length).toBe(LARGE_COUNT); + for (let i = 0; i < LARGE_COUNT; i++) { + expect(names).toContain(`testCookie${i}`); + } + }); + + test('chunked write creates a commit-marker with count > 1', async () => { + await NativeCookieTestHelpers.seedTestCookies( + LARGE_COUNT, + LARGE_VALUE_SIZE + ); + await NativeCookieTestHelpers.persistSessionCookies(); + + const chunkCount = await NativeCookieTestHelpers.getKeychainChunkCount(); + + expect(chunkCount).toBeGreaterThan(1); + }); + + test('commit-marker is removed after restore (chunked keys cleared on read)', async () => { + await NativeCookieTestHelpers.seedTestCookies( + LARGE_COUNT, + LARGE_VALUE_SIZE + ); + await NativeCookieTestHelpers.persistSessionCookies(); + await NativeCookieTestHelpers.clearHTTPCookies(); + await NativeCookieTestHelpers.restoreSessionCookies(); + + const chunkCount = await NativeCookieTestHelpers.getKeychainChunkCount(); + + expect(chunkCount).toBe(0); + }); + + test('keychain is empty after restore (no second restore possible)', async () => { + await NativeCookieTestHelpers.seedTestCookies( + LARGE_COUNT, + LARGE_VALUE_SIZE + ); + await NativeCookieTestHelpers.persistSessionCookies(); + await NativeCookieTestHelpers.clearHTTPCookies(); + await NativeCookieTestHelpers.restoreSessionCookies(); + + await NativeCookieTestHelpers.clearHTTPCookies(); + const names = await NativeCookieTestHelpers.restoreSessionCookies(); + + expect(names.length).toBe(0); + }); + }); + + // --------------------------------------------------------------------------- + // Format transitions + // --------------------------------------------------------------------------- + + describe('format transitions', () => { + test('overwriting large (chunked) with small (single-item) leaves no chunk marker', async () => { + await NativeCookieTestHelpers.seedTestCookies( + LARGE_COUNT, + LARGE_VALUE_SIZE + ); + await NativeCookieTestHelpers.persistSessionCookies(); + + // Replace with a small set. + await NativeCookieTestHelpers.clearHTTPCookies(); + await NativeCookieTestHelpers.seedTestCookies( + SMALL_COUNT, + SMALL_VALUE_SIZE + ); + await NativeCookieTestHelpers.persistSessionCookies(); + + const chunkCount = await NativeCookieTestHelpers.getKeychainChunkCount(); + expect(chunkCount).toBe(0); + + // Data is still correct. + await NativeCookieTestHelpers.clearHTTPCookies(); + const names = await NativeCookieTestHelpers.restoreSessionCookies(); + expect(names.length).toBe(SMALL_COUNT); + }); + + test('overwriting small (single-item) with large (chunked) round-trips correctly', async () => { + await NativeCookieTestHelpers.seedTestCookies( + SMALL_COUNT, + SMALL_VALUE_SIZE + ); + await NativeCookieTestHelpers.persistSessionCookies(); + + // Replace with a large set. + await NativeCookieTestHelpers.clearHTTPCookies(); + await NativeCookieTestHelpers.seedTestCookies( + LARGE_COUNT, + LARGE_VALUE_SIZE + ); + await NativeCookieTestHelpers.persistSessionCookies(); + + await NativeCookieTestHelpers.clearHTTPCookies(); + const names = await NativeCookieTestHelpers.restoreSessionCookies(); + expect(names.length).toBe(LARGE_COUNT); + }); + }); + + // --------------------------------------------------------------------------- + // clearAll + // --------------------------------------------------------------------------- + + describe('clearAll', () => { + test('removes cookies after a small-blob persist', async () => { + await NativeCookieTestHelpers.seedTestCookies( + SMALL_COUNT, + SMALL_VALUE_SIZE + ); + await NativeCookieTestHelpers.persistSessionCookies(); + await NativeCookie.clearAll(); + + await NativeCookieTestHelpers.clearHTTPCookies(); + const names = await NativeCookieTestHelpers.restoreSessionCookies(); + expect(names.length).toBe(0); + }); + + test('removes cookies and chunk marker after a large-blob persist', async () => { + await NativeCookieTestHelpers.seedTestCookies( + LARGE_COUNT, + LARGE_VALUE_SIZE + ); + await NativeCookieTestHelpers.persistSessionCookies(); + await NativeCookie.clearAll(); + + const chunkCount = await NativeCookieTestHelpers.getKeychainChunkCount(); + expect(chunkCount).toBe(0); + + await NativeCookieTestHelpers.clearHTTPCookies(); + const names = await NativeCookieTestHelpers.restoreSessionCookies(); + expect(names.length).toBe(0); + }); + + test('does not throw when called on an already-empty store', async () => { + await expect(NativeCookie.clearAll()).resolves.not.toThrow(); + await expect(NativeCookie.clearAll()).resolves.not.toThrow(); + }); + }); +}); diff --git a/ios/Modules/NativeCookieModule/NativeCookieModule.swift b/ios/Modules/NativeCookieModule/NativeCookieModule.swift index c7985e4..e67ee4d 100644 --- a/ios/Modules/NativeCookieModule/NativeCookieModule.swift +++ b/ios/Modules/NativeCookieModule/NativeCookieModule.swift @@ -6,7 +6,7 @@ public class NativeCookieModule: NSObject { NativeCookieModule.clearAll() promise.resolve(nil) } - + static func clearAll() { let storage = HTTPCookieStorage.shared for cookie in (storage.cookies ?? []) { @@ -14,4 +14,76 @@ public class NativeCookieModule: NSObject { } SessionCookieStore.clear() } + + // MARK: - Test / diagnostic helpers (DEBUG builds only) + // These methods are excluded from release builds to prevent cookie injection, + // session DoS, and keychain information disclosure from arbitrary JS callers. + +#if DEBUG + /// Seeds `count` session cookies (no expiry), each with a `valueSize`-byte value, + /// into `HTTPCookieStorage.shared`. Used in harness tests to produce blobs of + /// controlled size without relying on a live server. + public func seedTestCookies(count: Int, valueSize: Int, promise: Promise) { + let storage = HTTPCookieStorage.shared + for i in 0..; + // The following methods are only implemented in DEBUG builds. + // They must not be called in production; doing so will throw a TurboModule lookup error. + seedTestCookies(count: number, valueSize: number): Promise; + persistSessionCookies(): Promise; + clearHTTPCookies(): Promise; + restoreSessionCookies(): Promise; + getKeychainChunkCount(): Promise; } export default TurboModuleRegistry.getEnforcing('MxCookie'); diff --git a/src/cookie/index.ts b/src/cookie/index.ts index ff8e8c9..cbf21f9 100644 --- a/src/cookie/index.ts +++ b/src/cookie/index.ts @@ -3,3 +3,21 @@ import NativeMxCookie from './NativeMxCookie'; export const NativeCookie = { clearAll: NativeMxCookie.clearAll, }; + +/** + * Test-only helpers — available in DEBUG builds only. + * Never call these in production code; the native implementations are stripped + * from release binaries and calls will throw a TurboModule lookup error. + */ +export const NativeCookieTestHelpers = { + /** Seeds session cookies into HTTPCookieStorage. */ + seedTestCookies: NativeMxCookie.seedTestCookies, + /** Persists current session cookies to the keychain. Resolves when the write completes. */ + persistSessionCookies: NativeMxCookie.persistSessionCookies, + /** Clears all cookies from HTTPCookieStorage without touching the keychain (simulates an app restart). */ + clearHTTPCookies: NativeMxCookie.clearHTTPCookies, + /** Restores cookies from the keychain into HTTPCookieStorage and returns their names. */ + restoreSessionCookies: NativeMxCookie.restoreSessionCookies, + /** Returns the _chunkcount commit-marker value (> 1 = chunked write; 0 = single-item or empty). */ + getKeychainChunkCount: NativeMxCookie.getKeychainChunkCount, +}; From 69c6314a73ace751034f21edb81e01d3d48a6ec2 Mon Sep 17 00:00:00 2001 From: MxKevinBeqo Date: Thu, 9 Jul 2026 15:13:09 +0200 Subject: [PATCH 3/8] chore: add changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3baf045..6fada6a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +- We addressed a scenario where, in iOS, an Auth dialogue could continuously popup on app startup due to access of either large keychain blob items or blocked items. + ## [v0.5.1] - 2026-06-22 - We fixed an issue that could cause iOS apps to restart repeatedly after an OTA update. From 08b92db86f346d575444cf064a4b0cfc176db906 Mon Sep 17 00:00:00 2001 From: MxKevinBeqo Date: Thu, 9 Jul 2026 15:49:57 +0200 Subject: [PATCH 4/8] fix: add stub implementation for iOS-only methods --- .../com/mendixnative/cookie/MxCookieModule.kt | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/android/src/main/java/com/mendixnative/cookie/MxCookieModule.kt b/android/src/main/java/com/mendixnative/cookie/MxCookieModule.kt index edcb843..77f8662 100644 --- a/android/src/main/java/com/mendixnative/cookie/MxCookieModule.kt +++ b/android/src/main/java/com/mendixnative/cookie/MxCookieModule.kt @@ -1,5 +1,6 @@ package com.mendixnative.cookie +import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.module.annotations.ReactModule @@ -18,6 +19,29 @@ class MxCookieModule(reactContext: ReactApplicationContext) : cookieModule.clearAll(promise) } + // The following methods are iOS-only (keychain / SessionCookieStore). + // They are no-ops on Android so the TurboModule spec is satisfied. + + override fun seedTestCookies(count: Double, valueSize: Double, promise: Promise) { + promise.resolve(null) + } + + override fun persistSessionCookies(promise: Promise) { + promise.resolve(null) + } + + override fun clearHTTPCookies(promise: Promise) { + promise.resolve(null) + } + + override fun restoreSessionCookies(promise: Promise) { + promise.resolve(Arguments.createArray()) + } + + override fun getKeychainChunkCount(promise: Promise) { + promise.resolve(0.0) + } + companion object { const val NAME = "MxCookie" } From 366cdf07fe3c8ec6f8ed8d7bacd52aceb8aaefb5 Mon Sep 17 00:00:00 2001 From: MxKevinBeqo Date: Thu, 9 Jul 2026 16:18:06 +0200 Subject: [PATCH 5/8] test: fix issue with HTTPCookieStorage in tests --- .../com/mendixnative/cookie/MxCookieModule.kt | 10 +--- .../__tests__/session-cookie-store.harness.ts | 57 +++++-------------- .../NativeCookieModule.swift | 43 ++------------ .../SessionCookieStore.swift | 29 ++++++---- ios/TurboModules/MxCookie/MxCookie.mm | 24 ++------ src/cookie/NativeMxCookie.ts | 4 +- src/cookie/index.ts | 10 +--- 7 files changed, 51 insertions(+), 126 deletions(-) diff --git a/android/src/main/java/com/mendixnative/cookie/MxCookieModule.kt b/android/src/main/java/com/mendixnative/cookie/MxCookieModule.kt index 77f8662..e3c77a7 100644 --- a/android/src/main/java/com/mendixnative/cookie/MxCookieModule.kt +++ b/android/src/main/java/com/mendixnative/cookie/MxCookieModule.kt @@ -22,15 +22,7 @@ class MxCookieModule(reactContext: ReactApplicationContext) : // The following methods are iOS-only (keychain / SessionCookieStore). // They are no-ops on Android so the TurboModule spec is satisfied. - override fun seedTestCookies(count: Double, valueSize: Double, promise: Promise) { - promise.resolve(null) - } - - override fun persistSessionCookies(promise: Promise) { - promise.resolve(null) - } - - override fun clearHTTPCookies(promise: Promise) { + override fun persistTestCookies(count: Double, valueSize: Double, promise: Promise) { promise.resolve(null) } diff --git a/example/__tests__/session-cookie-store.harness.ts b/example/__tests__/session-cookie-store.harness.ts index 50ab0c6..a38aee9 100644 --- a/example/__tests__/session-cookie-store.harness.ts +++ b/example/__tests__/session-cookie-store.harness.ts @@ -20,12 +20,10 @@ describe('SessionCookieStore', () => { describe('small-blob round-trip (single-item format)', () => { test('persists and restores small cookies', async () => { - await NativeCookieTestHelpers.seedTestCookies( + await NativeCookieTestHelpers.persistTestCookies( SMALL_COUNT, SMALL_VALUE_SIZE ); - await NativeCookieTestHelpers.persistSessionCookies(); - await NativeCookieTestHelpers.clearHTTPCookies(); const names = await NativeCookieTestHelpers.restoreSessionCookies(); @@ -36,11 +34,10 @@ describe('SessionCookieStore', () => { }); test('single-item write does not create a chunk commit-marker', async () => { - await NativeCookieTestHelpers.seedTestCookies( + await NativeCookieTestHelpers.persistTestCookies( SMALL_COUNT, SMALL_VALUE_SIZE ); - await NativeCookieTestHelpers.persistSessionCookies(); const chunkCount = await NativeCookieTestHelpers.getKeychainChunkCount(); @@ -48,16 +45,13 @@ describe('SessionCookieStore', () => { }); test('keychain is empty after restore (cleared on read)', async () => { - await NativeCookieTestHelpers.seedTestCookies( + await NativeCookieTestHelpers.persistTestCookies( SMALL_COUNT, SMALL_VALUE_SIZE ); - await NativeCookieTestHelpers.persistSessionCookies(); - await NativeCookieTestHelpers.clearHTTPCookies(); await NativeCookieTestHelpers.restoreSessionCookies(); // A second restore should find nothing. - await NativeCookieTestHelpers.clearHTTPCookies(); const names = await NativeCookieTestHelpers.restoreSessionCookies(); expect(names.length).toBe(0); @@ -70,12 +64,10 @@ describe('SessionCookieStore', () => { describe('large-blob round-trip (chunked format)', () => { test('persists and restores large cookies', async () => { - await NativeCookieTestHelpers.seedTestCookies( + await NativeCookieTestHelpers.persistTestCookies( LARGE_COUNT, LARGE_VALUE_SIZE ); - await NativeCookieTestHelpers.persistSessionCookies(); - await NativeCookieTestHelpers.clearHTTPCookies(); const names = await NativeCookieTestHelpers.restoreSessionCookies(); @@ -86,11 +78,10 @@ describe('SessionCookieStore', () => { }); test('chunked write creates a commit-marker with count > 1', async () => { - await NativeCookieTestHelpers.seedTestCookies( + await NativeCookieTestHelpers.persistTestCookies( LARGE_COUNT, LARGE_VALUE_SIZE ); - await NativeCookieTestHelpers.persistSessionCookies(); const chunkCount = await NativeCookieTestHelpers.getKeychainChunkCount(); @@ -98,12 +89,10 @@ describe('SessionCookieStore', () => { }); test('commit-marker is removed after restore (chunked keys cleared on read)', async () => { - await NativeCookieTestHelpers.seedTestCookies( + await NativeCookieTestHelpers.persistTestCookies( LARGE_COUNT, LARGE_VALUE_SIZE ); - await NativeCookieTestHelpers.persistSessionCookies(); - await NativeCookieTestHelpers.clearHTTPCookies(); await NativeCookieTestHelpers.restoreSessionCookies(); const chunkCount = await NativeCookieTestHelpers.getKeychainChunkCount(); @@ -112,15 +101,12 @@ describe('SessionCookieStore', () => { }); test('keychain is empty after restore (no second restore possible)', async () => { - await NativeCookieTestHelpers.seedTestCookies( + await NativeCookieTestHelpers.persistTestCookies( LARGE_COUNT, LARGE_VALUE_SIZE ); - await NativeCookieTestHelpers.persistSessionCookies(); - await NativeCookieTestHelpers.clearHTTPCookies(); await NativeCookieTestHelpers.restoreSessionCookies(); - await NativeCookieTestHelpers.clearHTTPCookies(); const names = await NativeCookieTestHelpers.restoreSessionCookies(); expect(names.length).toBe(0); @@ -133,45 +119,36 @@ describe('SessionCookieStore', () => { describe('format transitions', () => { test('overwriting large (chunked) with small (single-item) leaves no chunk marker', async () => { - await NativeCookieTestHelpers.seedTestCookies( + await NativeCookieTestHelpers.persistTestCookies( LARGE_COUNT, LARGE_VALUE_SIZE ); - await NativeCookieTestHelpers.persistSessionCookies(); - // Replace with a small set. - await NativeCookieTestHelpers.clearHTTPCookies(); - await NativeCookieTestHelpers.seedTestCookies( + // Overwrite with a small set. + await NativeCookieTestHelpers.persistTestCookies( SMALL_COUNT, SMALL_VALUE_SIZE ); - await NativeCookieTestHelpers.persistSessionCookies(); const chunkCount = await NativeCookieTestHelpers.getKeychainChunkCount(); expect(chunkCount).toBe(0); - // Data is still correct. - await NativeCookieTestHelpers.clearHTTPCookies(); const names = await NativeCookieTestHelpers.restoreSessionCookies(); expect(names.length).toBe(SMALL_COUNT); }); test('overwriting small (single-item) with large (chunked) round-trips correctly', async () => { - await NativeCookieTestHelpers.seedTestCookies( + await NativeCookieTestHelpers.persistTestCookies( SMALL_COUNT, SMALL_VALUE_SIZE ); - await NativeCookieTestHelpers.persistSessionCookies(); - // Replace with a large set. - await NativeCookieTestHelpers.clearHTTPCookies(); - await NativeCookieTestHelpers.seedTestCookies( + // Overwrite with a large set. + await NativeCookieTestHelpers.persistTestCookies( LARGE_COUNT, LARGE_VALUE_SIZE ); - await NativeCookieTestHelpers.persistSessionCookies(); - await NativeCookieTestHelpers.clearHTTPCookies(); const names = await NativeCookieTestHelpers.restoreSessionCookies(); expect(names.length).toBe(LARGE_COUNT); }); @@ -183,30 +160,26 @@ describe('SessionCookieStore', () => { describe('clearAll', () => { test('removes cookies after a small-blob persist', async () => { - await NativeCookieTestHelpers.seedTestCookies( + await NativeCookieTestHelpers.persistTestCookies( SMALL_COUNT, SMALL_VALUE_SIZE ); - await NativeCookieTestHelpers.persistSessionCookies(); await NativeCookie.clearAll(); - await NativeCookieTestHelpers.clearHTTPCookies(); const names = await NativeCookieTestHelpers.restoreSessionCookies(); expect(names.length).toBe(0); }); test('removes cookies and chunk marker after a large-blob persist', async () => { - await NativeCookieTestHelpers.seedTestCookies( + await NativeCookieTestHelpers.persistTestCookies( LARGE_COUNT, LARGE_VALUE_SIZE ); - await NativeCookieTestHelpers.persistSessionCookies(); await NativeCookie.clearAll(); const chunkCount = await NativeCookieTestHelpers.getKeychainChunkCount(); expect(chunkCount).toBe(0); - await NativeCookieTestHelpers.clearHTTPCookies(); const names = await NativeCookieTestHelpers.restoreSessionCookies(); expect(names.length).toBe(0); }); diff --git a/ios/Modules/NativeCookieModule/NativeCookieModule.swift b/ios/Modules/NativeCookieModule/NativeCookieModule.swift index e67ee4d..f2ec2d9 100644 --- a/ios/Modules/NativeCookieModule/NativeCookieModule.swift +++ b/ios/Modules/NativeCookieModule/NativeCookieModule.swift @@ -20,47 +20,16 @@ public class NativeCookieModule: NSObject { // session DoS, and keychain information disclosure from arbitrary JS callers. #if DEBUG - /// Seeds `count` session cookies (no expiry), each with a `valueSize`-byte value, - /// into `HTTPCookieStorage.shared`. Used in harness tests to produce blobs of - /// controlled size without relying on a live server. - public func seedTestCookies(count: Int, valueSize: Int, promise: Promise) { - let storage = HTTPCookieStorage.shared - for i in 0.. Void) { + /// Writes `count` synthetic session cookies with `valueSize`-byte values directly + /// to the keychain, bypassing HTTPCookieStorage. Resolves via `completion` once + /// the async queue write finishes. Used by harness tests only. + public static func persistTestCookies(count: Int, valueSize: Int, completion: @escaping () -> Void) { queue.async { - let sessionCookies = HTTPCookieStorage.shared.cookies?.filter { isSessionCookie($0) } ?? [] - guard !sessionCookies.isEmpty else { - clear() - NSLog("SessionCookieStore: Clear existing session cookies from storage") - completion() - return + let cookies = (0.. [String] { + guard let cookies = get(key: storageKey) else { return [] } + clear() + return cookies.map(\.name) + } #endif public static func clear() { diff --git a/ios/TurboModules/MxCookie/MxCookie.mm b/ios/TurboModules/MxCookie/MxCookie.mm index ae1f656..f3bc8d4 100644 --- a/ios/TurboModules/MxCookie/MxCookie.mm +++ b/ios/TurboModules/MxCookie/MxCookie.mm @@ -20,25 +20,13 @@ - (void)clearAll:(nonnull RCTPromiseResolveBlock)resolve } #if DEBUG -- (void)seedTestCookies:(double)count valueSize:(double)valueSize - resolve:(nonnull RCTPromiseResolveBlock)resolve - reject:(nonnull RCTPromiseRejectBlock)reject { +- (void)persistTestCookies:(double)count valueSize:(double)valueSize + resolve:(nonnull RCTPromiseResolveBlock)resolve + reject:(nonnull RCTPromiseRejectBlock)reject { Promise *promise = [Promise instance:resolve reject:reject]; - [[[NativeCookieModule alloc] init] seedTestCookiesWithCount:(NSInteger)count - valueSize:(NSInteger)valueSize - promise:promise]; -} - -- (void)persistSessionCookies:(nonnull RCTPromiseResolveBlock)resolve - reject:(nonnull RCTPromiseRejectBlock)reject { - Promise *promise = [Promise instance:resolve reject:reject]; - [[[NativeCookieModule alloc] init] persistSessionCookies:promise]; -} - -- (void)clearHTTPCookies:(nonnull RCTPromiseResolveBlock)resolve - reject:(nonnull RCTPromiseRejectBlock)reject { - Promise *promise = [Promise instance:resolve reject:reject]; - [[[NativeCookieModule alloc] init] clearHTTPCookies:promise]; + [[[NativeCookieModule alloc] init] persistTestCookiesWithCount:(NSInteger)count + valueSize:(NSInteger)valueSize + promise:promise]; } - (void)restoreSessionCookies:(nonnull RCTPromiseResolveBlock)resolve diff --git a/src/cookie/NativeMxCookie.ts b/src/cookie/NativeMxCookie.ts index 0076b76..01d6ec6 100644 --- a/src/cookie/NativeMxCookie.ts +++ b/src/cookie/NativeMxCookie.ts @@ -4,9 +4,7 @@ export interface Spec extends TurboModule { clearAll(): Promise; // The following methods are only implemented in DEBUG builds. // They must not be called in production; doing so will throw a TurboModule lookup error. - seedTestCookies(count: number, valueSize: number): Promise; - persistSessionCookies(): Promise; - clearHTTPCookies(): Promise; + persistTestCookies(count: number, valueSize: number): Promise; restoreSessionCookies(): Promise; getKeychainChunkCount(): Promise; } diff --git a/src/cookie/index.ts b/src/cookie/index.ts index cbf21f9..e83176d 100644 --- a/src/cookie/index.ts +++ b/src/cookie/index.ts @@ -10,13 +10,9 @@ export const NativeCookie = { * from release binaries and calls will throw a TurboModule lookup error. */ export const NativeCookieTestHelpers = { - /** Seeds session cookies into HTTPCookieStorage. */ - seedTestCookies: NativeMxCookie.seedTestCookies, - /** Persists current session cookies to the keychain. Resolves when the write completes. */ - persistSessionCookies: NativeMxCookie.persistSessionCookies, - /** Clears all cookies from HTTPCookieStorage without touching the keychain (simulates an app restart). */ - clearHTTPCookies: NativeMxCookie.clearHTTPCookies, - /** Restores cookies from the keychain into HTTPCookieStorage and returns their names. */ + /** Writes N synthetic session cookies directly to the keychain (bypasses HTTPCookieStorage). Resolves when the write completes. */ + persistTestCookies: NativeMxCookie.persistTestCookies, + /** Reads cookies directly from the keychain, clears the entry, and returns their names (bypasses HTTPCookieStorage). */ restoreSessionCookies: NativeMxCookie.restoreSessionCookies, /** Returns the _chunkcount commit-marker value (> 1 = chunked write; 0 = single-item or empty). */ getKeychainChunkCount: NativeMxCookie.getKeychainChunkCount, From f2ddaa063ae8cebde489bab85ce9fbd47f2bf71e Mon Sep 17 00:00:00 2001 From: MxKevinBeqo Date: Mon, 13 Jul 2026 14:19:09 +0200 Subject: [PATCH 6/8] fix: reverted the chunking mechanism and tests --- .../com/mendixnative/cookie/MxCookieModule.kt | 16 -- .../__tests__/session-cookie-store.harness.ts | 192 ------------------ .../NativeCookieModule.swift | 37 ---- .../SessionCookieStore.swift | 138 +------------ ios/TurboModules/MxCookie/MxCookie.mm | 23 --- src/cookie/NativeMxCookie.ts | 5 - src/cookie/index.ts | 14 -- 7 files changed, 7 insertions(+), 418 deletions(-) delete mode 100644 example/__tests__/session-cookie-store.harness.ts diff --git a/android/src/main/java/com/mendixnative/cookie/MxCookieModule.kt b/android/src/main/java/com/mendixnative/cookie/MxCookieModule.kt index e3c77a7..edcb843 100644 --- a/android/src/main/java/com/mendixnative/cookie/MxCookieModule.kt +++ b/android/src/main/java/com/mendixnative/cookie/MxCookieModule.kt @@ -1,6 +1,5 @@ package com.mendixnative.cookie -import com.facebook.react.bridge.Arguments import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.module.annotations.ReactModule @@ -19,21 +18,6 @@ class MxCookieModule(reactContext: ReactApplicationContext) : cookieModule.clearAll(promise) } - // The following methods are iOS-only (keychain / SessionCookieStore). - // They are no-ops on Android so the TurboModule spec is satisfied. - - override fun persistTestCookies(count: Double, valueSize: Double, promise: Promise) { - promise.resolve(null) - } - - override fun restoreSessionCookies(promise: Promise) { - promise.resolve(Arguments.createArray()) - } - - override fun getKeychainChunkCount(promise: Promise) { - promise.resolve(0.0) - } - companion object { const val NAME = "MxCookie" } diff --git a/example/__tests__/session-cookie-store.harness.ts b/example/__tests__/session-cookie-store.harness.ts deleted file mode 100644 index a38aee9..0000000 --- a/example/__tests__/session-cookie-store.harness.ts +++ /dev/null @@ -1,192 +0,0 @@ -import { beforeEach, describe, expect, test } from 'react-native-harness'; -import { NativeCookie, NativeCookieTestHelpers } from 'mendix-native'; - -// Cookie sizing constants. -// 10 cookies × 7 000-char value → serialised blob ≈ 70–80 KB, above the 64 KB chunk threshold. -const LARGE_COUNT = 10; -const LARGE_VALUE_SIZE = 7_000; -// 3 cookies with tiny values → blob well under 64 KB (single-item path). -const SMALL_COUNT = 3; -const SMALL_VALUE_SIZE = 10; - -describe('SessionCookieStore', () => { - beforeEach(async () => { - await NativeCookie.clearAll(); - }); - - // --------------------------------------------------------------------------- - // Small-blob (single-item keychain format, ≤ 64 KB) - // --------------------------------------------------------------------------- - - describe('small-blob round-trip (single-item format)', () => { - test('persists and restores small cookies', async () => { - await NativeCookieTestHelpers.persistTestCookies( - SMALL_COUNT, - SMALL_VALUE_SIZE - ); - - const names = await NativeCookieTestHelpers.restoreSessionCookies(); - - expect(names.length).toBe(SMALL_COUNT); - for (let i = 0; i < SMALL_COUNT; i++) { - expect(names).toContain(`testCookie${i}`); - } - }); - - test('single-item write does not create a chunk commit-marker', async () => { - await NativeCookieTestHelpers.persistTestCookies( - SMALL_COUNT, - SMALL_VALUE_SIZE - ); - - const chunkCount = await NativeCookieTestHelpers.getKeychainChunkCount(); - - expect(chunkCount).toBe(0); - }); - - test('keychain is empty after restore (cleared on read)', async () => { - await NativeCookieTestHelpers.persistTestCookies( - SMALL_COUNT, - SMALL_VALUE_SIZE - ); - await NativeCookieTestHelpers.restoreSessionCookies(); - - // A second restore should find nothing. - const names = await NativeCookieTestHelpers.restoreSessionCookies(); - - expect(names.length).toBe(0); - }); - }); - - // --------------------------------------------------------------------------- - // Large-blob (chunked keychain format, > 64 KB) - // --------------------------------------------------------------------------- - - describe('large-blob round-trip (chunked format)', () => { - test('persists and restores large cookies', async () => { - await NativeCookieTestHelpers.persistTestCookies( - LARGE_COUNT, - LARGE_VALUE_SIZE - ); - - const names = await NativeCookieTestHelpers.restoreSessionCookies(); - - expect(names.length).toBe(LARGE_COUNT); - for (let i = 0; i < LARGE_COUNT; i++) { - expect(names).toContain(`testCookie${i}`); - } - }); - - test('chunked write creates a commit-marker with count > 1', async () => { - await NativeCookieTestHelpers.persistTestCookies( - LARGE_COUNT, - LARGE_VALUE_SIZE - ); - - const chunkCount = await NativeCookieTestHelpers.getKeychainChunkCount(); - - expect(chunkCount).toBeGreaterThan(1); - }); - - test('commit-marker is removed after restore (chunked keys cleared on read)', async () => { - await NativeCookieTestHelpers.persistTestCookies( - LARGE_COUNT, - LARGE_VALUE_SIZE - ); - await NativeCookieTestHelpers.restoreSessionCookies(); - - const chunkCount = await NativeCookieTestHelpers.getKeychainChunkCount(); - - expect(chunkCount).toBe(0); - }); - - test('keychain is empty after restore (no second restore possible)', async () => { - await NativeCookieTestHelpers.persistTestCookies( - LARGE_COUNT, - LARGE_VALUE_SIZE - ); - await NativeCookieTestHelpers.restoreSessionCookies(); - - const names = await NativeCookieTestHelpers.restoreSessionCookies(); - - expect(names.length).toBe(0); - }); - }); - - // --------------------------------------------------------------------------- - // Format transitions - // --------------------------------------------------------------------------- - - describe('format transitions', () => { - test('overwriting large (chunked) with small (single-item) leaves no chunk marker', async () => { - await NativeCookieTestHelpers.persistTestCookies( - LARGE_COUNT, - LARGE_VALUE_SIZE - ); - - // Overwrite with a small set. - await NativeCookieTestHelpers.persistTestCookies( - SMALL_COUNT, - SMALL_VALUE_SIZE - ); - - const chunkCount = await NativeCookieTestHelpers.getKeychainChunkCount(); - expect(chunkCount).toBe(0); - - const names = await NativeCookieTestHelpers.restoreSessionCookies(); - expect(names.length).toBe(SMALL_COUNT); - }); - - test('overwriting small (single-item) with large (chunked) round-trips correctly', async () => { - await NativeCookieTestHelpers.persistTestCookies( - SMALL_COUNT, - SMALL_VALUE_SIZE - ); - - // Overwrite with a large set. - await NativeCookieTestHelpers.persistTestCookies( - LARGE_COUNT, - LARGE_VALUE_SIZE - ); - - const names = await NativeCookieTestHelpers.restoreSessionCookies(); - expect(names.length).toBe(LARGE_COUNT); - }); - }); - - // --------------------------------------------------------------------------- - // clearAll - // --------------------------------------------------------------------------- - - describe('clearAll', () => { - test('removes cookies after a small-blob persist', async () => { - await NativeCookieTestHelpers.persistTestCookies( - SMALL_COUNT, - SMALL_VALUE_SIZE - ); - await NativeCookie.clearAll(); - - const names = await NativeCookieTestHelpers.restoreSessionCookies(); - expect(names.length).toBe(0); - }); - - test('removes cookies and chunk marker after a large-blob persist', async () => { - await NativeCookieTestHelpers.persistTestCookies( - LARGE_COUNT, - LARGE_VALUE_SIZE - ); - await NativeCookie.clearAll(); - - const chunkCount = await NativeCookieTestHelpers.getKeychainChunkCount(); - expect(chunkCount).toBe(0); - - const names = await NativeCookieTestHelpers.restoreSessionCookies(); - expect(names.length).toBe(0); - }); - - test('does not throw when called on an already-empty store', async () => { - await expect(NativeCookie.clearAll()).resolves.not.toThrow(); - await expect(NativeCookie.clearAll()).resolves.not.toThrow(); - }); - }); -}); diff --git a/ios/Modules/NativeCookieModule/NativeCookieModule.swift b/ios/Modules/NativeCookieModule/NativeCookieModule.swift index f2ec2d9..a2494a0 100644 --- a/ios/Modules/NativeCookieModule/NativeCookieModule.swift +++ b/ios/Modules/NativeCookieModule/NativeCookieModule.swift @@ -18,41 +18,4 @@ public class NativeCookieModule: NSObject { // MARK: - Test / diagnostic helpers (DEBUG builds only) // These methods are excluded from release builds to prevent cookie injection, // session DoS, and keychain information disclosure from arbitrary JS callers. - -#if DEBUG - /// Writes `count` synthetic session cookies with `valueSize`-byte values directly - /// to the keychain (bypasses HTTPCookieStorage) and resolves once the write completes. - public func persistTestCookies(count: Int, valueSize: Int, promise: Promise) { - SessionCookieStore.persistTestCookies(count: count, valueSize: valueSize) { - promise.resolve(nil) - } - } - - public func restoreSessionCookies(_ promise: Promise) { - let names = SessionCookieStore.restoreTestCookieNames() - promise.resolve(names) - } - - /// Returns the integer stored in the `_chunkcount` commit-marker keychain item, - /// or `0` if no chunked write exists (single-item or empty). - public func getKeychainChunkCount(_ promise: Promise) { - let bundleId = Bundle.main.bundleIdentifier ?? "com.mendix.app" - let countKey = bundleId + "sessionCookies_chunkcount" - let query: [CFString: Any] = [ - kSecClass: kSecClassGenericPassword, - kSecAttrAccount: countKey, - kSecReturnData: true, - ] - var ref: CFTypeRef? - let status = SecItemCopyMatching(query as CFDictionary, &ref) - if status == errSecSuccess, - let data = ref as? Data, - let str = String(data: data, encoding: .utf8), - let count = Int(str) { - promise.resolve(NSNumber(value: count)) - } else { - promise.resolve(NSNumber(value: 0)) - } - } -#endif } diff --git a/ios/Modules/NativeCookieModule/SessionCookieStore.swift b/ios/Modules/NativeCookieModule/SessionCookieStore.swift index 4edab19..0fd1bec 100644 --- a/ios/Modules/NativeCookieModule/SessionCookieStore.swift +++ b/ios/Modules/NativeCookieModule/SessionCookieStore.swift @@ -6,8 +6,6 @@ public class SessionCookieStore { private static let bundleIdentifier = Bundle.main.bundleIdentifier ?? "com.mendix.app" private static let storageKey = bundleIdentifier + "sessionCookies" private static let queue = DispatchQueue(label: bundleIdentifier + ".session-cookie-store", qos: .utility) - private static let chunkSize: Int = 64 * 1024 - private static let maxChunks: Int = 1000 // MARK: - Public API public static func restore() { @@ -36,35 +34,7 @@ public class SessionCookieStore { } } -#if DEBUG - /// Writes `count` synthetic session cookies with `valueSize`-byte values directly - /// to the keychain, bypassing HTTPCookieStorage. Resolves via `completion` once - /// the async queue write finishes. Used by harness tests only. - public static func persistTestCookies(count: Int, valueSize: Int, completion: @escaping () -> Void) { - queue.async { - let cookies = (0.. [String] { - guard let cookies = get(key: storageKey) else { return [] } - clear() - return cookies.map(\.name) - } -#endif - public static func clear() { - clearAllChunks(key: storageKey) clear(key: storageKey) } @@ -76,55 +46,11 @@ public class SessionCookieStore { private static func set(key: String, cookies: [HTTPCookie]) { do { let data = try NSKeyedArchiver.archivedData(withRootObject: cookies, requiringSecureCoding: false) - clear(key: key) - clearAllChunks(key: key) - - if data.count <= chunkSize { - let storeQuery = [kSecClass: kSecClassGenericPassword, kSecAttrAccount: key, kSecValueData: data] as CFDictionary - let status = SecItemAdd(storeQuery, nil) - if status != noErr { - NSLog("SessionCookieStore: Failed to persist session cookies with status: \(status)") - } - } else { - let chunkCount = min((data.count + chunkSize - 1) / chunkSize, maxChunks) - - var writtenChunkKeys: [String] = [] - var failed = false - - for i in 0.. [HTTPCookie]? { - let countKey = key + "_chunkcount" - let countQuery: [CFString: Any] = [ - kSecClass: kSecClassGenericPassword, - kSecAttrAccount: countKey, - kSecReturnData: true, - kSecUseAuthenticationUI: kSecUseAuthenticationUIFail - ] - var countRef: CFTypeRef? - let countStatus = SecItemCopyMatching(countQuery as CFDictionary, &countRef) - - if countStatus == errSecSuccess, - let countData = countRef as? Data, - let countStr = String(data: countData, encoding: .utf8), - let chunkCount = Int(countStr) { - - var assembled = Data() - for i in 0..; - // The following methods are only implemented in DEBUG builds. - // They must not be called in production; doing so will throw a TurboModule lookup error. - persistTestCookies(count: number, valueSize: number): Promise; - restoreSessionCookies(): Promise; - getKeychainChunkCount(): Promise; } export default TurboModuleRegistry.getEnforcing('MxCookie'); diff --git a/src/cookie/index.ts b/src/cookie/index.ts index e83176d..ff8e8c9 100644 --- a/src/cookie/index.ts +++ b/src/cookie/index.ts @@ -3,17 +3,3 @@ import NativeMxCookie from './NativeMxCookie'; export const NativeCookie = { clearAll: NativeMxCookie.clearAll, }; - -/** - * Test-only helpers — available in DEBUG builds only. - * Never call these in production code; the native implementations are stripped - * from release binaries and calls will throw a TurboModule lookup error. - */ -export const NativeCookieTestHelpers = { - /** Writes N synthetic session cookies directly to the keychain (bypasses HTTPCookieStorage). Resolves when the write completes. */ - persistTestCookies: NativeMxCookie.persistTestCookies, - /** Reads cookies directly from the keychain, clears the entry, and returns their names (bypasses HTTPCookieStorage). */ - restoreSessionCookies: NativeMxCookie.restoreSessionCookies, - /** Returns the _chunkcount commit-marker value (> 1 = chunked write; 0 = single-item or empty). */ - getKeychainChunkCount: NativeMxCookie.getKeychainChunkCount, -}; From 39b7fc332368234fe58578fc8714b30ccb0cad25 Mon Sep 17 00:00:00 2001 From: MxKevinBeqo Date: Mon, 13 Jul 2026 14:48:23 +0200 Subject: [PATCH 7/8] chore: remove old comment --- ios/Modules/NativeCookieModule/NativeCookieModule.swift | 4 ---- 1 file changed, 4 deletions(-) diff --git a/ios/Modules/NativeCookieModule/NativeCookieModule.swift b/ios/Modules/NativeCookieModule/NativeCookieModule.swift index a2494a0..cadb00a 100644 --- a/ios/Modules/NativeCookieModule/NativeCookieModule.swift +++ b/ios/Modules/NativeCookieModule/NativeCookieModule.swift @@ -14,8 +14,4 @@ public class NativeCookieModule: NSObject { } SessionCookieStore.clear() } - - // MARK: - Test / diagnostic helpers (DEBUG builds only) - // These methods are excluded from release builds to prevent cookie injection, - // session DoS, and keychain information disclosure from arbitrary JS callers. } From ae80d69b2aa2b95048bbcdda8209ba7e75b1f6d2 Mon Sep 17 00:00:00 2001 From: MxKevinBeqo Date: Tue, 14 Jul 2026 14:19:57 +0200 Subject: [PATCH 8/8] fix: consider more cases, improve code --- MendixNative.podspec | 2 +- ios/Modules/Encryption/EncryptedStorage.swift | 8 ++++++-- .../NativeCookieModule/ObjCExceptionCatcher.h | 13 +++++++++++++ .../NativeCookieModule/ObjCExceptionCatcher.m | 14 ++++++++++++++ .../NativeCookieModule/SessionCookieStore.swift | 17 ++++++++--------- 5 files changed, 42 insertions(+), 12 deletions(-) create mode 100644 ios/Modules/NativeCookieModule/ObjCExceptionCatcher.h create mode 100644 ios/Modules/NativeCookieModule/ObjCExceptionCatcher.m diff --git a/MendixNative.podspec b/MendixNative.podspec index c234757..d21b9a8 100644 --- a/MendixNative.podspec +++ b/MendixNative.podspec @@ -14,7 +14,7 @@ Pod::Spec.new do |s| s.source = { :git => "https://github.com/mendix/mendix-native.git", :tag => "#{s.version}" } s.source_files = "ios/**/*.{h,m,mm,cpp,swift}" - s.public_header_files = "ios/Modules/Helper/ReactHostHelper.h" + s.public_header_files = ["ios/Modules/Helper/ReactHostHelper.h", "ios/Modules/NativeCookieModule/ObjCExceptionCatcher.h"] s.private_header_files = "ios/TurboModules/**/*.h" s.dependency "SSZipArchive" diff --git a/ios/Modules/Encryption/EncryptedStorage.swift b/ios/Modules/Encryption/EncryptedStorage.swift index 4ec5e89..c0c1bf7 100644 --- a/ios/Modules/Encryption/EncryptedStorage.swift +++ b/ios/Modules/Encryption/EncryptedStorage.swift @@ -33,11 +33,15 @@ import Foundation kSecClass: kSecClassGenericPassword, kSecAttrAccount: key, kSecReturnData: kCFBooleanTrue as Any, - kSecMatchLimit: kSecMatchLimitOne + kSecMatchLimit: kSecMatchLimitOne, + kSecUseAuthenticationUI: kSecUseAuthenticationUIFail ] as CFDictionary var dataRef: CFTypeRef? let status = SecItemCopyMatching(query, &dataRef) - if status == errSecSuccess { + if status == errSecInteractionNotAllowed { + // Device locked or item requires auth — don't delete, resolve as absent + promise.resolve(NSNull()) + } else if status == errSecSuccess { guard let data = dataRef as? Data, let value = String(data: data, encoding: .utf8) else { promise.reject("An error occured while retrieving value", errorCode: Int(status)) return diff --git a/ios/Modules/NativeCookieModule/ObjCExceptionCatcher.h b/ios/Modules/NativeCookieModule/ObjCExceptionCatcher.h new file mode 100644 index 0000000..e93fd3d --- /dev/null +++ b/ios/Modules/NativeCookieModule/ObjCExceptionCatcher.h @@ -0,0 +1,13 @@ +#import + +NS_ASSUME_NONNULL_BEGIN + +@interface ObjCExceptionCatcher : NSObject + +/// Executes the block and returns its result. If an NSException is raised, +/// catches it and returns nil instead of crashing. ++ (nullable id)catchException:(id _Nullable (NS_NOESCAPE ^)(void))block; + +@end + +NS_ASSUME_NONNULL_END diff --git a/ios/Modules/NativeCookieModule/ObjCExceptionCatcher.m b/ios/Modules/NativeCookieModule/ObjCExceptionCatcher.m new file mode 100644 index 0000000..31e1811 --- /dev/null +++ b/ios/Modules/NativeCookieModule/ObjCExceptionCatcher.m @@ -0,0 +1,14 @@ +#import "ObjCExceptionCatcher.h" + +@implementation ObjCExceptionCatcher + ++ (nullable id)catchException:(id _Nullable (NS_NOESCAPE ^)(void))block { + @try { + return block(); + } @catch (NSException *exception) { + NSLog(@"ObjCExceptionCatcher: caught %@ — %@", exception.name, exception.reason); + return nil; + } +} + +@end diff --git a/ios/Modules/NativeCookieModule/SessionCookieStore.swift b/ios/Modules/NativeCookieModule/SessionCookieStore.swift index 0fd1bec..ea2dd2d 100644 --- a/ios/Modules/NativeCookieModule/SessionCookieStore.swift +++ b/ios/Modules/NativeCookieModule/SessionCookieStore.swift @@ -68,20 +68,19 @@ public class SessionCookieStore { let status = SecItemCopyMatching(query as CFDictionary, &item) if status == errSecInteractionNotAllowed { - // Oversized/blocked item — remove so it never prompts again - NSLog("SessionCookieStore: Blocked legacy item detected, clearing") - clear(key: key) + NSLog("SessionCookieStore: Item inaccessible (device locked), skipping restore") return nil } else if status == errSecSuccess, let data = item as? Data { - do { - let cookies = try NSKeyedUnarchiver.unarchivedObject(ofClasses: [NSArray.self, HTTPCookie.self], from: data) as? [HTTPCookie] - return cookies - } catch { - // Unarchiving failed (corrupt/oversized blob) — self-heal by removing - NSLog("SessionCookieStore: Failed to deserialize legacy cookies, clearing: \(error.localizedDescription)") + let result = ObjCExceptionCatcher.catchException { + try? NSKeyedUnarchiver.unarchivedObject(ofClasses: [NSArray.self, HTTPCookie.self], from: data) + } + + guard let cookies = result as? [HTTPCookie] else { + NSLog("SessionCookieStore: Failed to deserialize cookies, clearing") clear(key: key) return nil } + return cookies } else if status != errSecItemNotFound { // Any other unreadable state — delete to prevent repeated failures NSLog("SessionCookieStore: Unreadable legacy item (status: \(status)), clearing")