diff --git a/php-transformer/src/HtmlToBlocks/Classification/FormControlClassifier.php b/php-transformer/src/HtmlToBlocks/Classification/FormControlClassifier.php index 494cc7792..164e38fdf 100644 --- a/php-transformer/src/HtmlToBlocks/Classification/FormControlClassifier.php +++ b/php-transformer/src/HtmlToBlocks/Classification/FormControlClassifier.php @@ -212,8 +212,9 @@ public static function isNonAuthoredControl(DOMElement $control): bool } /** - * An untabbable free-text field is a trap when aria-hidden on itself, or - * invisible and non-interactive on itself or its immediate field container. + * An untabbable free-text field is a trap when aria-hidden on itself or on + * an ancestor inside its form, or invisible and non-interactive on itself + * or its immediate field container. * Choice and select value carriers share those attributes beside their * visible widget, so only free-text entry qualifies. */ @@ -229,7 +230,8 @@ private static function isUnreachableTextTrap(DOMElement $control): bool || ! in_array(self::controlType($control), array( 'text', 'email', 'url', 'tel', 'search', 'number' ), true) ) ) { return false; } - if ( 'true' === strtolower(trim($control->getAttribute('aria-hidden'))) ) { + if ( 'true' === strtolower(trim($control->getAttribute('aria-hidden'))) + || null !== self::ariaHiddenFormAncestor($control) ) { return true; } foreach ( array( $control, $control->parentNode ) as $element ) { @@ -251,6 +253,26 @@ private static function isUnreachableTextTrap(DOMElement $control): bool return false; } + /** + * The outermost `aria-hidden="true"` ancestor between a control and its + * native form, or null when the control sits outside a form or no + * ancestor below the form is hidden from assistive technology. + */ + private static function ariaHiddenFormAncestor(DOMElement $control): ?DOMElement + { + $hidden = null; + for ( $parent = $control->parentNode; $parent instanceof DOMElement; $parent = $parent->parentNode ) { + if ( 'form' === strtolower($parent->tagName) ) { + return $hidden; + } + if ( 'true' === strtolower(trim($parent->getAttribute('aria-hidden'))) ) { + $hidden = $parent; + } + } + + return null; + } + /** * A decoy field bots fill and no reader sees: `aria-hidden="true"`, out of * the tab order, and hidden or moved off-screen by inline style — all @@ -268,9 +290,11 @@ public static function isHoneypotControl(DOMElement $control): bool /** * A trap owns its associated/wrapping label and its immediate field - * container's copy. Never claim a form/group or a container shared with - * a real control, and count raw controls here (authored enumeration has - * already dropped the traps). + * container's copy. A trap hidden by an aria-hidden ancestor inside its + * form also owns every container up to and including that ancestor. + * Never claim a form/group or a container shared with a real control, + * and count raw controls here (authored enumeration has already dropped + * the traps). * * @return array */ @@ -285,22 +309,36 @@ public static function honeypotCopyElements(DOMElement $form): array if ( null !== $label ) { $elements[] = $label; } - $container = $control->parentNode; - if ( ! $container instanceof DOMElement - || in_array(strtolower($container->tagName), array( 'form', 'fieldset', 'body', 'html' ), true) ) { - continue; - } - foreach ( $container->getElementsByTagName('*') as $sibling ) { - if ( $sibling instanceof DOMElement && self::isControlElement($sibling) && ! self::isHoneypotControl($sibling) ) { - continue 2; + $hiddenAncestor = self::isUnreachableTextTrap($control) ? self::ariaHiddenFormAncestor($control) : null; + for ( $container = $control->parentNode; $container instanceof DOMElement; $container = $container->parentNode ) { + if ( in_array(strtolower($container->tagName), array( 'form', 'fieldset', 'body', 'html' ), true) + || self::holdsAuthoredControl($container) ) { + break; + } + if ( ! in_array($container, $elements, true) ) { + $elements[] = $container; + } + if ( null === $hiddenAncestor || $container === $hiddenAncestor ) { + break; } } - $elements[] = $container; } return $elements; } + /** Whether a container holds a raw control that is not a honeypot. */ + private static function holdsAuthoredControl(DOMElement $container): bool + { + foreach ( $container->getElementsByTagName('*') as $element ) { + if ( $element instanceof DOMElement && self::isControlElement($element) && ! self::isHoneypotControl($element) ) { + return true; + } + } + + return false; + } + /** The control's wrapping label when that label, not the control, carries the honeypot signals. */ private static function honeypotSignalLabel(DOMElement $control): ?DOMElement { diff --git a/php-transformer/tests/unit/invisible-unreachable-text-trap.php b/php-transformer/tests/unit/invisible-unreachable-text-trap.php index a9cec4378..f41ea7e8f 100644 --- a/php-transformer/tests/unit/invisible-unreachable-text-trap.php +++ b/php-transformer/tests/unit/invisible-unreachable-text-trap.php @@ -3,6 +3,7 @@ require dirname(__DIR__, 2) . '/vendor/autoload.php'; +use Automattic\BlocksEngine\PhpTransformer\ArtifactCompiler\ArtifactCompiler; use Automattic\BlocksEngine\PhpTransformer\HtmlToBlocks\Classification\FormControlClassifier; $failures = 0; @@ -64,5 +65,56 @@ $assert(1 === count(FormControlClassifier::controlElements($parse($html))), $boundary . ' is not a field container signal'); } +// An untabbable free-text trap hidden by an aria-hidden wrapper inside its +// form: the wrapper, its label, and the control stay out of readable blocks. +$wrapper = ''; +$contact = '
' + . '

' + . '

' + . '

' + . $wrapper + . '
'; + +$form = $parse($contact); +$names = array_map(static fn (DOMElement $control): string => $control->getAttribute('name'), FormControlClassifier::controlElements($form)); +$assert(! in_array('hp', $names, true) && array( 'name', 'email', 'message' ) === array_values(array_filter($names)), 'aria-hidden ancestor trap is not an authored control', json_encode($names)); +$copy = FormControlClassifier::honeypotCopyElements($form); +$assert(in_array($form->getElementsByTagName('label')->item(3), $copy, true), 'aria-hidden ancestor trap owns its label'); +$assert(in_array($form->getElementsByTagName('div')->item(0), $copy, true), 'aria-hidden ancestor trap owns its trap-only wrapper'); + +$result = ( new ArtifactCompiler() )->compile(array( + 'entrypoint' => 'index.html', + 'files' => array( 'index.html' => $contact ), +))->toArray(); +$readable = $result['fallbacks'][0]['readable_blocks'] ?? array(); +$assert(array() !== $readable, 'contact form yields readable blocks'); +$blockNames = array(); +$emptyGroups = 0; +$walk = static function (array $blocks) use (&$walk, &$blockNames, &$emptyGroups): void { + foreach ( $blocks as $block ) { + $blockNames[] = $block['blockName'] ?? null; + $inner = $block['innerBlocks'] ?? array(); + if ( 'core/group' === ( $block['blockName'] ?? null ) && array() === $inner ) { + ++$emptyGroups; + } + $walk($inner); + } +}; +$walk($readable); +$assert(! in_array('core/html', $blockNames, true), 'aria-hidden wrapper trap leaves no core/html in readable blocks', json_encode($blockNames)); +$assert(! str_contains((string) json_encode($readable), 'Leave empty'), 'no trap label text reaches readable blocks'); +$assert(1 === count(array_keys($blockNames, 'core/group', true)) && 0 === $emptyGroups, 'the trap wrapper container is gone from readable blocks', json_encode($blockNames)); +$layoutGraph = (string) json_encode($result['fallbacks'][0]['layout_graph'] ?? array()); +$assert(! str_contains($layoutGraph, 'Leave empty') && ! str_contains($layoutGraph, '"hp"'), 'no trap markup reaches the form layout graph'); + +// Choice carriers under the same aria-hidden wrapper keep their behaviour. +$form = $parse('
' . str_replace('type="text"', 'type="checkbox"', $wrapper) . '
'); +$assert(array() === FormControlClassifier::honeypotCopyElements($form), 'aria-hidden wrapper does not make a choice carrier a trap'); + +// The ancestor walk is bounded by the form. +$form = $parse(''); +$assert(array() === FormControlClassifier::honeypotCopyElements($form), 'aria-hidden outside the form does not make a text trap'); + fwrite(STDOUT, "invisible unreachable text trap: {$failures} failures\n"); exit($failures > 0 ? 1 : 0);