diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/components/keyboard-navigation-handler.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/components/keyboard-navigation-handler.tsx index ba3443567..adb2981be 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/components/keyboard-navigation-handler.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/components/keyboard-navigation-handler.tsx @@ -14,6 +14,8 @@ interface KeyboardNavigationHandlerProps { flatTagList: Array<{ tag: string; group?: BlockTagGroup }> nestedBlockTagGroups: NestedBlockTagGroup[] handleTagSelect: (tag: string, group?: BlockTagGroup) => void + /** Called when entering a folder from root level via keyboard navigation */ + onFolderEnter?: () => void } /** @@ -107,6 +109,7 @@ export const KeyboardNavigationHandler: React.FC flatTagList, nestedBlockTagGroups, handleTagSelect, + onFolderEnter, }) => { const { openFolder, closeFolder, isInFolder, currentFolder, setKeyboardNav } = usePopoverContext() const nestedNav = useNestedNavigation() @@ -251,7 +254,7 @@ export const KeyboardNavigationHandler: React.FC } else if (currentVisibleIndex < visibleIndices.length - 1) { newIndex = visibleIndices[currentVisibleIndex + 1] } else { - newIndex = visibleIndices[0] + newIndex = selectedIndex } setSelectedIndex(newIndex) scrollIntoView() @@ -269,7 +272,7 @@ export const KeyboardNavigationHandler: React.FC } else if (currentVisibleIndex > 0) { newIndex = visibleIndices[currentVisibleIndex - 1] } else { - newIndex = visibleIndices[visibleIndices.length - 1] + newIndex = selectedIndex } setSelectedIndex(newIndex) scrollIntoView() @@ -295,6 +298,7 @@ export const KeyboardNavigationHandler: React.FC currentFolderInfo.parentTag, currentFolderInfo.group ) + onFolderEnter?.() } } break @@ -346,6 +350,7 @@ export const KeyboardNavigationHandler: React.FC handleTagSelect, nestedNav, setKeyboardNav, + onFolderEnter, ]) return null diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx index 17b50aad2..228d38274 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tag-dropdown/tag-dropdown.tsx @@ -444,10 +444,12 @@ interface NestedTagRendererProps { nestedTag: NestedTag group: NestedBlockTagGroup flatTagList: Array<{ tag: string; group?: BlockTagGroup }> + /** Map from tag string to index for O(1) lookups */ + flatTagIndexMap: Map selectedIndex: number setSelectedIndex: (index: number) => void handleTagSelect: (tag: string, blockGroup?: BlockTagGroup) => void - itemRefs: React.RefObject> + itemRefs: React.RefObject> blocks: Record getMergedSubBlocks: (blockId: string) => Record } @@ -469,6 +471,7 @@ interface FolderContentsProps extends NestedTagRendererProps { const FolderContentsInner: React.FC = ({ group, flatTagList, + flatTagIndexMap, selectedIndex, setSelectedIndex, handleTagSelect, @@ -483,7 +486,7 @@ const FolderContentsInner: React.FC = ({ const currentNestedTag = nestedPath.length > 0 ? nestedPath[nestedPath.length - 1] : nestedTag const parentTagIndex = currentNestedTag.parentTag - ? flatTagList.findIndex((item) => item.tag === currentNestedTag.parentTag) + ? (flatTagIndexMap.get(currentNestedTag.parentTag) ?? -1) : -1 return ( @@ -493,7 +496,6 @@ const FolderContentsInner: React.FC = ({ = 0} onMouseEnter={() => { - // Skip selection update during keyboard navigation to prevent scroll-triggered selection changes if (isKeyboardNav) return setKeyboardNav(false) if (parentTagIndex >= 0) setSelectedIndex(parentTagIndex) @@ -504,8 +506,8 @@ const FolderContentsInner: React.FC = ({ handleTagSelect(currentNestedTag.parentTag!, group) }} ref={(el) => { - if (el && parentTagIndex >= 0) { - itemRefs.current?.set(parentTagIndex, el) + if (el && currentNestedTag.parentTag) { + itemRefs.current?.set(currentNestedTag.parentTag, el) } }} > @@ -515,7 +517,7 @@ const FolderContentsInner: React.FC = ({ {/* Render leaf children as PopoverItems */} {currentNestedTag.children?.map((child) => { - const childGlobalIndex = flatTagList.findIndex((item) => item.tag === child.fullTag) + const childGlobalIndex = flatTagIndexMap.get(child.fullTag) ?? -1 const tagParts = child.fullTag.split('.') const outputPath = tagParts.slice(1).join('.') @@ -550,8 +552,8 @@ const FolderContentsInner: React.FC = ({ handleTagSelect(child.fullTag, group) }} ref={(el) => { - if (el && childGlobalIndex >= 0) { - itemRefs.current?.set(childGlobalIndex, el) + if (el) { + itemRefs.current?.set(child.fullTag, el) } }} > @@ -568,7 +570,7 @@ const FolderContentsInner: React.FC = ({ {/* Render nested children as clickable folder items */} {currentNestedTag.nestedChildren?.map((nestedChild) => { const parentGlobalIndex = nestedChild.parentTag - ? flatTagList.findIndex((item) => item.tag === nestedChild.parentTag) + ? (flatTagIndexMap.get(nestedChild.parentTag) ?? -1) : -1 return ( @@ -583,12 +585,11 @@ const FolderContentsInner: React.FC = ({ onMouseDown={(e) => { e.preventDefault() e.stopPropagation() - // Navigate into the subfolder on click onNavigateIn(nestedChild) }} ref={(el) => { - if (el && parentGlobalIndex >= 0) { - itemRefs.current?.set(parentGlobalIndex, el) + if (el && nestedChild.parentTag) { + itemRefs.current?.set(nestedChild.parentTag, el) } }} > @@ -605,7 +606,7 @@ const FolderContentsInner: React.FC = ({ * Wrapper component that uses shared nested navigation state from context. * Handles registration of the base folder and navigation callbacks. */ -const FolderContents: React.FC = (props) => { +const FolderContents: React.FC> = (props) => { const nestedNav = useNestedNavigation() const { currentFolder } = usePopoverContext() @@ -638,6 +639,7 @@ const NestedTagRenderer: React.FC = ({ nestedTag, group, flatTagList, + flatTagIndexMap, selectedIndex, setSelectedIndex, handleTagSelect, @@ -653,7 +655,7 @@ const NestedTagRenderer: React.FC = ({ const folderId = `${group.blockId}-${nestedTag.key}` const parentGlobalIndex = nestedTag.parentTag - ? flatTagList.findIndex((item) => item.tag === nestedTag.parentTag) + ? (flatTagIndexMap.get(nestedTag.parentTag) ?? -1) : -1 return ( @@ -675,8 +677,8 @@ const NestedTagRenderer: React.FC = ({ } }} ref={(el) => { - if (el && parentGlobalIndex >= 0) { - itemRefs.current?.set(parentGlobalIndex, el) + if (el && nestedTag.parentTag) { + itemRefs.current?.set(nestedTag.parentTag, el) } }} > @@ -684,6 +686,7 @@ const NestedTagRenderer: React.FC = ({ nestedTag={nestedTag} group={group} flatTagList={flatTagList} + flatTagIndexMap={flatTagIndexMap} selectedIndex={selectedIndex} setSelectedIndex={setSelectedIndex} handleTagSelect={handleTagSelect} @@ -695,10 +698,7 @@ const NestedTagRenderer: React.FC = ({ ) } - // Leaf tag - render as a simple PopoverItem - const globalIndex = nestedTag.fullTag - ? flatTagList.findIndex((item) => item.tag === nestedTag.fullTag) - : -1 + const globalIndex = nestedTag.fullTag ? (flatTagIndexMap.get(nestedTag.fullTag) ?? -1) : -1 let tagDescription = '' @@ -751,8 +751,8 @@ const NestedTagRenderer: React.FC = ({ } }} ref={(el) => { - if (el && globalIndex >= 0) { - itemRefs.current?.set(globalIndex, el) + if (el && nestedTag.fullTag) { + itemRefs.current?.set(nestedTag.fullTag, el) } }} > @@ -767,7 +767,7 @@ const NestedTagRenderer: React.FC = ({ } /** - * Hook to get mouse enter handler that respects keyboard navigation mode. + * Hook to get mouse enter handler that respects keyboard navigation state. * Returns a handler that only updates selection if not in keyboard mode. */ const useKeyboardAwareMouseEnter = ( @@ -794,7 +794,7 @@ const VariableTagItem: React.FC<{ selectedIndex: number setSelectedIndex: (index: number) => void handleTagSelect: (tag: string) => void - itemRefs: React.RefObject> + itemRefs: React.RefObject> variableInfo: { type: string; id: string } | null }> = ({ tag, @@ -819,8 +819,8 @@ const VariableTagItem: React.FC<{ handleTagSelect(tag) }} ref={(el) => { - if (el && globalIndex >= 0) { - itemRefs.current?.set(globalIndex, el) + if (el) { + itemRefs.current?.set(tag, el) } }} > @@ -845,7 +845,7 @@ const BlockRootTagItem: React.FC<{ selectedIndex: number setSelectedIndex: (index: number) => void handleTagSelect: (tag: string, group?: BlockTagGroup) => void - itemRefs: React.RefObject> + itemRefs: React.RefObject> group: BlockTagGroup tagIcon: string | React.ComponentType<{ className?: string }> blockColor: string @@ -875,8 +875,8 @@ const BlockRootTagItem: React.FC<{ handleTagSelect(rootTag, group) }} ref={(el) => { - if (el && rootTagGlobalIndex >= 0) { - itemRefs.current?.set(rootTagGlobalIndex, el) + if (el) { + itemRefs.current?.set(rootTag, el) } }} > @@ -916,16 +916,12 @@ const TagDropdownBackButton: React.FC = () => { const handleBackClick = (e: React.MouseEvent) => { e.stopPropagation() - // Try to navigate back in nested path first if (nestedNav?.navigateBack()) { - // Successfully navigated back one level return } - // At root folder level, close the folder closeFolder() } - // Just render the back button - the parent tag is rendered as the first item in FolderContentsInner return (
= ({ inputRef, }) => { const [selectedIndex, setSelectedIndex] = useState(0) - const itemRefs = useRef>(new Map()) + const itemRefs = useRef>(new Map()) const [nestedPath, setNestedPath] = useState([]) const baseFolderRef = useRef<{ @@ -998,6 +994,11 @@ export const TagDropdown: React.FC = ({ const handleTagSelectRef = useRef<((tag: string, group?: BlockTagGroup) => void) | null>(null) const scrollAreaRef = useRef(null) + const inputValueRef = useRef(inputValue) + const cursorPositionRef = useRef(cursorPosition) + inputValueRef.current = inputValue + cursorPositionRef.current = cursorPosition + const { blocks, edges, loops, parallels } = useWorkflowStore( useShallow((state) => ({ blocks: state.blocks, @@ -1700,27 +1701,27 @@ export const TagDropdown: React.FC = ({ return list }, [variableTags, nestedBlockTagGroups]) - useEffect(() => { - if (!visible || selectedIndex < 0) return - - const element = itemRefs.current.get(selectedIndex) - if (element) { - element.scrollIntoView({ - behavior: 'auto', - block: 'nearest', - }) - } - }, [selectedIndex, visible]) + /** + * Map from tag string to its index in flatTagList for O(1) lookups. + * Replaces O(n) findIndex calls throughout the component. + */ + const flatTagIndexMap = useMemo(() => { + const map = new Map() + flatTagList.forEach((item, index) => { + map.set(item.tag, index) + }) + return map + }, [flatTagList]) const handleTagSelect = useCallback( (tag: string, blockGroup?: BlockTagGroup) => { - let liveCursor = cursorPosition - let liveValue = inputValue + let liveCursor = cursorPositionRef.current + let liveValue = inputValueRef.current if (typeof window !== 'undefined' && document?.activeElement) { const activeEl = document.activeElement as HTMLInputElement | HTMLTextAreaElement | null if (activeEl && typeof activeEl.selectionStart === 'number') { - liveCursor = activeEl.selectionStart ?? cursorPosition + liveCursor = activeEl.selectionStart ?? cursorPositionRef.current if ('value' in activeEl && typeof activeEl.value === 'string') { liveValue = activeEl.value } @@ -1805,7 +1806,7 @@ export const TagDropdown: React.FC = ({ onSelect(newValue) onClose?.() }, - [inputValue, cursorPosition, workflowVariables, onSelect, onClose, getMergedSubBlocks] + [workflowVariables, onSelect, onClose, getMergedSubBlocks] ) handleTagSelectRef.current = handleTagSelect @@ -1877,9 +1878,6 @@ export const TagDropdown: React.FC = ({ }, registerFolder: (folderId, folderTitle, baseTag, group) => { baseFolderRef.current = { id: folderId, title: folderTitle, baseTag, group } - if (scrollAreaRef.current) { - scrollAreaRef.current.scrollTop = 0 - } }, }), [nestedPath] @@ -1892,13 +1890,9 @@ export const TagDropdown: React.FC = ({ } }, [visible]) - useEffect(() => setSelectedIndex(0), [searchTerm]) - useEffect(() => { - if (selectedIndex >= flatTagList.length) { - setSelectedIndex(Math.max(0, flatTagList.length - 1)) - } - }, [flatTagList.length, selectedIndex]) + setSelectedIndex(0) + }, [flatTagList.length]) useEffect(() => { if (visible) { @@ -1917,20 +1911,28 @@ export const TagDropdown: React.FC = ({ } }, [visible, onClose]) + /** + * Memoized caret position and side calculation. + * getCaretViewportPosition does DOM manipulation, so we avoid calling it on every render. + */ + const { caretViewport, side } = useMemo(() => { + const inputElement = inputRef?.current + if (!inputElement) { + return { caretViewport: { left: 0, top: 0 }, side: 'bottom' as const } + } + + const viewport = getCaretViewportPosition(inputElement, cursorPosition, inputValue) + const margin = 8 + const spaceAbove = viewport.top - margin + const spaceBelow = window.innerHeight - viewport.top - margin + const computedSide: 'top' | 'bottom' = spaceBelow >= spaceAbove ? 'bottom' : 'top' + + return { caretViewport: viewport, side: computedSide } + }, [cursorPosition, inputValue, inputRef]) + if (!visible || tags.length === 0 || flatTagList.length === 0) return null const inputElement = inputRef?.current - let caretViewport = { left: 0, top: 0 } - let side: 'top' | 'bottom' = 'bottom' - - if (inputElement) { - caretViewport = getCaretViewportPosition(inputElement, cursorPosition, inputValue) - - const margin = 8 - const spaceAbove = caretViewport.top - margin - const spaceBelow = window.innerHeight - caretViewport.top - margin - side = spaceBelow >= spaceAbove ? 'bottom' : 'top' - } return ( @@ -1956,6 +1958,11 @@ export const TagDropdown: React.FC = ({ flatTagList={flatTagList} nestedBlockTagGroups={nestedBlockTagGroups} handleTagSelect={handleTagSelect} + onFolderEnter={() => { + if (scrollAreaRef.current) { + scrollAreaRef.current.scrollTop = 0 + } + }} /> = ({ {variableTags.map((tag: string) => { const variableInfo = variableInfoMap?.[tag] || null - const globalIndex = flatTagList.findIndex((item) => item.tag === tag) + const globalIndex = flatTagIndexMap.get(tag) ?? -1 return ( = ({ const rootTagFromTags = group.tags.find((tag) => tag === normalizedBlockName) const rootTag = rootTagFromTags || normalizedBlockName - const rootTagGlobalIndex = flatTagList.findIndex((item) => item.tag === rootTag) + const rootTagGlobalIndex = flatTagIndexMap.get(rootTag) ?? -1 return (
@@ -2054,6 +2061,7 @@ export const TagDropdown: React.FC = ({ nestedTag={nestedTag} group={group} flatTagList={flatTagList} + flatTagIndexMap={flatTagIndexMap} selectedIndex={selectedIndex} setSelectedIndex={setSelectedIndex} handleTagSelect={handleTagSelect} diff --git a/apps/sim/components/emcn/components/popover/popover.tsx b/apps/sim/components/emcn/components/popover/popover.tsx index 8c455bae6..d84200d21 100644 --- a/apps/sim/components/emcn/components/popover/popover.tsx +++ b/apps/sim/components/emcn/components/popover/popover.tsx @@ -513,18 +513,10 @@ const PopoverContent = React.forwardRef< return () => window.removeEventListener('keydown', handleKeyDown, true) }, [context]) - React.useEffect(() => { - const content = contentRef.current - if (!content || !context?.isKeyboardNav || context.selectedIndex < 0) return - - const items = content.querySelectorAll( - '[role="menuitem"]:not([aria-disabled="true"])' - ) - const selectedItem = items[context.selectedIndex] - if (selectedItem) { - selectedItem.scrollIntoView({ block: 'nearest', behavior: 'smooth' }) - } - }, [context?.selectedIndex, context?.isKeyboardNav]) + // Note: scrollIntoView for keyboard navigation is intentionally disabled here. + // Components using Popover (like TagDropdown) should handle their own scroll + // management to avoid conflicts between the popover's internal selection index + // and the component's custom navigation state. const hasUserWidthConstraint = maxWidth !== undefined || @@ -715,7 +707,8 @@ const PopoverItem = React.forwardRef( const handleMouseEnter = (e: React.MouseEvent) => { context?.setLastHoveredItem(null) - if (itemIndex >= 0 && context) { + // Don't update selection during keyboard navigation to prevent scroll jumps + if (itemIndex >= 0 && context && !context.isKeyboardNav) { context.setSelectedIndex(itemIndex) } onMouseEnter?.(e) @@ -896,7 +889,8 @@ const PopoverFolder = React.forwardRef( } const handleMouseEnter = () => { - if (itemIndex >= 0) { + // Don't update selection during keyboard navigation to prevent scroll jumps + if (itemIndex >= 0 && !isKeyboardNav) { setSelectedIndex(itemIndex) } diff --git a/apps/sim/lib/core/security/encryption.ts b/apps/sim/lib/core/security/encryption.ts index db2089816..9f82f4c04 100644 --- a/apps/sim/lib/core/security/encryption.ts +++ b/apps/sim/lib/core/security/encryption.ts @@ -1,4 +1,4 @@ -import { createCipheriv, createDecipheriv, randomBytes } from 'crypto' +import { createCipheriv, createDecipheriv, randomBytes, timingSafeEqual } from 'crypto' import { createLogger } from '@sim/logger' import { env } from '@/lib/core/config/env' @@ -82,3 +82,17 @@ export function generatePassword(length = 24): string { return result } + +/** + * Compares two strings in constant time to prevent timing attacks. + * Used for HMAC signature validation. + * @param a - First string to compare + * @param b - Second string to compare + * @returns True if strings are equal, false otherwise + */ +export function safeCompare(a: string, b: string): boolean { + if (a.length !== b.length) { + return false + } + return timingSafeEqual(Buffer.from(a), Buffer.from(b)) +} diff --git a/apps/sim/lib/webhooks/utils.server.ts b/apps/sim/lib/webhooks/utils.server.ts index 04d66f696..198f70a2b 100644 --- a/apps/sim/lib/webhooks/utils.server.ts +++ b/apps/sim/lib/webhooks/utils.server.ts @@ -1,9 +1,11 @@ +import crypto from 'crypto' import { db, workflowDeploymentVersion } from '@sim/db' import { account, webhook } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull, or } from 'drizzle-orm' import { nanoid } from 'nanoid' import { type NextRequest, NextResponse } from 'next/server' +import { safeCompare } from '@/lib/core/security/encryption' import { type SecureFetchResponse, secureFetchWithPinnedIP, @@ -517,16 +519,7 @@ export async function validateTwilioSignature( match: signatureBase64 === signature, }) - if (signatureBase64.length !== signature.length) { - return false - } - - let result = 0 - for (let i = 0; i < signatureBase64.length; i++) { - result |= signatureBase64.charCodeAt(i) ^ signature.charCodeAt(i) - } - - return result === 0 + return safeCompare(signatureBase64, signature) } catch (error) { logger.error('Error validating Twilio signature:', error) return false @@ -1046,21 +1039,11 @@ export function validateMicrosoftTeamsSignature( const providedSignature = signature.substring(5) - const crypto = require('crypto') const secretBytes = Buffer.from(hmacSecret, 'base64') const bodyBytes = Buffer.from(body, 'utf8') const computedHash = crypto.createHmac('sha256', secretBytes).update(bodyBytes).digest('base64') - if (computedHash.length !== providedSignature.length) { - return false - } - - let result = 0 - for (let i = 0; i < computedHash.length; i++) { - result |= computedHash.charCodeAt(i) ^ providedSignature.charCodeAt(i) - } - - return result === 0 + return safeCompare(computedHash, providedSignature) } catch (error) { logger.error('Error validating Microsoft Teams signature:', error) return false @@ -1090,19 +1073,9 @@ export function validateTypeformSignature( const providedSignature = signature.substring(7) - const crypto = require('crypto') const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('base64') - if (computedHash.length !== providedSignature.length) { - return false - } - - let result = 0 - for (let i = 0; i < computedHash.length; i++) { - result |= computedHash.charCodeAt(i) ^ providedSignature.charCodeAt(i) - } - - return result === 0 + return safeCompare(computedHash, providedSignature) } catch (error) { logger.error('Error validating Typeform signature:', error) return false @@ -1127,7 +1100,6 @@ export function validateLinearSignature(secret: string, signature: string, body: return false } - const crypto = require('crypto') const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') logger.debug('Linear signature comparison', { @@ -1138,16 +1110,7 @@ export function validateLinearSignature(secret: string, signature: string, body: match: computedHash === signature, }) - if (computedHash.length !== signature.length) { - return false - } - - let result = 0 - for (let i = 0; i < computedHash.length; i++) { - result |= computedHash.charCodeAt(i) ^ signature.charCodeAt(i) - } - - return result === 0 + return safeCompare(computedHash, signature) } catch (error) { logger.error('Error validating Linear signature:', error) return false @@ -1176,7 +1139,6 @@ export function validateCirclebackSignature( return false } - const crypto = require('crypto') const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') logger.debug('Circleback signature comparison', { @@ -1187,16 +1149,7 @@ export function validateCirclebackSignature( match: computedHash === signature, }) - if (computedHash.length !== signature.length) { - return false - } - - let result = 0 - for (let i = 0; i < computedHash.length; i++) { - result |= computedHash.charCodeAt(i) ^ signature.charCodeAt(i) - } - - return result === 0 + return safeCompare(computedHash, signature) } catch (error) { logger.error('Error validating Circleback signature:', error) return false @@ -1230,7 +1183,6 @@ export function validateJiraSignature(secret: string, signature: string, body: s const providedSignature = signature.substring(7) - const crypto = require('crypto') const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') logger.debug('Jira signature comparison', { @@ -1241,16 +1193,7 @@ export function validateJiraSignature(secret: string, signature: string, body: s match: computedHash === providedSignature, }) - if (computedHash.length !== providedSignature.length) { - return false - } - - let result = 0 - for (let i = 0; i < computedHash.length; i++) { - result |= computedHash.charCodeAt(i) ^ providedSignature.charCodeAt(i) - } - - return result === 0 + return safeCompare(computedHash, providedSignature) } catch (error) { logger.error('Error validating Jira signature:', error) return false @@ -1288,7 +1231,6 @@ export function validateFirefliesSignature( const providedSignature = signature.substring(7) - const crypto = require('crypto') const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') logger.debug('Fireflies signature comparison', { @@ -1299,16 +1241,7 @@ export function validateFirefliesSignature( match: computedHash === providedSignature, }) - if (computedHash.length !== providedSignature.length) { - return false - } - - let result = 0 - for (let i = 0; i < computedHash.length; i++) { - result |= computedHash.charCodeAt(i) ^ providedSignature.charCodeAt(i) - } - - return result === 0 + return safeCompare(computedHash, providedSignature) } catch (error) { logger.error('Error validating Fireflies signature:', error) return false @@ -1333,7 +1266,6 @@ export function validateGitHubSignature(secret: string, signature: string, body: return false } - const crypto = require('crypto') let algorithm: 'sha256' | 'sha1' let providedSignature: string @@ -1361,16 +1293,7 @@ export function validateGitHubSignature(secret: string, signature: string, body: match: computedHash === providedSignature, }) - if (computedHash.length !== providedSignature.length) { - return false - } - - let result = 0 - for (let i = 0; i < computedHash.length; i++) { - result |= computedHash.charCodeAt(i) ^ providedSignature.charCodeAt(i) - } - - return result === 0 + return safeCompare(computedHash, providedSignature) } catch (error) { logger.error('Error validating GitHub signature:', error) return false @@ -2556,7 +2479,6 @@ export function validateCalcomSignature(secret: string, signature: string, body: return false } - const crypto = require('crypto') const computedHash = crypto.createHmac('sha256', secret).update(body, 'utf8').digest('hex') logger.debug('Cal.com signature comparison', { @@ -2567,17 +2489,7 @@ export function validateCalcomSignature(secret: string, signature: string, body: match: computedHash === signature, }) - if (computedHash.length !== signature.length) { - return false - } - - // Constant-time comparison to prevent timing attacks - let result = 0 - for (let i = 0; i < computedHash.length; i++) { - result |= computedHash.charCodeAt(i) ^ signature.charCodeAt(i) - } - - return result === 0 + return safeCompare(computedHash, signature) } catch (error) { logger.error('Error validating Cal.com signature:', error) return false