From 831672b1dd7ad03e5d9a55dd1e8311e4f96092d3 Mon Sep 17 00:00:00 2001 From: Ezedike-egwom Collins Date: Thu, 30 Jul 2026 12:48:50 +0100 Subject: [PATCH] fix: validate biometric type at runtime to prevent spoofing --- src/__tests__/services/mobileAuth.test.ts | 26 +++++++++++++++++++++++ src/services/mobileAuth.ts | 23 +++++++++++++++++--- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/src/__tests__/services/mobileAuth.test.ts b/src/__tests__/services/mobileAuth.test.ts index 6f63f9c..c8afec5 100644 --- a/src/__tests__/services/mobileAuth.test.ts +++ b/src/__tests__/services/mobileAuth.test.ts @@ -56,7 +56,9 @@ import * as LocalAuthentication from 'expo-local-authentication'; import apiClient from '../../services/api/axios.config'; import { BiometricReenrollmentError, + isValidBiometricType, mobileAuthService, + VALID_BIOMETRIC_TYPES, } from '../../services/mobileAuth'; import * as secureStorage from '../../services/secureStorage'; @@ -587,4 +589,28 @@ describe('MobileAuthService — Biometric Re-enrollment Flow', () => { expect(result2).toEqual(MOCK_AUTH_RESULT); }); }); + + describe('isValidBiometricType', () => { + it.each(VALID_BIOMETRIC_TYPES)('should accept the valid biometric type "%s"', (type) => { + expect(isValidBiometricType(type)).toBe(true); + }); + + it.each([ + 'retina', + 'voice', + 'Fingerprint', + '', + ' ', + 'fingerprint ', + ])('should reject the invalid string "%s"', (value) => { + expect(isValidBiometricType(value)).toBe(false); + }); + + it.each([null, undefined, 1, true, {}, [], Symbol('face')])( + 'should reject the non-string value %p', + (value) => { + expect(isValidBiometricType(value)).toBe(false); + } + ); + }); }); diff --git a/src/services/mobileAuth.ts b/src/services/mobileAuth.ts index adb7576..85d099b 100644 --- a/src/services/mobileAuth.ts +++ b/src/services/mobileAuth.ts @@ -40,6 +40,18 @@ export interface SocialProvider { export type BiometricType = 'fingerprint' | 'face' | 'iris' | 'none'; +export const VALID_BIOMETRIC_TYPES: readonly BiometricType[] = ['fingerprint', 'face', 'iris', 'none']; + +/** + * Runtime type guard for BiometricType. Needed because biometric type + * values can come from mockable/abstract native modules, so a value + * claiming to be a BiometricType at compile time is not guaranteed to + * actually be one at runtime (e.g. in a mocked test environment). + */ +export function isValidBiometricType(value: unknown): value is BiometricType { + return typeof value === 'string' && (VALID_BIOMETRIC_TYPES as readonly string[]).includes(value); +} + // ─── Biometric re-enrollment error ──────────────────────────────────────────── /** @@ -293,13 +305,18 @@ class MobileAuthService { // expo-local-authentication returns an array of SupportedAuthenticationTypes // enum values. We map the first supported type to our BiometricType. - if (types.includes(1)) { + const resolvedType: BiometricType = types.includes(1) // BIOMETRIC = 1 (fingerprint, face, etc.) // On iOS we can't distinguish face vs fingerprint from this enum, // so we default to 'fingerprint' and let the UI adapt. - return 'fingerprint'; + ? 'fingerprint' + : 'none'; + + if (!isValidBiometricType(resolvedType)) { + throw new Error(`Invalid biometric type resolved: ${String(resolvedType)}`); } - return 'none'; + + return resolvedType; } catch { return 'none'; }