From 3b0d69507c62259fee108bbe38c6f08b2cd56cc2 Mon Sep 17 00:00:00 2001 From: MxKevinBeqo Date: Thu, 9 Jul 2026 15:12:13 +0200 Subject: [PATCH 1/5] 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/5] 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/5] 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/5] 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/5] 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,