# UNDF: UNDF-2026-000001276 # UNDF: UNDF-2026-XXXXXXXXX # CWE-407: Algorithmic Complexity -- O(N*k) -> O(N+k) in ARIA snapshot hot paths # # Defect: roleUtils.ts runs per-element during ARIA tree walks. Three helpers # (getExplicitAriaRole, allowsNameFromContent, hasGlobalAriaAttribute) each # call Array.includes on constant arrays of 20-70 role strings. For a page # with N=5000 elements and k=70 roles, total cost is O(N*k) = 350,000 # string comparisons per snapshot. # # Fix: Convert the hot-path constant arrays to Set at module scope. # Per-element cost drops from O(k) to O(1). Keeps the original arrays as # source-of-truth for readability; builds the Sets once. # # Complexity gate (tests/test-playwright-cwe407.py): # k-scaling 5x: time ratio must be <17.5x (O(k) ~=5x, not O(k^2) ~=25x) # N=5000 elements snapshot: must complete in <10ms --- a/packages/injected/src/roleUtils.ts +++ b/packages/injected/src/roleUtils.ts @@ -56,11 +56,14 @@ const kGlobalAriaAttributes: [string, string[] | undefined][] = [ ['aria-roledescription', ['generic']], ]; +// Pre-built Set views of the prohibited-role lists so hasGlobalAriaAttribute can do +// O(1) membership lookup per attribute check instead of scanning the prohibited array +// on every element during ARIA snapshot walks. +const kGlobalAriaAttributeProhibitedSets: [string, Set | undefined][] = + kGlobalAriaAttributes.map(([attr, prohibited]) => [attr, prohibited ? new Set(prohibited) : undefined]); + function hasGlobalAriaAttribute(element: Element, forRole?: string | null) { - return kGlobalAriaAttributes.some(([attr, prohibited]) => { - return !prohibited?.includes(forRole || '') && element.hasAttribute(attr); - }); + return kGlobalAriaAttributeProhibitedSets.some(([attr, prohibited]) => { + return !prohibited?.has(forRole || '') && element.hasAttribute(attr); + }); } function hasTabIndex(element: Element) { @@ -265,10 +268,13 @@ const validRoles: AriaRole[] = ['alert', 'alertdialog', 'application', 'article' 'spinbutton', 'status', 'strong', 'subscript', 'superscript', 'switch', 'tab', 'table', 'tablist', 'tabpanel', 'term', 'textbox', 'time', 'timer', 'toolbar', 'tooltip', 'tree', 'treegrid', 'treeitem']; +// Set view of validRoles so getExplicitAriaRole can test role membership in O(1) +// instead of scanning the 70+ element array per element during snapshot walks. +const validRolesSet = new Set(validRoles); + function getExplicitAriaRole(element: Element): AriaRole | null { // https://www.w3.org/TR/wai-aria-1.2/#document-handling_author-errors_roles const roles = (element.getAttribute('role') || '').split(' ').map(role => role.trim()); - return roles.find(role => validRoles.includes(role as any)) as AriaRole || null; + return roles.find(role => validRolesSet.has(role)) as AriaRole || null; } function hasPresentationConflictResolution(element: Element, role: string | null) { @@ -496,12 +502,21 @@ function allowsNameFromContent(role: string, targetDescendant: boolean) { // See chromium implementation here: // https://source.chromium.org/chromium/chromium/src/+/main:third_party/blink/renderer/modules/accessibility/ax_object.cc;l=6338;drc=3decef66bc4c08b142a19db9628e9efe68973e64;bpv=0;bpt=1 - const alwaysAllowsNameFromContent = ['button', 'cell', 'checkbox', 'columnheader', 'gridcell', 'heading', 'link', 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'radio', 'row', 'rowheader', 'switch', 'tab', 'tooltip', 'treeitem'].includes(role); - const descendantAllowsNameFromContent = targetDescendant && ['', 'caption', 'code', 'contentinfo', 'definition', 'deletion', 'emphasis', 'insertion', 'list', 'listitem', 'mark', 'none', 'paragraph', 'presentation', 'region', 'row', 'rowgroup', 'section', 'strong', 'subscript', 'superscript', 'table', 'term', 'time'].includes(role); + const alwaysAllowsNameFromContent = kAlwaysAllowsNameFromContentSet.has(role); + const descendantAllowsNameFromContent = targetDescendant && kDescendantAllowsNameFromContentSet.has(role); return alwaysAllowsNameFromContent || descendantAllowsNameFromContent; } +// Set views of the allowsNameFromContent role lists. Built once at module load; +// per-call membership test is O(1) vs the prior O(k) Array.includes scan. +const kAlwaysAllowsNameFromContentSet = new Set([ + 'button', 'cell', 'checkbox', 'columnheader', 'gridcell', 'heading', 'link', + 'menuitem', 'menuitemcheckbox', 'menuitemradio', 'option', 'radio', 'row', + 'rowheader', 'switch', 'tab', 'tooltip', 'treeitem']); +const kDescendantAllowsNameFromContentSet = new Set([ + '', 'caption', 'code', 'contentinfo', 'definition', 'deletion', 'emphasis', + 'insertion', 'list', 'listitem', 'mark', 'none', 'paragraph', 'presentation', + 'region', 'row', 'rowgroup', 'section', 'strong', 'subscript', 'superscript', + 'table', 'term', 'time']); + export function getElementAccessibleName(element: Element, includeHidden: boolean): string { const cache = (includeHidden ? cacheAccessibleNameHidden : cacheAccessibleName); let accessibleName = cache?.get(element);