Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand All @@ -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 ) {
Expand All @@ -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
Expand 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<int, DOMElement>
*/
Expand All @@ -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
{
Expand Down
52 changes: 52 additions & 0 deletions php-transformer/tests/unit/invisible-unreachable-text-trap.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 = '<div aria-hidden="true" style="position:absolute;left:-10000px;width:1px;height:1px;overflow:hidden">'
. '<label>Leave empty<input type="text" name="hp" tabindex="-1" autocomplete="off"></label></div>';
$contact = '<main><form method="post" action="/contact">'
. '<p><label for="name">Name</label><input type="text" id="name" name="name"></p>'
. '<p><label for="email">Email</label><input type="email" id="email" name="email"></p>'
. '<p><label for="message">Message</label><textarea id="message" name="message"></textarea></p>'
. $wrapper
. '<button type="submit">Send</button></form></main>';

$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('<form>' . str_replace('type="text"', 'type="checkbox"', $wrapper) . '</form>');
$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('<div aria-hidden="true"><form><input type="text" name="hp" tabindex="-1"></form></div>');
$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);
Loading