Skip to content

Commit 1f3da04

Browse files
committed
fix: validate setServiceMutatorType mask input
The proceed bitmask is bridged from JS as a double and was narrowed to int (Android) / NSInteger (iOS) with an unchecked cast. A fractional, NaN/Infinity, or out-of-32-bit-range value was silently truncated or coerced and could install a proceed policy other than the caller's intent (security-relevant: the mask decides which failure statuses may proceed). Reject such masks before applying the mutator on both platforms. Adds Android and iOS native regression tests.
1 parent 601978a commit 1f3da04

5 files changed

Lines changed: 138 additions & 1 deletion

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
- **iOS `approov.plist` is now optional (fixes a launch crash)**: When Approov is initialized natively from a bundled `approov.config`, the iOS module no longer raises `ApproovPropsNotFound` at launch if a bundled `approov.plist` is absent. It now logs and runs with default properties, matching the Android layer where a missing `approov.props` is tolerated. This lets an `approov.config`-only native integration run without shipping an empty placeholder `approov.plist`. A present-but-unreadable plist still raises, since that indicates a genuine misconfiguration. (Reported by PropertyGuru.)
55
- **Added — JS-selectable request policy (security-relevant)**: New `ApproovService.setServiceMutatorType(mask, { sign })` method plus `ApproovService.ReturnDecision` bit flags and `ApproovService.MutatorPreset` presets let an app choose the per-status proceed/block policy — and whether the outbound request is still HTTP Message Signed — entirely from JavaScript, with no native mutator code. The `mask` lists which Approov **failure** statuses may proceed; a failure status not in the mask blocks the request, surfacing identically on both platforms as a failed `fetch()` (`IOException` / `Network request failed` on Android, an `NSError` failure on iOS). Named presets (`DEFAULT`, `ALWAYS_PROCEED`, `PROCEED_IF_UNAVAILABLE`, `PROCEED_DEV_CLEARTEXT`) cover the common cases; the development-only `PROCEED_DEV_CLEARTEXT` forwards Metro's cleartext dev bundle, which the SDK reports as `BAD_URL` (issue #30). **Security caveat:** proceeding on `MITM_DETECTED` or `REJECTED` disables the protection those statuses provide, so keep those bits out of production masks unless you fully intend to. Pair with `setUseApproovStatusIfNoToken(true)` to surface the failure status to the backend in the token header. Behaviour is identical on Android and iOS.
66
- **Fixed — service mutator now resets on re-initialization (security-relevant)**: A configuration-change re-initialization previously left a custom service mutator installed; it now resets to the built-in message-signing default on both Android and iOS whenever the config actually changes. A same-config re-init still preserves the installed mutator, so re-apply `setServiceMutatorType` after a config-change re-init if you still need a custom policy.
7+
- **Hardened — `setServiceMutatorType` mask validation (security-relevant)**: The proceed bitmask is bridged from JavaScript as a floating-point number and is now rejected on both Android and iOS unless it is a finite, integral, 32-bit value. A fractional, `NaN`/`Infinity`, or out-of-range mask would previously have been silently truncated or coerced by the native narrowing cast, potentially installing a policy other than the one intended; such inputs now reject the promise instead of being applied. Covered by new Android and iOS native regression tests. (Copilot review, PR #32.)
78

89
## [3.5.15] - 2026-07-01
910
- **Gradle 9 Compatibility Fix**: Migrated the Android build from the unmaintained `com.github.johnrengelman.shadow` plugin 8.1.1 to the maintained fork `com.gradleup.shadow` 8.3.11. The old plugin fails on Gradle 9 with `MissingPropertyException: No such property: mode` (Gradle 9 removed `FileCopyDetails.mode`), halting the whole application build. The replacement uses the same `ShadowJar` task class and produces an identical shaded BouncyCastle jar; verified on Gradle 8.8, 8.10.2, and 9.0.0. Minimum supported Gradle remains 8.3 (React Native 0.76+ ships 8.10+), so no consumer action is required beyond updating the package.

android/src/main/java/io/approov/reactnative/ApproovService.java

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,19 @@ public static ApproovServiceMutator getServiceMutator() {
265265
@ReactMethod
266266
public void setServiceMutatorType(double maskDouble, boolean sign, Promise promise) {
267267
try {
268+
// The mask is bridged from JavaScript as a double. Reject any value that is
269+
// not a finite, integral, 32-bit quantity before narrowing to int: a
270+
// fractional value (e.g. 1.5), NaN/Infinity, or an out-of-range magnitude
271+
// would otherwise be silently truncated or coerced by the (int) cast and
272+
// could install a policy other than the one the caller intended. This is
273+
// security-relevant: the mask decides which failure statuses may proceed.
274+
if (Double.isNaN(maskDouble) || Double.isInfinite(maskDouble)
275+
|| maskDouble != Math.floor(maskDouble)
276+
|| maskDouble < Integer.MIN_VALUE || maskDouble > Integer.MAX_VALUE) {
277+
promise.reject("setServiceMutatorType",
278+
"invalid mutator mask: expected a finite 32-bit integer bitmask, got " + maskDouble);
279+
return;
280+
}
268281
int mask = (int) maskDouble;
269282
if (mask == MUTATOR_PRESET_DEFAULT) { // restore out-of-box signing default (sign flag N/A)
270283
ApproovDefaultMessageSigning signer = new ApproovDefaultMessageSigning();

android/src/test/java/io/approov/reactnative/ApproovServicePublicApiTest.java

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -256,6 +256,55 @@ public void isInterceptorActiveReturnsFalseWhenNoApproovInterceptorIsPresent() {
256256
}
257257
}
258258

259+
// Regression for the setServiceMutatorType mask validation (Copilot review, PR #32).
260+
// The proceed bitmask is bridged from JavaScript as a double; a non-finite, fractional,
261+
// or out-of-32-bit-range value must be rejected before it is narrowed to int, so it can
262+
// never be silently coerced into an unintended (security-relevant) proceed policy.
263+
@Test
264+
public void setServiceMutatorTypeRejectsNonFiniteOrNonIntegralMasks() {
265+
double[] invalidMasks = {
266+
1.5, // fractional
267+
Double.NaN, // not a number
268+
Double.POSITIVE_INFINITY, // +infinity
269+
Double.NEGATIVE_INFINITY, // -infinity
270+
(double) Integer.MAX_VALUE + 1.0, // above the signed 32-bit range
271+
(double) Integer.MIN_VALUE - 1.0, // below the signed 32-bit range
272+
};
273+
try (MockedStatic<Approov> approov = mockStatic(Approov.class)) {
274+
for (double mask : invalidMasks) {
275+
Promise promise = mock(Promise.class);
276+
277+
newService().setServiceMutatorType(mask, true, promise);
278+
279+
org.mockito.Mockito.verify(promise)
280+
.reject(org.mockito.ArgumentMatchers.eq("setServiceMutatorType"), anyString());
281+
org.mockito.Mockito.verify(promise, org.mockito.Mockito.never()).resolve(any());
282+
}
283+
}
284+
}
285+
286+
// The guard must not reject a well-formed mask: the DEFAULT sentinel (-1), an all-block
287+
// mask (0), and a composed in-range bitmask all pass validation and resolve.
288+
@Test
289+
public void setServiceMutatorTypeAcceptsValidIntegralMasks() {
290+
double[] validMasks = {
291+
-1.0, // MUTATOR_PRESET_DEFAULT — restore the built-in signing default
292+
0.0, // block every maskable failure status
293+
(double) (PolicyMutator.BIT_NO_NETWORK | PolicyMutator.BIT_POOR_NETWORK),
294+
};
295+
try (MockedStatic<Approov> approov = mockStatic(Approov.class)) {
296+
for (double mask : validMasks) {
297+
Promise promise = mock(Promise.class);
298+
299+
newService().setServiceMutatorType(mask, true, promise);
300+
301+
org.mockito.Mockito.verify(promise).resolve(any());
302+
org.mockito.Mockito.verify(promise, org.mockito.Mockito.never())
303+
.reject(org.mockito.ArgumentMatchers.eq("setServiceMutatorType"), anyString());
304+
}
305+
}
306+
}
307+
259308
private boolean hasPinHash(CertificatePinner pinner, String expectedHashBase64) throws Exception {
260309
for (Object pin : pinner.getPins()) {
261310
Object hash = pin.getClass().getMethod("getHash").invoke(pin);

ios/ApproovService.m

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -484,7 +484,23 @@ - (instancetype)init {
484484
resolver : (RCTPromiseResolveBlock)resolve
485485
rejecter : (RCTPromiseRejectBlock)reject) {
486486
@try {
487-
NSInteger maskValue = (NSInteger)mask;
487+
// The mask is bridged from JavaScript as a double. Reject any value that is
488+
// not a finite, integral, 32-bit quantity before narrowing: a fractional
489+
// value (e.g. 1.5), NaN/Infinity, or an out-of-range magnitude would
490+
// otherwise be silently truncated or coerced and could install a policy
491+
// other than the one the caller intended. This is security-relevant: the
492+
// mask decides which failure statuses may proceed. The in-range check is
493+
// evaluated first so the round-trip cast that verifies integrality is only
494+
// reached for values already known to be within int32_t range.
495+
if (!(mask >= INT32_MIN && mask <= INT32_MAX) || mask != (double)(int32_t)mask) {
496+
reject(@"setServiceMutatorType",
497+
[NSString stringWithFormat:
498+
@"invalid mutator mask: expected a finite 32-bit integer bitmask, got %g",
499+
mask],
500+
nil);
501+
return;
502+
}
503+
int32_t maskValue = (int32_t)mask;
488504
if (maskValue == -1) {
489505
// MutatorPreset.DEFAULT: restore the built-in message-signing default.
490506
[[ApproovServiceMutatorBridge shared] resetToDefault];

tests/ios/native/ApproovNativeTests.m

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,10 @@ - (void)isInitialized:(RCTPromiseResolveBlock)resolve
6363
rejecter:(RCTPromiseRejectBlock)reject;
6464
- (void)isApproovEnabled:(RCTPromiseResolveBlock)resolve
6565
rejecter:(RCTPromiseRejectBlock)reject;
66+
- (void)setServiceMutatorType:(double)mask
67+
sign:(BOOL)sign
68+
resolver:(RCTPromiseResolveBlock)resolve
69+
rejecter:(RCTPromiseRejectBlock)reject;
6670
@end
6771

6872
@implementation RCTTestNetworkDelegate
@@ -651,6 +655,59 @@ static void TestNSURLSessionExposesStatusHeaderWhenTokenMissingAndAllowed(void)
651655
[NSURLProtocol unregisterClass:[CaptureProtocol class]];
652656
}
653657

658+
// Regression for the setServiceMutatorType mask validation (Copilot review, PR #32).
659+
// The proceed bitmask is bridged from JavaScript as a double; a non-finite, fractional,
660+
// or out-of-32-bit-range value must be rejected before it is narrowed, so it can never be
661+
// silently coerced into an unintended (security-relevant) proceed policy. A well-formed
662+
// mask (including the DEFAULT sentinel -1) must still be accepted. This mirrors the Android
663+
// ApproovServicePublicApiTest coverage so both platforms enforce identical behaviour.
664+
static void TestSetServiceMutatorTypeValidatesMask(void) {
665+
const double invalidMasks[] = {
666+
1.5, // fractional
667+
NAN, // not a number
668+
INFINITY, // +infinity
669+
-INFINITY, // -infinity
670+
2147483648.0, // 2^31, above the signed 32-bit range
671+
-2147483649.0 // -(2^31) - 1, below the signed 32-bit range
672+
};
673+
for (size_t i = 0; i < sizeof(invalidMasks) / sizeof(invalidMasks[0]); i++) {
674+
ApproovService *service = FreshService();
675+
__block BOOL didResolve = NO;
676+
__block NSString *rejectionCode = nil;
677+
[service setServiceMutatorType:invalidMasks[i]
678+
sign:YES
679+
resolver:^(__unused id value) { didResolve = YES; }
680+
rejecter:^(NSString *code, __unused NSString *message,
681+
__unused NSError *error) { rejectionCode = code; }];
682+
AssertTrue(!didResolve,
683+
[NSString stringWithFormat:@"invalid mask %g should not resolve",
684+
invalidMasks[i]]);
685+
AssertEqualObjects(@"setServiceMutatorType", rejectionCode,
686+
[NSString stringWithFormat:@"invalid mask %g should reject with setServiceMutatorType",
687+
invalidMasks[i]]);
688+
}
689+
690+
const double validMasks[] = {
691+
-1.0, // MutatorPreset.DEFAULT — restore the built-in signing default
692+
0.0, // block every maskable failure status
693+
20.0 // BIT_NO_NETWORK (1 << 3) | BIT_POOR_NETWORK (1 << 4)
694+
};
695+
for (size_t i = 0; i < sizeof(validMasks) / sizeof(validMasks[0]); i++) {
696+
ApproovService *service = FreshService();
697+
__block BOOL didResolve = NO;
698+
__block NSString *rejectionCode = nil;
699+
[service setServiceMutatorType:validMasks[i]
700+
sign:YES
701+
resolver:^(__unused id value) { didResolve = YES; }
702+
rejecter:^(NSString *code, __unused NSString *message,
703+
__unused NSError *error) { rejectionCode = code; }];
704+
AssertTrue(didResolve,
705+
[NSString stringWithFormat:@"valid mask %g should resolve", validMasks[i]]);
706+
AssertTrue(rejectionCode == nil,
707+
[NSString stringWithFormat:@"valid mask %g should not reject", validMasks[i]]);
708+
}
709+
}
710+
654711
int main(void) {
655712
@autoreleasepool {
656713
NSArray<void (^)(void)> *tests = @[
@@ -666,6 +723,7 @@ int main(void) {
666723
^{ TestReactFetchStylePoorNetworkReturnsSyntheticResponseWithoutRecursion(); },
667724
^{ TestFetchWithApproovRejectsInvalidURLs(); },
668725
^{ TestNSURLSessionExposesStatusHeaderWhenTokenMissingAndAllowed(); },
726+
^{ TestSetServiceMutatorTypeValidatesMask(); },
669727
];
670728

671729
for (void (^testBlock)(void) in tests) {

0 commit comments

Comments
 (0)