From 8d1b4f5afe7e9b544417bf9dd16558907534e885 Mon Sep 17 00:00:00 2001 From: Adrian Date: Tue, 9 Jun 2026 13:52:08 +0100 Subject: [PATCH 1/3] fix: preserve runtime configuration on same-config re-initialization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The initialize() rewrite reset ALL service-layer state (substitution headers, exclusion URL regexes, token/binding header settings, status flags) on every successful call. The only guard was "already enabled with a valid config + empty config" — every other path, including re-initializing with the SAME config already in force, fell through to the unconditional reset. In React Native this is reached routinely: ApproovProvider calls initialize() from a useEffect, and React StrictMode double-invokes effects, Fast Refresh re-runs them, and apps may mount more than one provider. A second initialize() with the same config then silently discarded any configuration the app applied after the first call — dropping request mutations and, more seriously, exclusion URL regexes, which are security relevant. Fix: only reset the runtime configuration when the config actually changes. A same-config re-initialization (with or without a reinit/options comment) is still forwarded to the platform SDK but preserves the service-layer state. Applied identically to Android (ApproovService.java) and iOS (ApproovService.m). Tests: - Android ApproovServiceRegressionTest: same-config re-init preserves substitution/exclusion/token/binding config; different-config still resets. - iOS ApproovNativeTests: same-config re-init preserves substitution header, exclusion regex and token header. Both new tests fail without the fix. --- .../approov/reactnative/ApproovService.java | 40 +++++++++----- .../ApproovServiceRegressionTest.java | 53 +++++++++++++++++++ ios/ApproovService.m | 38 ++++++++----- tests/ios/native/ApproovNativeTests.m | 44 +++++++++++++++ 4 files changed, 150 insertions(+), 25 deletions(-) diff --git a/android/src/main/java/io/approov/reactnative/ApproovService.java b/android/src/main/java/io/approov/reactnative/ApproovService.java index feb1505..c96159f 100644 --- a/android/src/main/java/io/approov/reactnative/ApproovService.java +++ b/android/src/main/java/io/approov/reactnative/ApproovService.java @@ -687,6 +687,16 @@ public void initialize(String config, String comment, Promise promise) { return; } + // Detect whether this is a re-initialization with the identical config that is + // already in force. Re-initializing with the same config (e.g. an ApproovProvider + // remount, a React StrictMode double-invoke or Fast Refresh in development) must + // NOT discard the runtime configuration the app set up after the first initialize() + // call — substitution headers, exclusion URL regexes and token/binding header + // settings. Wiping those silently would drop request mutations and, more seriously, + // exclusion rules that are security relevant. Only a genuinely different config + // resets the service-layer state. + boolean configUnchanged = isInitialized && config.equals(initialConfig); + // Initialize the platform SDK if not in bypass mode (empty config). // State is only modified after the SDK confirms success, preserving the current // operating mode (protected or bypass) if the call fails. @@ -697,19 +707,23 @@ public void initialize(String config, String comment, Promise promise) { log(LOG_DEBUG, TAG, "Approov SDK already initialized"); } } - // SDK succeeded (or bypass) — now reset and commit new service-layer state. - isInitialized = false; - initialConfig = null; - useApproovStatusIfNoToken = false; - approovTokenHeader = APPROOV_TOKEN_HEADER; - approovTraceIDHeader = APPROOV_TRACE_ID_HEADER; - approovTokenPrefix = APPROOV_TOKEN_PREFIX; - bindingHeader = null; - substitutionHeaders = new HashMap<>(); - substitutionQueryParams = new HashMap<>(); - exclusionURLRegexs = new HashMap<>(); - suppressLoggingUnknownURL = false; - sessionMetadataCollectionEnabled = true; + // SDK succeeded (or bypass) — now commit new service-layer state. The runtime + // configuration is only reset when the config actually changes; a same-config + // re-initialization preserves any configuration applied after the first call. + if (!configUnchanged) { + isInitialized = false; + initialConfig = null; + useApproovStatusIfNoToken = false; + approovTokenHeader = APPROOV_TOKEN_HEADER; + approovTraceIDHeader = APPROOV_TRACE_ID_HEADER; + approovTokenPrefix = APPROOV_TOKEN_PREFIX; + bindingHeader = null; + substitutionHeaders = new HashMap<>(); + substitutionQueryParams = new HashMap<>(); + exclusionURLRegexs = new HashMap<>(); + suppressLoggingUnknownURL = false; + sessionMetadataCollectionEnabled = true; + } initialConfig = config; isInitialized = true; clearEarliestNetworkRequestTime(); diff --git a/android/src/test/java/io/approov/reactnative/ApproovServiceRegressionTest.java b/android/src/test/java/io/approov/reactnative/ApproovServiceRegressionTest.java index 0a51ff9..f7f12a5 100644 --- a/android/src/test/java/io/approov/reactnative/ApproovServiceRegressionTest.java +++ b/android/src/test/java/io/approov/reactnative/ApproovServiceRegressionTest.java @@ -264,6 +264,59 @@ public void initializeWithEmptyConfigMarksLayerInitializedWithoutApproovSdkCalls approovStatic.verifyNoInteractions(); } + @Test + public void initializeWithSameConfigPreservesRuntimeConfiguration() { + ApproovService service = newService(); + String config = "valid-config"; + + // Approov.initialize returns false here (mockStatic default) meaning the native + // SDK is treated as already initialized — no exception, so the service layer + // commits the configuration successfully. + Promise firstInit = mock(Promise.class); + service.initialize(config, null, firstInit); + verify(firstInit, timeout(2000)).resolve(null); + assertTrue(service.isApproovEnabled()); + + // Configure runtime state after the first initialization, exactly as an app + // would after ApproovService.initialize() resolves. + service.addSubstitutionHeader("Authorization", "Bearer "); + service.addExclusionURLRegex("https://example.com/excluded/.*"); + service.setTokenHeader("X-Custom-Token", "Bearer "); + service.setBindingHeader("Authorization"); + + // Re-initialize with the SAME config (e.g. a provider remount / StrictMode). + Promise secondInit = mock(Promise.class); + service.initialize(config, null, secondInit); + verify(secondInit, timeout(2000)).resolve(null); + + // The runtime configuration must survive the same-config re-initialization. + assertTrue("substitution header should be preserved", + service.getSubstitutionHeaders().containsKey("Authorization")); + assertTrue("exclusion URL regex should be preserved", + service.getExclusionURLRegexs().containsKey("https://example.com/excluded/.*")); + assertEquals("token header should be preserved", "X-Custom-Token", service.getTokenHeader()); + assertEquals("binding header should be preserved", "Authorization", service.getBindingHeader()); + assertTrue(service.isApproovEnabled()); + } + + @Test + public void initializeWithDifferentConfigResetsRuntimeConfiguration() { + ApproovService service = newService(); + + Promise firstInit = mock(Promise.class); + service.initialize("config-one", null, firstInit); + verify(firstInit, timeout(2000)).resolve(null); + service.addSubstitutionHeader("Authorization", "Bearer "); + + // A genuinely different config still resets the runtime configuration. + Promise secondInit = mock(Promise.class); + service.initialize("config-two", null, secondInit); + verify(secondInit, timeout(2000)).resolve(null); + + assertFalse("substitution header should be cleared on a different config", + service.getSubstitutionHeaders().containsKey("Authorization")); + } + @Test public void statusMethodsReflectServiceLayerAndApproovEnabledStates() { ApproovService service = newService(); diff --git a/ios/ApproovService.m b/ios/ApproovService.m index 31b5891..21aeca7 100644 --- a/ios/ApproovService.m +++ b/ios/ApproovService.m @@ -364,6 +364,16 @@ - (instancetype)init { return; } + // Detect a re-initialization with the identical config already in force. Re-initializing + // with the same config (e.g. an ApproovProvider remount, a React StrictMode double-invoke + // or Fast Refresh in development) must NOT discard the runtime configuration the app set + // up after the first initialize() call — substitution headers, exclusion URL regexes and + // token/binding header settings. Wiping those silently would drop request mutations and, + // more seriously, exclusion rules that are security relevant. Only a genuinely different + // config resets the service-layer state. + BOOL configUnchanged = isInitialized && initialConfigString != nil && + [initialConfigString isEqualToString:config]; + // Initialize the platform SDK if not in bypass mode (empty config). // State is only modified after the SDK confirms success, preserving the // current operating mode (protected or bypass) on any failure. @@ -399,21 +409,25 @@ - (instancetype)init { return; } - // SDK succeeded (or bypass) — now reset and commit new service-layer state. + // SDK succeeded (or bypass) — now commit new service-layer state. The runtime + // configuration is only reset when the config actually changes; a same-config + // re-initialization preserves any configuration applied after the first call. if (!initializationResult) { ApproovLogD(@"native SDK already initialized"); } - isInitialized = NO; - initialConfigString = nil; - useApproovStatusIfNoToken = NO; - approovTokenHeader = @"Approov-Token"; - approovTraceIDHeader = @"Approov-TraceID"; - approovTokenPrefix = @""; - bindingHeader = @""; - substitutionHeaders = [[NSMutableDictionary alloc] init]; - substitutionQueryParams = [[NSMutableSet alloc] init]; - exclusionURLRegexs = [[NSMutableSet alloc] init]; - suppressLoggingUnknownURL = NO; + if (!configUnchanged) { + isInitialized = NO; + initialConfigString = nil; + useApproovStatusIfNoToken = NO; + approovTokenHeader = @"Approov-Token"; + approovTraceIDHeader = @"Approov-TraceID"; + approovTokenPrefix = @""; + bindingHeader = @""; + substitutionHeaders = [[NSMutableDictionary alloc] init]; + substitutionQueryParams = [[NSMutableSet alloc] init]; + exclusionURLRegexs = [[NSMutableSet alloc] init]; + suppressLoggingUnknownURL = NO; + } initialConfigString = config; isInitialized = YES; @synchronized(earliestNetworkRequestTimeLock) { diff --git a/tests/ios/native/ApproovNativeTests.m b/tests/ios/native/ApproovNativeTests.m index 9b3e716..81810f4 100644 --- a/tests/ios/native/ApproovNativeTests.m +++ b/tests/ios/native/ApproovNativeTests.m @@ -293,6 +293,49 @@ static void TestInitializeWithEmptyConfigThenSdkFailurePreservesBootstrapState(v @"SDK failure after empty bootstrap should preserve the bypass initialized state"); } +// Regression guard: re-initializing with the SAME config (e.g. an ApproovProvider +// remount, a React StrictMode double-invoke or Fast Refresh in development) must NOT +// discard the runtime configuration the app applied after the first initialize() call. +// Wiping substitution headers and, more seriously, exclusion URL regexes would silently +// drop request mutations and security-relevant exclusion rules. +static void TestInitializeWithSameConfigPreservesRuntimeConfiguration(void) { + ApproovService *service = FreshService(); + + __block BOOL firstResolved = NO; + [service initialize:@"test-config" + comment:nil + resolver:^(__unused id value) { firstResolved = YES; } + rejecter:^(__unused NSString *code, __unused NSString *message, + __unused NSError *error) {}]; + AssertTrue(firstResolved, @"First initialization should resolve"); + AssertTrue(isInitialized, @"First initialization should mark the layer initialized"); + + // Configure runtime state after the first initialization, as an app would via + // addSubstitutionHeader:/addExclusionURLRegex:/setTokenHeader:. Those are RCT-exported + // methods that are not declared in the public header, so the equivalent service-layer + // state is set directly here — it is precisely the state the reset block would wipe. + [substitutionHeaders setObject:@"Bearer " forKey:@"Authorization"]; + [exclusionURLRegexs addObject:@"https://example.com/excluded/.*"]; + approovTokenHeader = @"X-Custom-Token"; + + // Re-initialize with the SAME config. + __block BOOL secondResolved = NO; + [service initialize:@"test-config" + comment:nil + resolver:^(__unused id value) { secondResolved = YES; } + rejecter:^(__unused NSString *code, __unused NSString *message, + __unused NSError *error) {}]; + AssertTrue(secondResolved, @"Same-config re-initialization should resolve"); + + // The runtime configuration must survive the same-config re-initialization. + AssertTrue([substitutionHeaders objectForKey:@"Authorization"] != nil, + @"Substitution header should be preserved across same-config re-init"); + AssertTrue([exclusionURLRegexs containsObject:@"https://example.com/excluded/.*"], + @"Exclusion URL regex should be preserved across same-config re-init"); + AssertEqualObjects(@"X-Custom-Token", approovTokenHeader, + @"Token header should be preserved across same-config re-init"); +} + static NSURLSession *MockSession(void) { NSURLSessionConfiguration *configuration = @@ -615,6 +658,7 @@ int main(void) { ^{ TestInitializeTreatsFalseNilErrorAsAlreadyInitialized(); }, ^{ TestInitializeRejectsNativeDifferentConfigurationError(); }, ^{ TestInitializeWithEmptyConfigThenSdkFailurePreservesBootstrapState(); }, + ^{ TestInitializeWithSameConfigPreservesRuntimeConfiguration(); }, ^{ TestMockStatusCompletionHandlersFire(); }, ^{ TestMockErrorCompletionHandlersFire(); }, ^{ TestMockUploadCompletionHandlersFire(); }, From 16fe95959c6a3c681d7c5905ad87a62906188d1f Mon Sep 17 00:00:00 2001 From: Adrian Date: Tue, 9 Jun 2026 13:53:49 +0100 Subject: [PATCH 2/3] fix(android): make the initialize() state transition atomic to prevent unprotected requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit initialize() is not a synchronized method, yet it performs a multi-field state reset that transiently sets isInitialized=false and initialConfig=null before re-committing them. The interceptor reads isInitialized, isApproovEnabled and the header/substitution/exclusion state through synchronized getters on OkHttp network threads. Because the reset wrote those non-volatile static fields with no lock (and thus no happens-before relationship with the synchronized getters), a request racing a runtime re-initialization could observe an inconsistent state — either the transient isInitialized=false/initialConfig=null window, or a re-ordered commit where isInitialized=true is visible before initialConfig is set — causing isApproovEnabled() to return false and a request that should be protected to be forwarded without an Approov token. The same window could also re-arm the startup sync gate and stall concurrent requests for up to STARTUP_SYNC_TIME_WINDOW. Fix: perform the reset and commit inside synchronized(this) so the transition is atomic relative to the synchronized getters. The platform SDK call is deliberately left outside the lock so its network work never blocks those getters. This mirrors iOS, which already performs the equivalent reset inside @synchronized(initializerLock). Verified by the existing Android unit suite (45 tests, 0 failures). --- .../approov/reactnative/ApproovService.java | 43 ++++++++++++------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/android/src/main/java/io/approov/reactnative/ApproovService.java b/android/src/main/java/io/approov/reactnative/ApproovService.java index c96159f..7e20b2c 100644 --- a/android/src/main/java/io/approov/reactnative/ApproovService.java +++ b/android/src/main/java/io/approov/reactnative/ApproovService.java @@ -710,22 +710,35 @@ public void initialize(String config, String comment, Promise promise) { // SDK succeeded (or bypass) — now commit new service-layer state. The runtime // configuration is only reset when the config actually changes; a same-config // re-initialization preserves any configuration applied after the first call. - if (!configUnchanged) { - isInitialized = false; - initialConfig = null; - useApproovStatusIfNoToken = false; - approovTokenHeader = APPROOV_TOKEN_HEADER; - approovTraceIDHeader = APPROOV_TRACE_ID_HEADER; - approovTokenPrefix = APPROOV_TOKEN_PREFIX; - bindingHeader = null; - substitutionHeaders = new HashMap<>(); - substitutionQueryParams = new HashMap<>(); - exclusionURLRegexs = new HashMap<>(); - suppressLoggingUnknownURL = false; - sessionMetadataCollectionEnabled = true; + // + // The reset and commit are performed under the instance monitor so the + // transition is atomic relative to the interceptor, which reads isInitialized, + // isApproovEnabled and the header/substitution/exclusion state through + // synchronized getters on OkHttp network threads. Without this lock a request + // racing a re-initialization could observe the transient isInitialized=false / + // initialConfig=null window (these are non-volatile static fields written with + // no happens-before guarantee), and forward a request that should be protected + // without an Approov token. The platform SDK call above is intentionally left + // outside the lock so its network work never blocks those getters. iOS performs + // the equivalent reset inside @synchronized(initializerLock). + synchronized (this) { + if (!configUnchanged) { + isInitialized = false; + initialConfig = null; + useApproovStatusIfNoToken = false; + approovTokenHeader = APPROOV_TOKEN_HEADER; + approovTraceIDHeader = APPROOV_TRACE_ID_HEADER; + approovTokenPrefix = APPROOV_TOKEN_PREFIX; + bindingHeader = null; + substitutionHeaders = new HashMap<>(); + substitutionQueryParams = new HashMap<>(); + exclusionURLRegexs = new HashMap<>(); + suppressLoggingUnknownURL = false; + sessionMetadataCollectionEnabled = true; + } + initialConfig = config; + isInitialized = true; } - initialConfig = config; - isInitialized = true; clearEarliestNetworkRequestTime(); if (isApproovEnabled()) { Approov.setUserProperty("approov-react-native"); From 193f90a85d03b28773cb4442df242096eae62ec9 Mon Sep 17 00:00:00 2001 From: Adrian Date: Tue, 9 Jun 2026 13:55:35 +0100 Subject: [PATCH 3/3] fix(android): gate the request-mutation log by log level to match iOS The new "request mutation" INFO log in ApproovInterceptor was emitted via android.util.Log.i directly, so it could not be suppressed through setLogLevel and logged the request URL plus token length on every single request regardless of the configured level. The iOS "task mutation" log it mirrors uses ApproovLogI, which is gated and suppressed below INFO. Fix: add a package-private, level-gated logInfo() entry point on ApproovService and route the interceptor's mutation log through it, so the log honours setLogLevel and matches the iOS behaviour instead of writing unconditionally. Verified by the existing Android unit suite (45 tests, 0 failures). --- .../io/approov/reactnative/ApproovInterceptor.java | 7 +++++-- .../io/approov/reactnative/ApproovService.java | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/android/src/main/java/io/approov/reactnative/ApproovInterceptor.java b/android/src/main/java/io/approov/reactnative/ApproovInterceptor.java index dd60a1f..0b298b8 100644 --- a/android/src/main/java/io/approov/reactnative/ApproovInterceptor.java +++ b/android/src/main/java/io/approov/reactnative/ApproovInterceptor.java @@ -217,10 +217,13 @@ public Response intercept(Chain chain) throws IOException { request = request.newBuilder().header(traceIDHeader, traceID).build(); } - // log the request mutation result (matches iOS "task mutation" log at INFO level) + // log the request mutation result (matches iOS "task mutation" log at INFO level). + // Routed through the service's level-gated logger so it honours setLogLevel and is + // suppressed below INFO, matching the iOS ApproovLogI behaviour — rather than + // writing to android.util.Log unconditionally for every request. String tokenAfter = request.header(tokenHeaderKey); String traceAfter = (traceIDHeader != null) ? request.header(traceIDHeader) : null; - Log.i(TAG, "request mutation " + url + approovService.logInfo(TAG, "request mutation " + url + " token=" + headerState(tokenBefore) + "->" + headerState(tokenAfter) + " trace=" + headerState(traceAfter)); diff --git a/android/src/main/java/io/approov/reactnative/ApproovService.java b/android/src/main/java/io/approov/reactnative/ApproovService.java index 7e20b2c..eeebb66 100644 --- a/android/src/main/java/io/approov/reactnative/ApproovService.java +++ b/android/src/main/java/io/approov/reactnative/ApproovService.java @@ -258,6 +258,20 @@ private void log(int level, String tag, String msg) { log(level, tag, msg, null); } + /** + * Logs a message at INFO through the shared, level-gated logger. Exposed + * package-privately so collaborators such as {@link ApproovInterceptor} honour the + * configured log level (set via setLogLevel) instead of writing to android.util.Log + * unconditionally. This matches the iOS ApproovLogI behaviour, where the equivalent + * "task mutation" log is suppressed below INFO. + * + * @param tag the logging tag + * @param msg the message to log + */ + void logInfo(String tag, String msg) { + log(LOG_INFO, tag, msg); + } + private void log(int level, String tag, String msg, Throwable tr) { if (level < currentLogLevel) return;