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 feb1505..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; @@ -687,6 +701,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,21 +721,38 @@ 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; - initialConfig = config; - isInitialized = 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. + // + // 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; + } clearEarliestNetworkRequestTime(); if (isApproovEnabled()) { Approov.setUserProperty("approov-react-native"); 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(); },