Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down
71 changes: 56 additions & 15 deletions android/src/main/java/io/approov/reactnative/ApproovService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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.
Expand All @@ -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");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
38 changes: 26 additions & 12 deletions ios/ApproovService.m
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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) {
Expand Down
44 changes: 44 additions & 0 deletions tests/ios/native/ApproovNativeTests.m
Original file line number Diff line number Diff line change
Expand Up @@ -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 =
Expand Down Expand Up @@ -615,6 +658,7 @@ int main(void) {
^{ TestInitializeTreatsFalseNilErrorAsAlreadyInitialized(); },
^{ TestInitializeRejectsNativeDifferentConfigurationError(); },
^{ TestInitializeWithEmptyConfigThenSdkFailurePreservesBootstrapState(); },
^{ TestInitializeWithSameConfigPreservesRuntimeConfiguration(); },
^{ TestMockStatusCompletionHandlersFire(); },
^{ TestMockErrorCompletionHandlersFire(); },
^{ TestMockUploadCompletionHandlersFire(); },
Expand Down