diff --git a/package.json b/package.json index 42682a7b..7843196c 100644 --- a/package.json +++ b/package.json @@ -151,6 +151,7 @@ "react-native-webview": "13.16.1", "react-native-worklets": "0.8.3", "react-query-kit": "3.3.2", + "sanitize-html": "2.17.0", "tailwind-variants": "0.2.1", "zod": "3.23.8", "zustand": "4.5.7" @@ -171,6 +172,7 @@ "@types/mapbox-gl": "3.4.1", "@types/react": "~19.2.14", "@types/react-native-base64": "0.2.2", + "@types/sanitize-html": "^2.16.0", "@typescript-eslint/eslint-plugin": "8.56.0", "@typescript-eslint/parser": "8.56.0", "babel-jest": "30.0.5", diff --git a/src/app/call/[id].tsx b/src/app/call/[id].tsx index 45410ba9..ff67bda0 100644 --- a/src/app/call/[id].tsx +++ b/src/app/call/[id].tsx @@ -426,9 +426,9 @@ export default function CallDetail() { {callExtraData?.Protocols && callExtraData.Protocols.length > 0 ? ( {callExtraData.Protocols.map((protocol, index) => ( - + {protocol.Name} - {protocol.Description} + {protocol.Description} diff --git a/src/components/calls/__tests__/close-call-bottom-sheet.test.tsx b/src/components/calls/__tests__/close-call-bottom-sheet.test.tsx index 571a3860..a2b6ba2b 100644 --- a/src/components/calls/__tests__/close-call-bottom-sheet.test.tsx +++ b/src/components/calls/__tests__/close-call-bottom-sheet.test.tsx @@ -49,16 +49,31 @@ jest.mock('react-native-keyboard-controller', () => ({ }, })); -// Mock lucide icons -jest.mock('lucide-react-native', () => ({ - ChevronDown: () => null, -})); - // Mock UI components +jest.mock('@/components/ui/actionsheet', () => { + const { View } = require('react-native'); + return { + Actionsheet: ({ isOpen, children, testID }: any) => (isOpen ? {children} : null), + ActionsheetBackdrop: ({ children }: any) => {children}, + ActionsheetContent: ({ children, style }: any) => ( + + {children} + + ), + ActionsheetDragIndicator: () => , + ActionsheetDragIndicatorWrapper: ({ children }: any) => {children}, + }; +}); + jest.mock('@/components/ui/button', () => ({ - Button: ({ children, onPress, testID, disabled, ...props }: any) => { + Button: ({ children, onPress, testID, disabled, isDisabled, ...props }: any) => { const { TouchableOpacity } = require('react-native'); - return {children}; + const resolvedDisabled = disabled ?? isDisabled; + return ( + + {children} + + ); }, ButtonText: ({ children, ...props }: any) => { const { Text } = require('react-native'); @@ -66,6 +81,13 @@ jest.mock('@/components/ui/button', () => ({ }, })); +jest.mock('@/components/ui/heading', () => ({ + Heading: ({ children, ...props }: any) => { + const { Text } = require('react-native'); + return {children}; + }, +})); + jest.mock('@/components/ui/text', () => ({ Text: ({ children, ...props }: any) => { const { Text: RNText } = require('react-native'); @@ -87,6 +109,61 @@ jest.mock('@/components/ui/hstack', () => ({ }, })); +jest.mock('@/components/ui/form-control', () => ({ + FormControl: ({ children, ...props }: any) => { + const { View } = require('react-native'); + return {children}; + }, + FormControlLabel: ({ children, ...props }: any) => { + const { View } = require('react-native'); + return {children}; + }, + FormControlLabelText: ({ children, ...props }: any) => { + const { Text } = require('react-native'); + return {children}; + }, +})); + +jest.mock('@/components/ui/select', () => ({ + Select: ({ children, testID, selectedValue, onValueChange, ...props }: any) => { + const { View, TouchableOpacity, Text } = require('react-native'); + return ( + + {children} + onValueChange && onValueChange('1')}> + Select Option + + + ); + }, + SelectTrigger: ({ children, ...props }: any) => { + const { View } = require('react-native'); + return {children}; + }, + SelectInput: ({ placeholder, ...props }: any) => { + const { Text } = require('react-native'); + return {placeholder}; + }, + SelectIcon: () => null, + SelectPortal: ({ children, ...props }: any) => { + const { View } = require('react-native'); + return {children}; + }, + SelectBackdrop: () => null, + SelectContent: ({ children, ...props }: any) => { + const { View } = require('react-native'); + return {children}; + }, + SelectItem: ({ label, value, ...props }: any) => { + const { View, Text } = require('react-native'); + return ( + + {label} + + ); + }, +})); + jest.mock('@/components/ui/textarea', () => ({ Textarea: ({ children, ...props }: any) => { const { View } = require('react-native'); @@ -117,12 +194,10 @@ const mockUseCallDetailStore = useCallDetailStore as jest.MockedFunction; const mockUseToastStore = useToastStore as jest.MockedFunction; -/** Helper: select a close call type via the inline dropdown */ +/** Helper: select a close call type via the gluestack Select */ function selectCloseCallType(type: string) { const typeSelect = screen.getByTestId('close-call-type-select'); - fireEvent.press(typeSelect); - const option = screen.getByTestId(`close-call-type-option-${type}`); - fireEvent.press(option); + fireEvent(typeSelect, 'onValueChange', type); } describe('CloseCallBottomSheet', () => { @@ -191,7 +266,7 @@ describe('CloseCallBottomSheet', () => { const mockOnClose = jest.fn(); render(); - // Select close type via inline dropdown + // Select close type selectCloseCallType('1'); // Add note @@ -277,7 +352,7 @@ describe('CloseCallBottomSheet', () => { render(); - // Select close type via inline dropdown + // Select close type selectCloseCallType(type); // Submit diff --git a/src/components/calls/call-card.tsx b/src/components/calls/call-card.tsx index f2a887fe..db9b1edc 100644 --- a/src/components/calls/call-card.tsx +++ b/src/components/calls/call-card.tsx @@ -1,7 +1,7 @@ import { AlertTriangle, MapPin, Phone, Timer } from 'lucide-react-native'; import React, { useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; -import { Animated, ScrollView, StyleSheet } from 'react-native'; +import { Animated, Platform, ScrollView, StyleSheet } from 'react-native'; import { Box } from '@/components/ui/box'; import { HStack } from '@/components/ui/hstack'; @@ -174,11 +174,14 @@ export const CallCard: React.FC = React.memo(({ call, priority, s {/* Nature of Call */} - {call.Nature && ( - + {call.Nature ? ( + // Android's WebView claims the touch stream (requestDisallowInterceptTouchEvent), + // so a drag starting on it never reaches the surrounding list — kill its pointer + // events there and let the list scroll. iOS nests scrolling fine, leave it alone. + - )} + ) : null} ); }); diff --git a/src/components/calls/close-call-bottom-sheet.tsx b/src/components/calls/close-call-bottom-sheet.tsx index a83b11e6..152ad372 100644 --- a/src/components/calls/close-call-bottom-sheet.tsx +++ b/src/components/calls/close-call-bottom-sheet.tsx @@ -1,16 +1,20 @@ import { useRouter } from 'expo-router'; -import { ChevronDown } from 'lucide-react-native'; import React, { useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { Modal, Pressable as RNPressable, StyleSheet, View } from 'react-native'; +import { Platform } from 'react-native'; import { KeyboardAwareScrollView } from 'react-native-keyboard-controller'; +import { Actionsheet, ActionsheetBackdrop, ActionsheetContent, ActionsheetDragIndicator, ActionsheetDragIndicatorWrapper } from '@/components/ui/actionsheet'; import { Button, ButtonText } from '@/components/ui/button'; +import { FormControl, FormControlLabel, FormControlLabelText } from '@/components/ui/form-control'; +import { Heading } from '@/components/ui/heading'; import { HStack } from '@/components/ui/hstack'; +import { Select, SelectBackdrop, SelectContent, SelectIcon, SelectInput, SelectItem, SelectPortal, SelectTrigger } from '@/components/ui/select'; import { Text } from '@/components/ui/text'; import { Textarea, TextareaInput } from '@/components/ui/textarea'; import { VStack } from '@/components/ui/vstack'; import { useAnalytics } from '@/hooks/use-analytics'; +import { useKeyboardHeight } from '@/hooks/use-keyboard-height'; import { useCallDetailStore } from '@/stores/calls/detail-store'; import { useCallsStore } from '@/stores/calls/store'; import { useToastStore } from '@/stores/toast/store'; @@ -22,19 +26,10 @@ interface CloseCallBottomSheetProps { isLoading?: boolean; } -const CLOSE_CALL_TYPES = [ - { value: '1', translationKey: 'call_detail.close_call_types.closed' }, - { value: '2', translationKey: 'call_detail.close_call_types.cancelled' }, - { value: '3', translationKey: 'call_detail.close_call_types.unfounded' }, - { value: '4', translationKey: 'call_detail.close_call_types.founded' }, - { value: '5', translationKey: 'call_detail.close_call_types.minor' }, - { value: '6', translationKey: 'call_detail.close_call_types.transferred' }, - { value: '7', translationKey: 'call_detail.close_call_types.false_alarm' }, -]; - export const CloseCallBottomSheet: React.FC = ({ isOpen, onClose, callId, isLoading = false }) => { const { t } = useTranslation(); const router = useRouter(); + const keyboardHeight = useKeyboardHeight(); const showToast = useToastStore((state) => state.showToast); const { trackEvent } = useAnalytics(); const closeCall = useCallDetailStore((state) => state.closeCall); @@ -42,7 +37,6 @@ export const CloseCallBottomSheet: React.FC = ({ isOp const [closeCallType, setCloseCallType] = useState(''); const [closeCallNote, setCloseCallNote] = useState(''); const [isSubmitting, setIsSubmitting] = useState(false); - const [isTypeDropdownOpen, setIsTypeDropdownOpen] = useState(false); // Track when close call bottom sheet is opened/rendered React.useEffect(() => { @@ -57,7 +51,6 @@ export const CloseCallBottomSheet: React.FC = ({ isOp const handleClose = React.useCallback(() => { setCloseCallType(''); setCloseCallNote(''); - setIsTypeDropdownOpen(false); onClose(); }, [onClose]); @@ -94,46 +87,55 @@ export const CloseCallBottomSheet: React.FC = ({ isOp } }, [closeCallType, showToast, t, callId, closeCallNote, handleClose, fetchCalls, router, closeCall]); - const selectedTypeLabel = closeCallType ? t(CLOSE_CALL_TYPES.find((ct) => ct.value === closeCallType)?.translationKey ?? '') : t('call_detail.close_call_type_placeholder'); - const isButtonDisabled = isLoading || isSubmitting; return ( - - - e.stopPropagation()}> - - - - - {t('call_detail.close_call')} - - {/* Close Call Type selector */} - - {t('call_detail.close_call_type')} - setIsTypeDropdownOpen(!isTypeDropdownOpen)} testID="close-call-type-select"> - {selectedTypeLabel} - - - - {isTypeDropdownOpen && ( - - {CLOSE_CALL_TYPES.map((type) => ( - { - setCloseCallType(type.value); - setIsTypeDropdownOpen(false); - }} - testID={`close-call-type-option-${type.value}`} - > - {t(type.translationKey)} - - ))} - - )} - + + + {/* Same keyboard treatment as the other sheets: the sheet is bottom-anchored + and content-sized, so padding it by the keyboard height slides it up out + from under the keyboard, max-h caps it, and shrink lets the scrollview + compress and scroll instead of Yoga clipping the overflow. */} + + + + + + + + {t('call_detail.close_call')} + + + + + + + {t('call_detail.close_call_type')} + + + {t('call_detail.close_call_note')} @@ -142,93 +144,18 @@ export const CloseCallBottomSheet: React.FC = ({ isOp - - - - - - + + + ); }; - -const styles = StyleSheet.create({ - backdrop: { - flex: 1, - backgroundColor: 'rgba(0,0,0,0.4)', - justifyContent: 'flex-end', - }, - sheet: { - backgroundColor: 'white', - borderTopLeftRadius: 16, - borderTopRightRadius: 16, - maxHeight: '90%', - paddingBottom: 34, - paddingTop: 8, - }, - handle: { - width: 36, - height: 4, - borderRadius: 2, - backgroundColor: '#D1D5DB', - alignSelf: 'center', - marginBottom: 8, - }, - scrollView: { - width: '100%', - }, - scrollViewContent: { - flexGrow: 1, - paddingBottom: 40, - }, - typeTrigger: { - flexDirection: 'row', - alignItems: 'center', - justifyContent: 'space-between', - borderWidth: 1, - borderColor: '#D1D5DB', - borderRadius: 8, - paddingHorizontal: 12, - paddingVertical: 12, - backgroundColor: '#F9FAFB', - }, - typeText: { - fontSize: 16, - color: '#111827', - }, - typePlaceholder: { - fontSize: 16, - color: '#9CA3AF', - }, - typeDropdown: { - borderWidth: 1, - borderColor: '#D1D5DB', - borderRadius: 8, - backgroundColor: 'white', - marginTop: 4, - }, - typeOption: { - paddingHorizontal: 12, - paddingVertical: 12, - borderBottomWidth: StyleSheet.hairlineWidth, - borderBottomColor: '#E5E7EB', - }, - typeOptionSelected: { - backgroundColor: '#EFF6FF', - }, - typeOptionText: { - fontSize: 15, - color: '#374151', - }, - typeOptionTextSelected: { - fontSize: 15, - color: '#2563EB', - fontWeight: '600', - }, -}); diff --git a/src/components/status/status-bottom-sheet.tsx b/src/components/status/status-bottom-sheet.tsx index b9166953..d09cfc92 100644 --- a/src/components/status/status-bottom-sheet.tsx +++ b/src/components/status/status-bottom-sheet.tsx @@ -5,6 +5,7 @@ import { useTranslation } from 'react-i18next'; import { ScrollView, TouchableOpacity } from 'react-native'; import { KeyboardAwareScrollView } from 'react-native-keyboard-controller'; +import { useKeyboardHeight } from '@/hooks/use-keyboard-height'; import { logger } from '@/lib/logging'; import { createPoiTypeMap, getPoiSelectionLabel } from '@/lib/poi-utils'; import { invertColor } from '@/lib/utils'; @@ -93,6 +94,7 @@ const getPreferredDestinationTab = ({ export const StatusBottomSheet = () => { const { t } = useTranslation(); const { colorScheme } = useColorScheme(); + const keyboardHeight = useKeyboardHeight(); const [selectedTab, setSelectedTab] = React.useState('call'); const [isSubmitting, setIsSubmitting] = React.useState(false); const showToast = useToastStore((state) => state.showToast); @@ -618,12 +620,16 @@ export const StatusBottomSheet = () => { return ( - + {/* The sheet renders inside a native Modal, so keyboard-controller's window-bound + avoidance never moves it — padding by the keyboard height reserves the covered + strip instead. shrink on the column lets the step content compress into the + remaining space so its scrollview actually scrolls rather than Yoga clipping it. */} + - + {t('common.step')} {getStepNumber()} {t('common.of')} {getTotalSteps()} @@ -858,7 +864,13 @@ export const StatusBottomSheet = () => { ) : null} {currentStep === 'add-note' ? ( - + {t('status.selected_status')}: diff --git a/src/components/ui/html-renderer/index.tsx b/src/components/ui/html-renderer/index.tsx index 62cccfe3..a0f71650 100644 --- a/src/components/ui/html-renderer/index.tsx +++ b/src/components/ui/html-renderer/index.tsx @@ -3,6 +3,8 @@ import React from 'react'; import { Linking, type StyleProp, StyleSheet, type ViewStyle } from 'react-native'; import WebView from 'react-native-webview'; +import { sanitizeHtmlContent } from '@/utils/html-sanitizer'; + /** Light / dark theme color tokens used when no explicit override is provided */ const THEME_COLORS = { light: { text: '#1F2937', background: 'transparent' }, // gray-800 @@ -83,7 +85,7 @@ export const HtmlRenderer: React.FC = ({ ${customCSS} - ${html} + ${sanitizeHtmlContent(html)} `; diff --git a/src/components/ui/html-renderer/index.web.tsx b/src/components/ui/html-renderer/index.web.tsx index 4d3136a2..e7bd2ec0 100644 --- a/src/components/ui/html-renderer/index.web.tsx +++ b/src/components/ui/html-renderer/index.web.tsx @@ -2,6 +2,8 @@ import { useColorScheme } from 'nativewind'; import React, { useCallback, useMemo, useRef } from 'react'; import { Linking, type StyleProp, StyleSheet, View, type ViewStyle } from 'react-native'; +import { sanitizeHtmlContent } from '@/utils/html-sanitizer'; + /** Light / dark theme color tokens used when no explicit override is provided */ const THEME_COLORS = { light: { text: '#1F2937', background: 'transparent' }, // gray-800 @@ -100,7 +102,7 @@ export const HtmlRenderer: React.FC = ({ html, style, scrollE ${customCSS} - ${html}${linkScript} + ${sanitizeHtmlContent(html)}${linkScript} `, [html, scrollEnabled, showsVerticalScrollIndicator, resolvedTextColor, resolvedBgColor, customCSS, linkScript] diff --git a/src/hooks/use-keyboard-height.ts b/src/hooks/use-keyboard-height.ts new file mode 100644 index 00000000..00ecac38 --- /dev/null +++ b/src/hooks/use-keyboard-height.ts @@ -0,0 +1,38 @@ +import { useEffect, useState } from 'react'; +import { Keyboard, Platform } from 'react-native'; + +/** + * Current soft-keyboard height in dp, or 0 when it is closed. + * + * Bottom sheets render inside a native `Modal`, which owns its own window. + * react-native-keyboard-controller's inset animations are bound to the main window, + * so `KeyboardAvoidingView`/`KeyboardAwareScrollView` never move sheet content and the + * keyboard sits on top of it. React Native's own `Keyboard` events are dispatched + * regardless of which window is focused, so they still describe the keyboard correctly + * inside a sheet — use them to size the gap the sheet needs to leave. + * + * iOS gets the `Will` events so the sheet moves with the keyboard animation; Android only + * reports usable frames on `Did`. + */ +export function useKeyboardHeight(): number { + const [height, setHeight] = useState(0); + + useEffect(() => { + const showEvent = Platform.OS === 'ios' ? 'keyboardWillShow' : 'keyboardDidShow'; + const hideEvent = Platform.OS === 'ios' ? 'keyboardWillHide' : 'keyboardDidHide'; + + const showSubscription = Keyboard.addListener(showEvent, (event) => { + setHeight(event.endCoordinates.height); + }); + const hideSubscription = Keyboard.addListener(hideEvent, () => { + setHeight(0); + }); + + return () => { + showSubscription.remove(); + hideSubscription.remove(); + }; + }, []); + + return height; +} diff --git a/src/utils/__tests__/html-sanitizer.test.ts b/src/utils/__tests__/html-sanitizer.test.ts new file mode 100644 index 00000000..2e326b83 --- /dev/null +++ b/src/utils/__tests__/html-sanitizer.test.ts @@ -0,0 +1,90 @@ +import { sanitizeHtmlContent } from '../html-sanitizer'; + +describe('sanitizeHtmlContent', () => { + it('returns an empty string for empty input', () => { + expect(sanitizeHtmlContent('')).toBe(''); + }); + + it('preserves safe formatting markup', () => { + const result = sanitizeHtmlContent('

Engine fully staffed

'); + + expect(result).toContain('fully'); + expect(result).toContain('Engine'); + }); + + it('drops script tags and their contents', () => { + const result = sanitizeHtmlContent('

before

after

'); + + expect(result).not.toContain('script'); + expect(result).not.toContain('alert(1)'); + expect(result).toContain('before'); + expect(result).toContain('after'); + }); + + it('strips inline event handlers', () => { + const result = sanitizeHtmlContent('

tap me

'); + + expect(result).not.toContain('onclick'); + expect(result).not.toContain('onerror'); + expect(result).toContain('tap me'); + }); + + it('removes javascript: and data: URLs from links', () => { + const result = sanitizeHtmlContent('tap'); + + expect(result).not.toContain('javascript:'); + expect(result).toContain('tap'); + }); + + // The API returns some fields entity-encoded. Decoding has to happen before + // sanitizing, otherwise an encoded payload passes through untouched and is + // revived as live markup by the WebView. + it('sanitizes payloads that arrive HTML-entity-encoded', () => { + const result = sanitizeHtmlContent('<img src=x onerror=alert(1)><script>alert(2)</script>'); + + expect(result).not.toContain('onerror'); + expect(result).not.toContain('alert(1)'); + expect(result).not.toContain('alert(2)'); + expect(result).not.toContain(' { + const result = sanitizeHtmlContent('<p>Structure fire</p>'); + + expect(result).toContain('

'); + expect(result).toContain('Structure fire'); + }); + + it('leaves entities alone when the value already contains real markup', () => { + const result = sanitizeHtmlContent('

Smith & Sons

'); + + expect(result).toContain('&'); + expect(result).toContain('

'); + }); + + // A numeric entity above U+10FFFF makes String.fromCodePoint throw, which would + // take down the whole render for one malformed field. + it('does not throw on numeric entities outside the Unicode range', () => { + expect(() => sanitizeHtmlContent('<p>���</p>')).not.toThrow(); + + const result = sanitizeHtmlContent('<p>bad � tail</p>'); + + expect(result).toContain('bad'); + expect(result).toContain('tail'); + }); + + it('leaves surrogate-range numeric entities undecoded', () => { + const result = sanitizeHtmlContent('<p>��</p>'); + + // Lone surrogates are malformed UTF-16; the raw entity text is the safe result. + expect(result).not.toMatch(/[\uD800-\uDFFF]/); + }); + + it('still decodes valid decimal and hexadecimal entities', () => { + const result = sanitizeHtmlContent('<p>AB🚘</p>'); + + expect(result).toContain('A'); + expect(result).toContain('B'); + expect(result).toContain('\u{1F698}'); + }); +}); diff --git a/src/utils/html-entities.ts b/src/utils/html-entities.ts new file mode 100644 index 00000000..e810d1d3 --- /dev/null +++ b/src/utils/html-entities.ts @@ -0,0 +1,35 @@ +const NAMED_ENTITIES: Record = { + amp: '&', + lt: '<', + gt: '>', + quot: '"', + apos: "'", + nbsp: ' ', +}; + +// `String.fromCodePoint` throws on anything above U+10FFFF, and lone surrogates are +// malformed UTF-16. Both stay as the original entity text rather than taking down the +// render for one bad field. +const isDecodableCodePoint = (code: number): boolean => Number.isInteger(code) && code >= 0 && code <= 0x10ffff && !(code >= 0xd800 && code <= 0xdfff); + +export const decodeHtmlEntities = (value: string): string => + value.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z]+);/g, (match, entity: string) => { + if (entity.startsWith('#')) { + const isHex = entity[1] === 'x' || entity[1] === 'X'; + const code = Number.parseInt(entity.slice(isHex ? 2 : 1), isHex ? 16 : 10); + return isDecodableCodePoint(code) ? String.fromCodePoint(code) : match; + } + return NAMED_ENTITIES[entity.toLowerCase()] ?? match; + }); + +// Some API fields (e.g. protocol text) arrive HTML-entity-encoded: no real tags, +// just `<p>…`. Rendering that shows literal markup, so decode it first — +// only when there are no actual tags in the value. +export const looksHtmlEncoded = (value: string): boolean => !/<[a-z!/]/i.test(value) && /&(lt|#0*60|#x0*3c);/i.test(value); + +/** + * Decodes HTML entities only when the value looks entity-encoded + * (no real tags, but encoded tag markers present). Otherwise the + * value is returned unchanged. + */ +export const decodeHtmlEntitiesIfEncoded = (value: string): string => (looksHtmlEncoded(value) ? decodeHtmlEntities(value) : value); diff --git a/src/utils/html-sanitizer.ts b/src/utils/html-sanitizer.ts new file mode 100644 index 00000000..152fc1ff --- /dev/null +++ b/src/utils/html-sanitizer.ts @@ -0,0 +1,210 @@ +/** + * HTML sanitizer for API-supplied content rendered inside WebViews. + * + * Fields like call Nature, call/contact notes and protocol text are authored server-side + * and rendered as real markup, so they must be sanitized before injection — otherwise a + * crafted note is script execution inside the WebView. Mirrors the strict allowlist used + * by the Responder app so the apps stay consistent. + */ + +import sanitizeHtmlLib from 'sanitize-html'; + +import { decodeHtmlEntitiesIfEncoded } from './html-entities'; + +/** + * Checks if a URL scheme is safe (not javascript: or data:) + */ +function isSafeScheme(url: string): boolean { + if (!url) return false; + const normalizedUrl = url.toLowerCase().trim(); + return !normalizedUrl.startsWith('javascript:') && !normalizedUrl.startsWith('data:'); +} + +/** + * Filters attributes to remove dangerous ones (on* events, unsafe styles) + */ +function filterDangerousAttributes(tag: string, name: string, value: string): boolean { + // Block all event handlers (onclick, onload, etc.) + if (name.toLowerCase().startsWith('on')) { + return false; + } + + // Block style attributes with expressions or javascript + if (name.toLowerCase() === 'style') { + const normalizedValue = value.toLowerCase(); + if (normalizedValue.includes('expression(') || normalizedValue.includes('javascript:')) { + return false; + } + } + + // For URL attributes, ensure safe schemes + if (['href', 'src', 'srcset', 'srcdoc'].includes(name.toLowerCase())) { + return isSafeScheme(value); + } + + return true; +} + +// Strict sanitization configuration with explicit allowlist +export const strictSanitizeConfig: sanitizeHtmlLib.IOptions = { + // Allow only safe HTML tags - strict allowlist (includes table tags for webview compatibility) + allowedTags: [ + 'p', + 'br', + 'strong', + 'b', + 'em', + 'i', + 'u', + 'span', + 'div', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'ul', + 'ol', + 'li', + 'a', + 'img', + 'blockquote', + 'pre', + 'code', + // Table tags for webview compatibility + 'table', + 'thead', + 'tbody', + 'tr', + 'th', + 'td', + // Additional formatting tags + 'hr', + 's', + 'small', + 'sub', + 'sup', + 'dl', + 'dt', + 'dd', + ], + + // Allow only safe attributes - strict allowlist with filtering + allowedAttributes: { + a: ['href', 'title'], + img: ['src', 'alt', 'title', 'width', 'height'], + span: ['style'], + div: ['style'], + p: ['style'], + table: ['width', 'cellpadding', 'cellspacing'], + th: ['scope', 'colspan', 'rowspan'], + td: ['colspan', 'rowspan'], + '*': ['class'], + }, + + // Allow only safe URL schemes + allowedSchemes: ['http', 'https', 'mailto'], + + // Specific schemes allowed per tag + allowedSchemesByTag: { + a: ['http', 'https', 'mailto'], + img: ['http', 'https'], + }, + + // Disallow unknown tags (strict mode) + disallowedTagsMode: 'discard', + + // Additional security options + allowedIframeHostnames: [], // No iframes allowed + allowedScriptHostnames: [], // No scripts allowed + + // Style attribute options - very restrictive + allowedStyles: { + '*': { + color: [/^#(0x)?[0-9a-f]+$/i, /^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/], + 'text-align': [/^left$/, /^right$/, /^center$/, /^justify$/], + 'font-size': [/^\d+(?:px|em|%)$/], + 'font-weight': [/^(?:normal|bold|bolder|lighter|\d+)$/], + margin: [/^\d+(?:px|em|%)$/], + padding: [/^\d+(?:px|em|%)$/], + }, + }, + + // Remove empty elements and dangerous tags + nonTextTags: ['style', 'script', 'textarea', 'option'], + + // Transform tags for better security and validation + transformTags: { + a: (tagName, attribs) => { + // Validate href attribute and remove if unsafe + if (attribs.href && !isSafeScheme(attribs.href)) { + delete attribs.href; + } + + // Filter out dangerous attributes + const filteredAttribs: Record = {}; + Object.entries(attribs).forEach(([name, value]) => { + if (filterDangerousAttributes(tagName, name, value)) { + filteredAttribs[name] = value; + } + }); + + return { + tagName: 'a', + attribs: { + ...filteredAttribs, + target: '_blank', + rel: 'noopener noreferrer', + }, + }; + }, + img: (tagName, attribs) => { + // Validate src attribute and remove if unsafe + if (attribs.src && !isSafeScheme(attribs.src)) { + delete attribs.src; + } + + // Filter out dangerous attributes + const filteredAttribs: Record = {}; + Object.entries(attribs).forEach(([name, value]) => { + if (filterDangerousAttributes(tagName, name, value)) { + filteredAttribs[name] = value; + } + }); + + return { + tagName: 'img', + attribs: filteredAttribs, + }; + }, + // Apply filtering to all other tags + '*': (tagName, attribs) => { + const filteredAttribs: Record = {}; + Object.entries(attribs).forEach(([name, value]) => { + if (filterDangerousAttributes(tagName, name, value)) { + filteredAttribs[name] = value; + } + }); + + return { + tagName, + attribs: filteredAttribs, + }; + }, + }, +}; + +/** + * Sanitizes API-supplied HTML for safe injection into a WebView document. + * + * Entity-decoding happens first because some fields arrive encoded (`<p>…`); + * decoding after sanitizing would let an encoded payload slip through untouched. + */ +export const sanitizeHtmlContent = (html: string): string => { + if (!html) { + return ''; + } + + return sanitizeHtmlLib(decodeHtmlEntitiesIfEncoded(html), strictSanitizeConfig); +}; diff --git a/yarn.lock b/yarn.lock index a56ded59..94940b10 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4920,6 +4920,13 @@ dependencies: "@types/node" "*" +"@types/sanitize-html@^2.16.0": + version "2.16.1" + resolved "https://registry.yarnpkg.com/@types/sanitize-html/-/sanitize-html-2.16.1.tgz#27b9ac6cc29838f7a048bfec0113e8ad00918d0a" + integrity sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA== + dependencies: + htmlparser2 "^10.1" + "@types/semver@^7.3.12": version "7.7.1" resolved "https://registry.yarnpkg.com/@types/semver/-/semver-7.7.1.tgz#3ce3af1a5524ef327d2da9e4fd8b6d95c8d70528" @@ -7347,7 +7354,7 @@ domhandler@^5.0.2, domhandler@^5.0.3: dependencies: domelementtype "^2.3.0" -domutils@^3.0.1: +domutils@^3.0.1, domutils@^3.2.2: version "3.2.2" resolved "https://registry.yarnpkg.com/domutils/-/domutils-3.2.2.tgz#edbfe2b668b0c1d97c24baf0f1062b132221bc78" integrity sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw== @@ -7573,6 +7580,11 @@ entities@^6.0.0: resolved "https://registry.yarnpkg.com/entities/-/entities-6.0.1.tgz#c28c34a43379ca7f61d074130b2f5f7020a30694" integrity sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g== +entities@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/entities/-/entities-7.0.1.tgz#26e8a88889db63417dcb9a1e79a3f1bc92b5976b" + integrity sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA== + env-paths@^2.2.0, env-paths@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/env-paths/-/env-paths-2.2.1.tgz#420399d416ce1fbe9bc0a07c62fa68d67fd0f8f2" @@ -9557,6 +9569,26 @@ html-parse-stringify@^3.0.1: dependencies: void-elements "3.1.0" +htmlparser2@^10.1: + version "10.1.0" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-10.1.0.tgz#fe3f2e12c73b6e462d4e10395db9c1119e4d6ae4" + integrity sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ== + dependencies: + domelementtype "^2.3.0" + domhandler "^5.0.3" + domutils "^3.2.2" + entities "^7.0.1" + +htmlparser2@^8.0.0: + version "8.0.2" + resolved "https://registry.yarnpkg.com/htmlparser2/-/htmlparser2-8.0.2.tgz#f002151705b383e62433b5cf466f5b716edaec21" + integrity sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA== + dependencies: + domelementtype "^2.3.0" + domhandler "^5.0.3" + domutils "^3.0.1" + entities "^4.4.0" + http-cache-semantics@^4.0.0, http-cache-semantics@^4.1.1: version "4.2.0" resolved "https://registry.yarnpkg.com/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz#205f4db64f8562b76a4ff9235aa5279839a09dd5" @@ -10135,6 +10167,11 @@ is-path-inside@^4.0.0: resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-4.0.0.tgz#805aeb62c47c1b12fc3fd13bfb3ed1e7430071db" integrity sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA== +is-plain-object@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/is-plain-object/-/is-plain-object-5.0.0.tgz#4427f50ab3429e9025ea7d52e9043a9ef4159344" + integrity sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q== + is-potential-custom-element-name@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz#171ed6f19e3ac554394edf78caa05784a45bebb5" @@ -12187,6 +12224,11 @@ nanoid@^3.3.16: resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.16.tgz#a04d8ec4b1f10009d2d533947aefe4293737816c" integrity sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q== +nanoid@^3.3.17: + version "3.3.18" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.18.tgz#f66a2de1199ffde0fcf21c8a5f13106b1c081913" + integrity sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w== + napi-postinstall@^0.3.0: version "0.3.3" resolved "https://registry.yarnpkg.com/napi-postinstall/-/napi-postinstall-0.3.3.tgz#93d045c6b576803ead126711d3093995198c6eb9" @@ -12818,6 +12860,11 @@ parse-png@^2.1.0: dependencies: pngjs "^3.3.0" +parse-srcset@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/parse-srcset/-/parse-srcset-1.0.2.tgz#f2bd221f6cc970a938d88556abc589caaaa2bde1" + integrity sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q== + parse5@^7.0.0, parse5@^7.1.1: version "7.3.0" resolved "https://registry.yarnpkg.com/parse5/-/parse5-7.3.0.tgz#d7e224fa72399c7a175099f45fc2ad024b05ec05" @@ -13056,6 +13103,15 @@ postcss-value-parser@^4.2.0: resolved "https://registry.yarnpkg.com/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz#723c09920836ba6d3e5af019f92bc0971c02e514" integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== +postcss@^8.3.11: + version "8.5.26" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.26.tgz#6e75135780c7e10df3433bf2266c552d35c8c620" + integrity sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ== + dependencies: + nanoid "^3.3.17" + picocolors "^1.1.1" + source-map-js "^1.2.1" + postcss@^8.4.4: version "8.5.6" resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.6.tgz#2825006615a619b4f62a9e7426cc120b349a8f3c" @@ -14100,6 +14156,18 @@ sanitize-filename@^1.6.3: dependencies: truncate-utf8-bytes "^1.0.0" +sanitize-html@2.17.0: + version "2.17.0" + resolved "https://registry.yarnpkg.com/sanitize-html/-/sanitize-html-2.17.0.tgz#a8f66420a6be981d8fe412e3397cc753782598e4" + integrity sha512-dLAADUSS8rBwhaevT12yCezvioCA+bmUTPH/u57xKPT8d++voeYE6HeluA/bPbQ15TwDBG2ii+QZIEmYx8VdxA== + dependencies: + deepmerge "^4.2.2" + escape-string-regexp "^4.0.0" + htmlparser2 "^8.0.0" + is-plain-object "^5.0.0" + parse-srcset "^1.0.2" + postcss "^8.3.11" + sax@>=0.6.0: version "1.4.1" resolved "https://registry.yarnpkg.com/sax/-/sax-1.4.1.tgz#44cc8988377f126304d3b3fc1010c733b929ef0f"