Skip to content

Commit cc635bb

Browse files
voidstackloopclaude
andcommitted
Add a ninth check: duplicate field names
Design (per the /engineering:system-design request): a form where two or more controls share a name attribute is a common, silent bug — copy-pasted markup, a duplicated "add another" field group, a templated component. Standard form encoding keeps only one value (or silently merges them in a way the server likely doesn't expect) for a repeated key, so a user filling in two different pieces of information has one vanish on submit with zero client-side signal. Axe-core has no concept of submission semantics, so it doesn't (and can't) catch this, and none of the other eight checks do either. check_duplicate_names groups every named input/select/textarea in the target form by name and flags any group of 2+ where the elements aren't all radios (mutual exclusion is the whole point of sharing a name there) or all checkboxes (a checkbox group commonly and legitimately shares one name too). Anything else sharing a name — two text fields, a text field colliding with a checkbox, etc. — is a real defect in the form itself, so this is a Fail, not a heuristic-limit Warn like Bot protection. fixtures/duplicate-name-form.html covers both the violation (two unrelated fields both named "email") and the two legitimate exclusions (a radio group and a checkbox group sharing a name each) in one fixture, so the same test proves the check flags the real bug without false-positiving on either legitimate pattern. Verified both directions by mutation: removed the radio/checkbox exclusion entirely and confirmed the false-positive assertions caught it (flagging "contact" and "interests" alongside "email"), then forced the JS to always return an empty dupes list and confirmed the Fail assertion caught that too, before restoring the real logic. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent c231138 commit cc635bb

5 files changed

Lines changed: 155 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,15 @@ project doesn't have a release yet, so everything below is grouped under
2424

2525
### Added
2626

27+
- A ninth check, **Duplicate field names**: flags form controls that
28+
share a `name` attribute with something other than a legitimate
29+
radio/checkbox group. Standard form encoding keeps only one value (or
30+
silently merges them) for a repeated key, so two different pieces of
31+
information submitted under one name means one vanishes with no
32+
client-side signal at all — not an accessibility issue (axe-core has
33+
no concept of submission semantics) and not covered by any other
34+
check. Always a `Fail`: unlike Bot protection, this is a real defect
35+
in the form itself.
2736
- **Optional LLM semantic checks** (`--llm` / `llm:` config): two
2837
provider-agnostic checks — "Error wording (LLM)" and
2938
"Instructions (LLM)" — that score the clarity of validation error

README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,12 @@ your own use.
8989
fine — but without this check, a page stuck behind Cloudflare's "Just a
9090
moment..." interstitial just looks like "no `<form>` found," a real
9191
finding with the wrong explanation.
92+
- **Duplicate field names** — flags form controls that share a `name`
93+
attribute with something other than a legitimate radio/checkbox group.
94+
Standard form encoding keeps only one value (or silently merges them)
95+
for a repeated key, so two different pieces of information submitted
96+
under one name means one vanishes with no client-side signal at all —
97+
not an accessibility issue, and not covered by any other check.
9298

9399
## Commands
94100

fixtures/duplicate-name-form.html

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head><meta charset="utf-8"><title>Duplicate Field Names</title></head>
4+
<body>
5+
<!-- Two unrelated text fields both named "email" — a realistic copy-
6+
paste mistake. On submit, standard form encoding keeps only one
7+
of the two values; the user has no way to know which one, or that
8+
anything was lost at all.
9+
10+
Also includes a legitimate radio group and a legitimate checkbox
11+
group sharing a name each — neither should be flagged, since that
12+
is how HTML radio/checkbox groups are meant to work. -->
13+
<form>
14+
<label for="personal-email">Personal email</label>
15+
<input id="personal-email" name="email" type="email">
16+
17+
<label for="work-email">Work email</label>
18+
<input id="work-email" name="email" type="email">
19+
20+
<fieldset>
21+
<legend>Preferred contact method</legend>
22+
<label><input type="radio" name="contact" value="email"> Email</label>
23+
<label><input type="radio" name="contact" value="phone"> Phone</label>
24+
</fieldset>
25+
26+
<fieldset>
27+
<legend>Interests</legend>
28+
<label><input type="checkbox" name="interests" value="parks"> Parks</label>
29+
<label><input type="checkbox" name="interests" value="roads"> Roads</label>
30+
</fieldset>
31+
32+
<button type="submit">Submit</button>
33+
</form>
34+
</body>
35+
</html>

src/checks.rs

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -918,6 +918,68 @@ pub async fn check_autofill_hints(page: &Page) -> Result<CheckResult> {
918918
))
919919
}
920920

