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
15 changes: 15 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
121 changes: 121 additions & 0 deletions src/__tests__/utils/notificationHandlers.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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();
Expand Down Expand Up @@ -257,6 +274,88 @@ describe('notificationHandlers', () => {
});
});

describe('screenName security gate (handleNotificationResponse)', () => {
const mockSentryCaptureMessage = Sentry.captureMessage as jest.Mock;

function makeResponse(data: Record<string, unknown>): 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' };
Expand Down Expand Up @@ -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();
});
});
12 changes: 12 additions & 0 deletions src/config/security.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export const NOTIFICATION_SCREEN_ALLOWLIST = new Set([
'Home',
'Courses',
'CourseDetail',
'Messages',
'Chat',
'Learning',
'Community',
'CommunityPost',
'Achievements',
'AchievementDetail',
] as const);
1 change: 1 addition & 0 deletions src/types/notifications.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export interface NotificationData {
achievementId?: string;
postId?: string;
deepLink?: string;
screenName?: string;
}

export interface StoredNotification {
Expand Down
36 changes: 30 additions & 6 deletions src/utils/notificationHandlers.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) => void;
Expand All @@ -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)
Expand Down Expand Up @@ -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,
};
}

Expand Down Expand Up @@ -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:
Expand Down
Loading