fix(tag-dropdown): performance improvements and scroll bug fixes

- Add flatTagIndexMap for O(1) tag lookups (replaces O(n²) findIndex calls)
- Memoize caret position calculation to avoid DOM manipulation on every render
- Use refs for inputValue/cursorPosition to keep handleTagSelect callback stable
- Change itemRefs from index-based to tag-based keys to prevent stale refs
- Fix scroll jump in nested folders by removing scroll reset from registerFolder
- Add onFolderEnter callback for scroll reset when entering folder via keyboard
- Disable keyboard navigation wrap-around at boundaries
- Simplify selection reset to single effect on flatTagList.length change

Also:
- Add safeCompare utility for timing-safe string comparison
- Refactor webhook signature validation to use safeCompare

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
waleed
2026-01-29 12:02:10 -08:00
parent ded36d3c70
commit ee5f623bc3
5 changed files with 120 additions and 187 deletions

View File

@@ -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<KeyboardNavigationHandlerProps>
flatTagList,
nestedBlockTagGroups,
handleTagSelect,
onFolderEnter,
}) => {
const { openFolder, closeFolder, isInFolder, currentFolder, setKeyboardNav } = usePopoverContext()
const nestedNav = useNestedNavigation()
@@ -251,7 +254,7 @@ export const KeyboardNavigationHandler: React.FC<KeyboardNavigationHandlerProps>
} 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<KeyboardNavigationHandlerProps>
} 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<KeyboardNavigationHandlerProps>
currentFolderInfo.parentTag,
currentFolderInfo.group
)
onFolderEnter?.()
}
}
break
@@ -346,6 +350,7 @@ export const KeyboardNavigationHandler: React.FC<KeyboardNavigationHandlerProps>
handleTagSelect,
nestedNav,
setKeyboardNav,
onFolderEnter,
])
return null

View File

@@ -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<string, number>
selectedIndex: number
setSelectedIndex: (index: number) => void
handleTagSelect: (tag: string, blockGroup?: BlockTagGroup) => void
itemRefs: React.RefObject<Map<number, HTMLElement>>
itemRefs: React.RefObject<Map<string, HTMLElement>>
blocks: Record<string, BlockState>
getMergedSubBlocks: (blockId: string) => Record<string, any>
}
@@ -469,6 +471,7 @@ interface FolderContentsProps extends NestedTagRendererProps {
const FolderContentsInner: React.FC<FolderContentsProps> = ({
group,
flatTagList,
flatTagIndexMap,
selectedIndex,
setSelectedIndex,
handleTagSelect,
@@ -483,7 +486,7 @@ const FolderContentsInner: React.FC<FolderContentsProps> = ({
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<FolderContentsProps> = ({
<PopoverItem
active={parentTagIndex === selectedIndex && parentTagIndex >= 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<FolderContentsProps> = ({
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<FolderContentsProps> = ({
{/* 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<FolderContentsProps> = ({
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<FolderContentsProps> = ({
{/* 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<FolderContentsProps> = ({
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<FolderContentsProps> = ({
* Wrapper component that uses shared nested navigation state from context.
* Handles registration of the base folder and navigation callbacks.
*/
const FolderContents: React.FC<NestedTagRendererProps> = (props) => {
const FolderContents: React.FC<Omit<NestedTagRendererProps, never>> = (props) => {
const nestedNav = useNestedNavigation()
const { currentFolder } = usePopoverContext()
@@ -638,6 +639,7 @@ const NestedTagRenderer: React.FC<NestedTagRendererProps> = ({
nestedTag,
group,
flatTagList,
flatTagIndexMap,
selectedIndex,
setSelectedIndex,
handleTagSelect,
@@ -653,7 +655,7 @@ const NestedTagRenderer: React.FC<NestedTagRendererProps> = ({
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<NestedTagRendererProps> = ({
}
}}
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<NestedTagRendererProps> = ({
nestedTag={nestedTag}
group={group}
flatTagList={flatTagList}
flatTagIndexMap={flatTagIndexMap}
selectedIndex={selectedIndex}
setSelectedIndex={setSelectedIndex}
handleTagSelect={handleTagSelect}
@@ -695,10 +698,7 @@ const NestedTagRenderer: React.FC<NestedTagRendererProps> = ({
)
}
// 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<NestedTagRendererProps> = ({
}
}}
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<NestedTagRendererProps> = ({
}
/**
* 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<Map<number, HTMLElement>>
itemRefs: React.RefObject<Map<string, HTMLElement>>
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<Map<number, HTMLElement>>
itemRefs: React.RefObject<Map<string, HTMLElement>>
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 (
<div
className={cn(
@@ -986,7 +982,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
inputRef,
}) => {
const [selectedIndex, setSelectedIndex] = useState(0)
const itemRefs = useRef<Map<number, HTMLElement>>(new Map())
const itemRefs = useRef<Map<string, HTMLElement>>(new Map())
const [nestedPath, setNestedPath] = useState<NestedTag[]>([])
const baseFolderRef = useRef<{
@@ -998,6 +994,11 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
const handleTagSelectRef = useRef<((tag: string, group?: BlockTagGroup) => void) | null>(null)
const scrollAreaRef = useRef<HTMLDivElement>(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<TagDropdownProps> = ({
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<string, number>()
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<TagDropdownProps> = ({
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<TagDropdownProps> = ({
},
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<TagDropdownProps> = ({
}
}, [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<TagDropdownProps> = ({
}
}, [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 (
<NestedNavigationContext.Provider value={nestedNavigationValue}>
@@ -1956,6 +1958,11 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
flatTagList={flatTagList}
nestedBlockTagGroups={nestedBlockTagGroups}
handleTagSelect={handleTagSelect}
onFolderEnter={() => {
if (scrollAreaRef.current) {
scrollAreaRef.current.scrollTop = 0
}
}}
/>
<PopoverContent
maxHeight={240}
@@ -1984,7 +1991,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
</PopoverSection>
{variableTags.map((tag: string) => {
const variableInfo = variableInfoMap?.[tag] || null
const globalIndex = flatTagList.findIndex((item) => item.tag === tag)
const globalIndex = flatTagIndexMap.get(tag) ?? -1
return (
<VariableTagItem
@@ -2027,7 +2034,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
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 (
<div key={group.blockId}>
@@ -2054,6 +2061,7 @@ export const TagDropdown: React.FC<TagDropdownProps> = ({
nestedTag={nestedTag}
group={group}
flatTagList={flatTagList}
flatTagIndexMap={flatTagIndexMap}
selectedIndex={selectedIndex}
setSelectedIndex={setSelectedIndex}
handleTagSelect={handleTagSelect}

View File

@@ -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<HTMLElement>(
'[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<HTMLDivElement, PopoverItemProps>(
const handleMouseEnter = (e: React.MouseEvent<HTMLDivElement>) => {
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<HTMLDivElement, PopoverFolderProps>(
}
const handleMouseEnter = () => {
if (itemIndex >= 0) {
// Don't update selection during keyboard navigation to prevent scroll jumps
if (itemIndex >= 0 && !isKeyboardNav) {
setSelectedIndex(itemIndex)
}

View File

@@ -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))
}

View File

@@ -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