diff --git a/package-lock.json b/package-lock.json index 73479daa..5db8ab01 100644 --- a/package-lock.json +++ b/package-lock.json @@ -62,6 +62,7 @@ "react-native-web": "~0.21.0", "react-native-worklets": "0.5.1", "socket.io-client": "^4.8.3", + "zod": "^3.23.0", "zustand": "^5.0.10" }, "devDependencies": { @@ -20058,6 +20059,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/zustand": { "version": "5.0.14", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", @@ -33639,6 +33649,11 @@ "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==" }, + "zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==" + }, "zustand": { "version": "5.0.14", "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", diff --git a/package.json b/package.json index 02a8c8d0..d0ef7b97 100644 --- a/package.json +++ b/package.json @@ -104,6 +104,7 @@ "react-native-web": "~0.21.0", "react-native-worklets": "0.5.1", "socket.io-client": "^4.8.3", + "zod": "^3.23.0", "zustand": "^5.0.10" }, "devDependencies": { diff --git a/src/__tests__/utils/notificationHandlers.test.ts b/src/__tests__/utils/notificationHandlers.test.ts index 016736dd..60542c73 100644 --- a/src/__tests__/utils/notificationHandlers.test.ts +++ b/src/__tests__/utils/notificationHandlers.test.ts @@ -1,3 +1,6 @@ +import * as Sentry from '@sentry/react-native'; +import * as Notifications from 'expo-notifications'; + import { NotificationType, NotificationData } from '../../types/notifications'; import { setNavigationRef, @@ -6,11 +9,25 @@ import { handleLearningReminder, handleAchievementUnlock, handleCommunityActivity, + handleNotificationResponse, buildDeepLink, parseDeepLink, validateNotificationPayload, } from '../../utils/notificationHandlers'; +jest.mock('@sentry/react-native', () => ({ + captureMessage: jest.fn(), +})); + +jest.mock('../../store/notificationStore', () => ({ + useNotificationStore: { + getState: jest.fn(() => ({ + isNotificationTypeEnabled: jest.fn(() => true), + recordEngagement: jest.fn(), + })), + }, +})); + describe('notificationHandlers', () => { const mockNavigate = jest.fn(); const mockIsReady = jest.fn(); @@ -257,6 +274,88 @@ describe('notificationHandlers', () => { }); }); +describe('screenName security gate (handleNotificationResponse)', () => { + const mockSentryCaptureMessage = Sentry.captureMessage as jest.Mock; + + function makeResponse(data: Record): Notifications.NotificationResponse { + return { + notification: { + request: { + content: { data }, + }, + }, + } as unknown as Notifications.NotificationResponse; + } + + beforeEach(() => { + jest.clearAllMocks(); + mockIsReady.mockReturnValue(true); + setNavigationRef({ + navigate: mockNavigate, + isReady: mockIsReady, + }); + }); + + it('navigates to default screen when screenName is allowlisted', () => { + handleNotificationResponse( + makeResponse({ + type: NotificationType.COURSE_UPDATE, + screenName: 'CourseDetail', + courseId: 'c-1', + }) + ); + expect(mockNavigate).toHaveBeenCalledWith('CourseDetail', { courseId: 'c-1' }); + expect(mockSentryCaptureMessage).not.toHaveBeenCalled(); + }); + + it('blocks navigation and warns when screenName is not in allowlist', () => { + handleNotificationResponse( + makeResponse({ + type: NotificationType.COURSE_UPDATE, + screenName: 'AdminPanel', + }) + ); + expect(mockNavigate).not.toHaveBeenCalled(); + expect(mockSentryCaptureMessage).toHaveBeenCalledWith( + expect.stringContaining('AdminPanel'), + { level: 'warning' } + ); + }); + + it('blocks navigation and warns when screenName has invalid type', () => { + handleNotificationResponse( + makeResponse({ + type: NotificationType.COURSE_UPDATE, + screenName: 42, + }) + ); + expect(mockNavigate).not.toHaveBeenCalled(); + expect(mockSentryCaptureMessage).toHaveBeenCalledWith( + 'Notification payload has invalid screenName', + { level: 'warning' } + ); + }); + + it('navigates to default screen when screenName is absent', () => { + handleNotificationResponse( + makeResponse({ + type: NotificationType.COURSE_UPDATE, + courseId: 'c-1', + }) + ); + expect(mockNavigate).toHaveBeenCalledWith('CourseDetail', { courseId: 'c-1' }); + expect(mockSentryCaptureMessage).not.toHaveBeenCalled(); + }); + + it('does not crash when response has no data at all', () => { + handleNotificationResponse( + makeResponse({}) + ); + expect(mockNavigate).not.toHaveBeenCalled(); + expect(mockSentryCaptureMessage).not.toHaveBeenCalled(); + }); +}); + describe('validateNotificationPayload', () => { it('returns a valid NotificationData for a clean payload', () => { const raw = { type: NotificationType.COURSE_UPDATE, courseId: 'c-1' }; @@ -346,4 +445,26 @@ describe('validateNotificationPayload', () => { deepLink: 'teachlink://community/p-1', }); }); + + it('includes screenName when present and valid', () => { + const raw = { + type: NotificationType.COURSE_UPDATE, + screenName: 'Home', + }; + const result = validateNotificationPayload(raw); + expect(result).toEqual({ + type: NotificationType.COURSE_UPDATE, + screenName: 'Home', + }); + }); + + it('drops screenName when it is not a string', () => { + const raw = { + type: NotificationType.COURSE_UPDATE, + screenName: 42, + }; + const result = validateNotificationPayload(raw); + expect(result).toBeDefined(); + expect(result?.screenName).toBeUndefined(); + }); }); diff --git a/src/config/security.ts b/src/config/security.ts new file mode 100644 index 00000000..1c27450c --- /dev/null +++ b/src/config/security.ts @@ -0,0 +1,12 @@ +export const NOTIFICATION_SCREEN_ALLOWLIST = new Set([ + 'Home', + 'Courses', + 'CourseDetail', + 'Messages', + 'Chat', + 'Learning', + 'Community', + 'CommunityPost', + 'Achievements', + 'AchievementDetail', +] as const); diff --git a/src/types/notifications.ts b/src/types/notifications.ts index c047e611..5137cc5f 100644 --- a/src/types/notifications.ts +++ b/src/types/notifications.ts @@ -28,6 +28,7 @@ export interface NotificationData { achievementId?: string; postId?: string; deepLink?: string; + screenName?: string; } export interface StoredNotification { diff --git a/src/utils/notificationHandlers.ts b/src/utils/notificationHandlers.ts index 8895809f..10b7239d 100644 --- a/src/utils/notificationHandlers.ts +++ b/src/utils/notificationHandlers.ts @@ -1,8 +1,11 @@ import * as Notifications from 'expo-notifications'; +import * as Sentry from '@sentry/react-native'; +import { z } from 'zod'; import logger from './logger'; import { useNotificationStore } from '../store/notificationStore'; import { NotificationData, NotificationType } from '../types/notifications'; +import { NOTIFICATION_SCREEN_ALLOWLIST } from '../config/security'; type NavigationRef = { navigate: (screen: string, params?: Record) => void; @@ -14,6 +17,8 @@ let navigationRef: NavigationRef | null = null; /** Keys that must never appear in a trusted notification payload. */ const BLOCKED_KEYS = new Set(['__proto__', 'constructor', 'prototype']); +const screenNameSchema = z.string().min(1); + function isNotificationType(value: unknown): value is NotificationType { return ( typeof value === 'string' && Object.values(NotificationType).includes(value as NotificationType) @@ -48,14 +53,15 @@ export function validateNotificationPayload(value: unknown): NotificationData | // Build the result from an explicit allow-list — never spread the raw object return { - type: maybeData.type, - courseId: typeof maybeData.courseId === 'string' ? maybeData.courseId : undefined, + type: raw.type, + courseId: typeof raw.courseId === 'string' ? raw.courseId : undefined, conversationId: - typeof maybeData.conversationId === 'string' ? maybeData.conversationId : undefined, + typeof raw.conversationId === 'string' ? raw.conversationId : undefined, achievementId: - typeof maybeData.achievementId === 'string' ? maybeData.achievementId : undefined, - postId: typeof maybeData.postId === 'string' ? maybeData.postId : undefined, - deepLink: typeof maybeData.deepLink === 'string' ? maybeData.deepLink : undefined, + typeof raw.achievementId === 'string' ? raw.achievementId : undefined, + postId: typeof raw.postId === 'string' ? raw.postId : undefined, + deepLink: typeof raw.deepLink === 'string' ? raw.deepLink : undefined, + screenName: typeof raw.screenName === 'string' ? raw.screenName : undefined, }; } @@ -90,6 +96,24 @@ export function handleNotificationResponse(response: Notifications.NotificationR useNotificationStore.getState().recordEngagement(); + // screenName validation + allowlist check (security gate) + if (data.screenName) { + const parsed = screenNameSchema.safeParse(data.screenName); + if (!parsed.success) { + Sentry.captureMessage('Notification payload has invalid screenName', { + level: 'warning', + }); + return; + } + if (!NOTIFICATION_SCREEN_ALLOWLIST.has(parsed.data)) { + Sentry.captureMessage( + `Notification navigation blocked: screen "${parsed.data}" is not in the allowlist`, + { level: 'warning' } + ); + return; + } + } + // Route to appropriate handler switch (data.type) { case NotificationType.COURSE_UPDATE: