diff --git a/sonar-html-plugin/src/main/java/org/sonar/plugins/html/api/accessibility/ControlGroup.java b/sonar-html-plugin/src/main/java/org/sonar/plugins/html/api/accessibility/ControlGroup.java
new file mode 100644
index 000000000..e412d2400
--- /dev/null
+++ b/sonar-html-plugin/src/main/java/org/sonar/plugins/html/api/accessibility/ControlGroup.java
@@ -0,0 +1,141 @@
+/*
+ * SonarSource HTML analyzer :: Sonar Plugin
+ * Copyright (c) 2010-2024 SonarSource SA and Matthijs Galesloot
+ * sonarqube@googlegroups.com
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sonar.plugins.html.api.accessibility;
+
+import org.sonar.plugins.html.node.TagNode;
+
+public class ControlGroup {
+ public static boolean belongsToAutofillExpectationMantleControlGroup(TagNode node) {
+ if (!node.getNodeName().equalsIgnoreCase("input")) {
+ return true;
+ }
+
+ var type = node.getAttribute("type");
+
+ return type == null || !type.equalsIgnoreCase("hidden");
+ }
+
+ public static boolean belongsToDateControlGroup(TagNode node) {
+ if (belongsToTextControlGroup(node)) {
+ return true;
+ }
+
+ var type = node.getAttribute("type");
+
+ return type != null && type.equalsIgnoreCase("date");
+ }
+
+ public static boolean belongsToMonthControlGroup(TagNode node) {
+ var type = node.getAttribute("type");
+
+ if (type != null && type.equalsIgnoreCase("month")) {
+ return true;
+ }
+
+ return belongsToTextControlGroup(node);
+ }
+
+ public static boolean belongsToMultilineControlGroup(TagNode node) {
+ var nodeName = node.getNodeName();
+
+ if (nodeName.equalsIgnoreCase("textarea") || nodeName.equalsIgnoreCase("select")) {
+ return true;
+ }
+
+ if (!nodeName.equalsIgnoreCase("input")) {
+ return false;
+ }
+
+ var type = node.getAttribute("type");
+
+ return type != null && type.equalsIgnoreCase("hidden");
+ }
+
+ public static boolean belongsToNumericControlGroup(TagNode node) {
+ var type = node.getAttribute("type");
+
+ if (type != null && type.equalsIgnoreCase("number")) {
+ return true;
+ }
+
+ return belongsToTextControlGroup(node);
+ }
+
+ public static boolean belongsToPasswordControlGroup(TagNode node) {
+ var type = node.getAttribute("type");
+
+ if (type != null && type.equalsIgnoreCase("password")) {
+ return true;
+ }
+
+ return belongsToTextControlGroup(node);
+ }
+
+ public static boolean belongsToTelControlGroup(TagNode node) {
+ var type = node.getAttribute("type");
+
+ if (type != null && type.equalsIgnoreCase("tel")) {
+ return true;
+ }
+
+ return belongsToTextControlGroup(node);
+ }
+
+ public static boolean belongsToTextControlGroup(TagNode node) {
+ var nodeName = node.getNodeName();
+
+ if (nodeName.equalsIgnoreCase("textarea") || nodeName.equalsIgnoreCase("select")) {
+ return true;
+ }
+
+ if (!nodeName.equalsIgnoreCase("input")) {
+ return false;
+ }
+
+ var type = node.getAttribute("type");
+
+ if (type == null) {
+ return false;
+ }
+
+ return type.equalsIgnoreCase("hidden") || type.equalsIgnoreCase("text") || type.equalsIgnoreCase("search");
+ }
+
+ public static boolean belongsToUrlControlGroup(TagNode node) {
+ var type = node.getAttribute("type");
+
+ if (type != null && type.equalsIgnoreCase("url")) {
+ return true;
+ }
+
+ return belongsToTextControlGroup(node);
+ }
+
+ public static boolean belongsToUsernameControlGroup(TagNode node) {
+ var type = node.getAttribute("type");
+
+ if (type != null && type.equalsIgnoreCase("email")) {
+ return true;
+ }
+
+ return belongsToTextControlGroup(node);
+ }
+
+ private ControlGroup() {
+ }
+}
diff --git a/sonar-html-plugin/src/main/java/org/sonar/plugins/html/checks/accessibility/ValidAutocompleteCheck.java b/sonar-html-plugin/src/main/java/org/sonar/plugins/html/checks/accessibility/ValidAutocompleteCheck.java
new file mode 100644
index 000000000..00a583cea
--- /dev/null
+++ b/sonar-html-plugin/src/main/java/org/sonar/plugins/html/checks/accessibility/ValidAutocompleteCheck.java
@@ -0,0 +1,333 @@
+/*
+ * SonarSource HTML analyzer :: Sonar Plugin
+ * Copyright (c) 2010-2024 SonarSource SA and Matthijs Galesloot
+ * sonarqube@googlegroups.com
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.sonar.plugins.html.checks.accessibility;
+
+import org.sonar.check.Rule;
+import org.sonar.plugins.html.api.accessibility.ControlGroup;
+import org.sonar.plugins.html.checks.AbstractPageCheck;
+import org.sonar.plugins.html.node.TagNode;
+
+import java.util.List;
+import java.util.function.Predicate;
+import java.util.stream.Stream;
+
+@Rule(key = "S6840")
+public class ValidAutocompleteCheck extends AbstractPageCheck {
+ /**
+ * A very naive and straightforward implementation of a validator capable of testing a candidate against a series
+ * of predicates until either one is matched or none is. Should totally be replaced by a validation library would
+ * we eventually decide to use one.
+ */
+ static class Validator {
+ protected List> predicates;
+
+ public Validator(List> predicates) {
+ this.predicates = predicates;
+ }
+
+ boolean isValid(TagNode tagNode) {
+ var autocomplete = tagNode.getAttribute("autocomplete");
+
+ if (autocomplete == null) {
+ return true;
+ }
+
+ var tokens = isADirective(autocomplete) ? new String[]{autocomplete} : autocomplete.split("\\s+");
+ var numberOfPredicates = predicates.size();
+
+ if (tokens.length != numberOfPredicates) {
+ return false;
+ }
+
+ var isValid = true;
+
+ for (int i = 0; i < numberOfPredicates; i++) {
+ var predicate = predicates.get(i);
+ var token = tokens[i];
+ var candidate = new Candidate(token, tagNode);
+
+ isValid = isValid && predicate.test(candidate);
+ }
+
+ return isValid;
+ }
+ }
+
+ /**
+ * A class representing an autocomplete token as defined by the HTML specification - i.e. a value and a control
+ * group this value belongs to.
+ * See https://html.spec.whatwg.org/multipage/form-control-infrastructure.html#autofilling-form-controls:-the-autocomplete-attribute
+ */
+ static class Token {
+ private final String value;
+
+ private final Predicate controlGroupPredicate;
+
+ Token(
+ String value,
+ Predicate controlGroup
+ ) {
+ this.value = value;
+ this.controlGroupPredicate = controlGroup;
+ }
+ }
+
+ /**
+ * A class representing an autocomplete value in the context of a tag - e.g. "cc-exp" in the context of an "input" -
+ * that can be validated as a whole.
+ */
+ static class Candidate {
+ private final String value;
+
+ private final TagNode tagNode;
+
+ Candidate(
+ String value,
+ TagNode tagNode
+ ) {
+ this.value = value;
+ this.tagNode = tagNode;
+ }
+
+ public boolean satisfies(Token token) {
+ /*
+ * Directives are considered as wildcards - i.e. they always match the passed token
+ */
+ return (isADirective(this.value) || this.value.equalsIgnoreCase(token.value)) && token.controlGroupPredicate.test(this.tagNode);
+ }
+ }
+
+ static List validators = List.of(
+ new Validator(List.of(
+ candidate -> candidate.value.isBlank()
+ )),
+ new Validator(List.of(
+ candidate -> isADirective(candidate.value)
+ )),
+ new Validator(List.of(
+ ValidAutocompleteCheck::isAOnOffToken
+ )),
+ new Validator(List.of(
+ ValidAutocompleteCheck::isASection,
+ ValidAutocompleteCheck::isAnAutofillFieldNameToken
+ )),
+ new Validator(List.of(
+ ValidAutocompleteCheck::isASection,
+ ValidAutocompleteCheck::isAnAutofillFieldNameToken,
+ ValidAutocompleteCheck::isAWebAuthnToken
+ )),
+ new Validator(List.of(
+ ValidAutocompleteCheck::isASection,
+ ValidAutocompleteCheck::isAnAddressType,
+ ValidAutocompleteCheck::isAnAutofillFieldNameToken
+ )),
+ new Validator(List.of(
+ ValidAutocompleteCheck::isASection,
+ ValidAutocompleteCheck::isAnAddressType,
+ ValidAutocompleteCheck::isAnAutofillFieldNameToken,
+ ValidAutocompleteCheck::isAWebAuthnToken
+ )),
+ new Validator(List.of(
+ ValidAutocompleteCheck::isASection,
+ ValidAutocompleteCheck::isAnAddressType,
+ ValidAutocompleteCheck::isAMediumValueToken
+ )),
+ new Validator(List.of(
+ ValidAutocompleteCheck::isASection,
+ ValidAutocompleteCheck::isAnAddressType,
+ ValidAutocompleteCheck::isAMediumValueToken,
+ ValidAutocompleteCheck::isAWebAuthnToken
+ )),
+ new Validator(List.of(
+ ValidAutocompleteCheck::isASection,
+ ValidAutocompleteCheck::isAnAddressType,
+ ValidAutocompleteCheck::isAMediumTypeToken,
+ ValidAutocompleteCheck::isAMediumValueToken
+ )),
+ new Validator(List.of(
+ ValidAutocompleteCheck::isASection,
+ ValidAutocompleteCheck::isAnAddressType,
+ ValidAutocompleteCheck::isAMediumTypeToken,
+ ValidAutocompleteCheck::isAMediumValueToken,
+ ValidAutocompleteCheck::isAWebAuthnToken
+ )),
+ new Validator(List.of(
+ ValidAutocompleteCheck::isAnAddressType,
+ ValidAutocompleteCheck::isAnAutofillFieldNameToken
+ )),
+ new Validator(List.of(
+ ValidAutocompleteCheck::isAnAddressType,
+ ValidAutocompleteCheck::isAnAutofillFieldNameToken,
+ ValidAutocompleteCheck::isAWebAuthnToken
+ )),
+ new Validator(List.of(
+ ValidAutocompleteCheck::isAnAddressType,
+ ValidAutocompleteCheck::isAMediumTypeToken
+ )),
+ new Validator(List.of(
+ ValidAutocompleteCheck::isAnAddressType,
+ ValidAutocompleteCheck::isAMediumTypeToken,
+ ValidAutocompleteCheck::isAWebAuthnToken
+ )),
+ new Validator(List.of(
+ ValidAutocompleteCheck::isAnAddressType,
+ ValidAutocompleteCheck::isAMediumTypeToken,
+ ValidAutocompleteCheck::isAMediumValueToken
+ )),
+ new Validator(List.of(
+ ValidAutocompleteCheck::isAnAddressType,
+ ValidAutocompleteCheck::isAMediumTypeToken,
+ ValidAutocompleteCheck::isAMediumValueToken,
+ ValidAutocompleteCheck::isAWebAuthnToken
+ )),
+ new Validator(List.of(
+ ValidAutocompleteCheck::isAnAutofillFieldNameToken
+ )),
+ new Validator(List.of(
+ ValidAutocompleteCheck::isAnAutofillFieldNameToken,
+ ValidAutocompleteCheck::isAWebAuthnToken
+ )),
+ new Validator(List.of(
+ ValidAutocompleteCheck::isAMediumValueToken
+ )),
+ new Validator(List.of(
+ ValidAutocompleteCheck::isAMediumValueToken,
+ ValidAutocompleteCheck::isAWebAuthnToken
+ )),
+ new Validator(List.of(
+ ValidAutocompleteCheck::isAMediumTypeToken,
+ ValidAutocompleteCheck::isAMediumValueToken
+ )),
+ new Validator(List.of(
+ ValidAutocompleteCheck::isAMediumTypeToken,
+ ValidAutocompleteCheck::isAMediumValueToken,
+ ValidAutocompleteCheck::isAWebAuthnToken
+ ))
+ );
+
+ static boolean isADirective(String value) {
+ return value.startsWith("<%=") || value.startsWith("");
+ }
+
+ static boolean isAOnOffToken(Candidate candidate) {
+ return Stream.of(
+ new Token("on", ControlGroup::belongsToAutofillExpectationMantleControlGroup),
+ new Token("off", ControlGroup::belongsToAutofillExpectationMantleControlGroup)
+ ).anyMatch(candidate::satisfies);
+ }
+
+ static boolean isAMediumTypeToken(Candidate candidate) {
+ return Stream.of(
+ "home",
+ "work",
+ "mobile",
+ "fax",
+ "pager"
+ ).anyMatch(item -> item.equalsIgnoreCase(candidate.value));
+ }
+
+ static boolean isAMediumValueToken(Candidate candidate) {
+ return Stream.of(
+ new Token("tel", ControlGroup::belongsToTelControlGroup),
+ new Token("tel-country-code", ControlGroup::belongsToTextControlGroup),
+ new Token("tel-national", ControlGroup::belongsToTextControlGroup),
+ new Token("tel-area-code", ControlGroup::belongsToTextControlGroup),
+ new Token("tel-local", ControlGroup::belongsToTextControlGroup),
+ new Token("tel-local-prefix", ControlGroup::belongsToTextControlGroup),
+ new Token("tel-local-suffix", ControlGroup::belongsToTextControlGroup),
+ new Token("tel-extension", ControlGroup::belongsToTextControlGroup),
+ new Token("email", ControlGroup::belongsToUsernameControlGroup),
+ new Token("impp", ControlGroup::belongsToUrlControlGroup)
+ ).anyMatch(candidate::satisfies);
+ }
+
+ static boolean isASection(Candidate candidate) {
+ return candidate.value.toLowerCase().startsWith("section-");
+ }
+
+ static boolean isAnAddressType(Candidate candidate) {
+ return Stream.of(
+ "billing",
+ "shipping"
+ ).anyMatch(item -> item.equalsIgnoreCase(candidate.value));
+ }
+
+ static boolean isAnAutofillFieldNameToken(Candidate candidate) {
+ return Stream.of(
+ new Token("name", ControlGroup::belongsToTextControlGroup),
+ new Token("honorific-prefix", ControlGroup::belongsToTextControlGroup),
+ new Token("given-name", ControlGroup::belongsToTextControlGroup),
+ new Token("additional-name", ControlGroup::belongsToTextControlGroup),
+ new Token("family-name", ControlGroup::belongsToTextControlGroup),
+ new Token("honorific-suffix", ControlGroup::belongsToTextControlGroup),
+ new Token("nickname", ControlGroup::belongsToTextControlGroup),
+ new Token("username", ControlGroup::belongsToUsernameControlGroup),
+ new Token("new-password", ControlGroup::belongsToPasswordControlGroup),
+ new Token("current-password", ControlGroup::belongsToPasswordControlGroup),
+ new Token("one-time-code", ControlGroup::belongsToPasswordControlGroup),
+ new Token("organization-title", ControlGroup::belongsToTextControlGroup),
+ new Token("organization", ControlGroup::belongsToTextControlGroup),
+ new Token("street-address", ControlGroup::belongsToMultilineControlGroup),
+ new Token("address-line1", ControlGroup::belongsToTextControlGroup),
+ new Token("address-line2", ControlGroup::belongsToTextControlGroup),
+ new Token("address-line3", ControlGroup::belongsToTextControlGroup),
+ new Token("address-level4", ControlGroup::belongsToTextControlGroup),
+ new Token("address-level3", ControlGroup::belongsToTextControlGroup),
+ new Token("address-level2", ControlGroup::belongsToTextControlGroup),
+ new Token("address-level1", ControlGroup::belongsToTextControlGroup),
+ new Token("country", ControlGroup::belongsToTextControlGroup),
+ new Token("country-name", ControlGroup::belongsToTextControlGroup),
+ new Token("postal-code", ControlGroup::belongsToTextControlGroup),
+ new Token("cc-name", ControlGroup::belongsToTextControlGroup),
+ new Token("cc-given-name", ControlGroup::belongsToTextControlGroup),
+ new Token("cc-additional-name", ControlGroup::belongsToTextControlGroup),
+ new Token("cc-family-name", ControlGroup::belongsToTextControlGroup),
+ new Token("cc-number", ControlGroup::belongsToTextControlGroup),
+ new Token("cc-exp", ControlGroup::belongsToMonthControlGroup),
+ new Token("cc-exp-month", ControlGroup::belongsToNumericControlGroup),
+ new Token("cc-exp-year", ControlGroup::belongsToMultilineControlGroup),
+ new Token("cc-csc", ControlGroup::belongsToTextControlGroup),
+ new Token("cc-type", ControlGroup::belongsToTextControlGroup),
+ new Token("transaction-currency", ControlGroup::belongsToTextControlGroup),
+ new Token("transaction-amount", ControlGroup::belongsToMultilineControlGroup),
+ new Token("language", ControlGroup::belongsToTextControlGroup),
+ new Token("bday", ControlGroup::belongsToDateControlGroup),
+ new Token("bday-day", ControlGroup::belongsToMultilineControlGroup),
+ new Token("bday-month", ControlGroup::belongsToMultilineControlGroup),
+ new Token("bday-year", ControlGroup::belongsToMultilineControlGroup),
+ new Token("sex", ControlGroup::belongsToTextControlGroup),
+ new Token("url", ControlGroup::belongsToUrlControlGroup),
+ new Token("photo", ControlGroup::belongsToUrlControlGroup)
+ ).anyMatch(candidate::satisfies);
+ }
+
+ static boolean isAWebAuthnToken(Candidate candidate) {
+ return Stream.of(
+ new Token("webauthn", node -> node.getNodeName().equalsIgnoreCase("input") || node.getNodeName().equalsIgnoreCase("textarea"))
+ ).anyMatch(candidate::satisfies);
+ }
+
+ @Override
+ public void startElement(TagNode node) {
+ var isValid = validators.stream().anyMatch(validator -> validator.isValid(node));
+
+ if (!isValid) {
+ createViolation(node, "DOM elements should use the \"autocomplete\" attribute correctly.");
+ }
+ }
+}
diff --git a/sonar-html-plugin/src/main/java/org/sonar/plugins/html/rules/CheckClasses.java b/sonar-html-plugin/src/main/java/org/sonar/plugins/html/rules/CheckClasses.java
index 2feb0a283..c8bf6d3e1 100644
--- a/sonar-html-plugin/src/main/java/org/sonar/plugins/html/rules/CheckClasses.java
+++ b/sonar-html-plugin/src/main/java/org/sonar/plugins/html/rules/CheckClasses.java
@@ -21,6 +21,7 @@
import org.sonar.plugins.html.checks.accessibility.AnchorsHaveContentCheck;
import org.sonar.plugins.html.checks.accessibility.AriaProptypesCheck;
+import org.sonar.plugins.html.checks.accessibility.ValidAutocompleteCheck;
import org.sonar.plugins.html.checks.accessibility.ImgRedundantAltCheck;
import org.sonar.plugins.html.checks.accessibility.LabelHasAssociatedControlCheck;
import org.sonar.plugins.html.checks.accessibility.NoNoninteractiveElementToInteractiveRoleCheck;
@@ -104,6 +105,7 @@ public final class CheckClasses {
ChildElementRequiredCheck.class,
ComplexityCheck.class,
DeprecatedAttributesInHtml5Check.class,
+ ValidAutocompleteCheck.class,
DoubleQuotesCheck.class,
DynamicJspIncludeCheck.class,
FileLengthCheck.class,
diff --git a/sonar-html-plugin/src/main/resources/org/sonar/l10n/web/rules/Web/S6840.html b/sonar-html-plugin/src/main/resources/org/sonar/l10n/web/rules/Web/S6840.html
new file mode 100644
index 000000000..2d17750c4
--- /dev/null
+++ b/sonar-html-plugin/src/main/resources/org/sonar/l10n/web/rules/Web/S6840.html
@@ -0,0 +1,41 @@
+
Why is this an issue?
+
Not providing autocomplete values in form fields can lead to content inaccessibility. The function of each standard input field, which gathers a
+person’s personal data, is systematically determined according to the list of 53 Input Purposes
+for User Interface Components. If the necessary autocomplete attribute values are absent, screen readers will not be able to identify and read
+these fields. This lack of information can hinder users, particularly those using screen readers, from properly navigating and interacting with
+forms.
+
For screen readers to operate effectively, it is imperative that the autocomplete attribute values are not only valid but also correctly
+applied.
+
How to fix it
+
Ensure the autocomplete attribute is correct and suitable for the form field it is used with:
+
+
Identify the input type: The autocomplete attribute should be used with form elements like <input>,
+ <select>, and <textarea>. The type of input field should be clearly identified using the type
+ attribute, such as type="text", type="email", or type="tel".
+
Specify the autocomplete value: The value of the autocomplete attribute should be a string that specifies what kind of input the browser should
+ autofill. For example, autocomplete="name" would suggest that the browser autofill the user’s full name.
+
Use appropriate autocomplete values: The value you use should be appropriate for the type of input. For example, for a credit card field, you
+ might use autocomplete="cc-number". For a country field in an address form, you might use autocomplete="country".
+
+
For additional details, please refer to the guidelines provided in the HTML standard.