921+
/// Flags form controls that share a `name` attribute with something they
922+
/// shouldn't. Standard form encoding keeps only one value (or silently
923+
/// merges them in a way the server likely doesn't expect) for a repeated
924+
/// key — so two *different* pieces of information submitted under one
925+
/// name means one of them vanishes with no client-side signal at all.
926+
/// Not an accessibility issue (axe-core has no concept of submission
927+
/// semantics) and not covered by any other check.
928+
///
929+
/// Radios sharing a `name` is how mutual exclusion works, and a checkbox
930+
/// group commonly and legitimately shares one too — both are excluded.
931+
/// Only a collision involving anything else (two text fields, a text
932+
/// field colliding with a checkbox, ...) is a real defect in the form
933+
/// itself, so this is a `Fail`, not a heuristic-limit `Warn`.
934+
pub async fn check_duplicate_names(page: &Page) -> Result<CheckResult> {
935+
let dupes: Vec<String> = page
936+
.evaluate(format!(
937+
r#"(() => {{
938+
const f = {TARGET_FORM_JS};
939+
const fields = f ? ({DEEP_QUERY_JS})(f, 'input[name], select[name], textarea[name]') : [];
940+
const groups = new Map();
941+
for (const el of fields) {{
942+
const name = el.getAttribute('name');
943+
if (!name) continue;
944+
const kind = (el.tagName === 'INPUT' ? el.type : el.tagName).toLowerCase();
945+
if (!groups.has(name)) groups.set(name, []);
946+
groups.get(name).push(kind);
947+
}}
948+
const dupes = [];
949+
for (const [name, kinds] of groups) {{
950+
if (kinds.length < 2) continue;
951+
const allRadio = kinds.every((k) => k === 'radio');
952+
const allCheckbox = kinds.every((k) => k === 'checkbox');
953+
if (!allRadio && !allCheckbox) dupes.push(name);
954+
}}
955+
return dupes;
956+
}})()"#
957+
))
958+
.await?
959+
.into_value()?;
960+
961+
let status = if dupes.is_empty() {
962+
Status::Pass
963+
} else {
964+
Status::Fail
965+
};
966+
Ok(result(
967+
"Duplicate field names",
968+
status,
969+
if dupes.is_empty() {
970+
"No form controls share a name attribute (outside legitimate radio/checkbox groups)."
971+
.to_string()
972+
} else {
973+
format!(
974+
"{} field name(s) shared by more than one non-radio/checkbox control — \
975+
submitting the form will silently drop at least one of these values: {}.",
976+
dupes.len(),
977+
dupes.join(", ")
978+
)
979+
},
980+
))
981+
}
982+
921983
/// Detects a bot-protection/CAPTCHA challenge (reCAPTCHA, hCaptcha,
922984
/// Cloudflare Turnstile, or a generic "verify you're human" interstitial)
923985
/// on the page. Never a Fail: none of this is evidence the *form* is
@@ -1154,6 +1216,14 @@ pub async fn run_all_with(page: &Page, opts: &RunOptions) -> Vec<CheckResult> {
11541216
check_bot_protection(page),
11551217
)
11561218
.await,
1219+
run_safely(
1220+
page,
1221+
"Duplicate field names",
1222+
check_timeout,
1223+
capture,
1224+
check_duplicate_names(page),
1225+
)
1226+
.await,
11571227
submission,
11581228
run_safely(
11591229
page,

tests/checks_test.rs

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -779,3 +779,38 @@ async fn llm_semantic_checks_run_against_a_page_with_the_mock_provider() {
779779
);
780780
}
781781
}
782+
783+
#[tokio::test]
784+
async fn duplicate_name_between_two_text_fields_is_a_fail() {
785+
// fixtures/duplicate-name-form.html has two unrelated text inputs
786+
// both named "email" — on submit, standard form encoding keeps only
787+
// one of the two values, with zero client-side signal that anything
788+
// was lost. The fixture also has a legitimate radio group and a
789+
// legitimate checkbox group sharing a name each; neither should be
790+
// flagged, since that's how those groups are meant to work.
791+
let (_browser, page, _handle) = open_fixture("duplicate-name-form.html").await;
792+
let result = checks::check_duplicate_names(&page)
793+
.await
794+
.expect("check_duplicate_names");
795+
assert_eq!(result.status, checks::Status::Fail, "got: {result:?}");
796+
assert!(result.detail.contains("email"), "got: {}", result.detail);
797+
assert!(
798+
!result.detail.contains("contact"),
799+
"a legitimate radio group must not be flagged, got: {}",
800+
result.detail
801+
);
802+
assert!(
803+
!result.detail.contains("interests"),
804+
"a legitimate checkbox group must not be flagged, got: {}",
805+
result.detail
806+
);
807+
}
808+
809+
#[tokio::test]
810+
async fn form_with_no_duplicate_names_passes() {
811+
let (_browser, page, _handle) = open_fixture("test-form.html").await;
812+
let result = checks::check_duplicate_names(&page)
813+
.await
814+
.expect("check_duplicate_names");
815+
assert_eq!(result.status, checks::Status::Pass, "got: {result:?}");
816+
}

0 commit comments

Comments
 (0)