use chromiumoxide::Page;
use crate::cli::WcagLevel;
use crate::wcag::types::{RuleMetadata, Severity, Violation};
pub(super) const ACCESSIBLE_AUTH_PASTE_RULE: RuleMetadata = RuleMetadata {
id: "3.3.8",
name: "Accessible Authentication (Minimum)",
level: WcagLevel::AA,
severity: Severity::High,
description: "Password and one-time-code fields allow pasting, so credentials don't have to be transcribed",
help_url: "https://www.w3.org/WAI/WCAG22/Understanding/accessible-authentication-minimum.html",
axe_id: "accessible-auth-paste-blocked",
tags: &["wcag22aa", "wcag338", "cat.forms"],
};
pub(super) const ACCESSIBLE_AUTH_CAPTCHA_RULE: RuleMetadata = RuleMetadata {
id: "3.3.8",
name: "Accessible Authentication (Minimum)",
level: WcagLevel::AA,
severity: Severity::Medium,
description: "A CAPTCHA in an authentication form offers an alternative that is not a cognitive function test",
help_url: "https://www.w3.org/WAI/WCAG22/Understanding/accessible-authentication-minimum.html",
axe_id: "accessible-auth-captcha",
tags: &["wcag22aa", "wcag338", "cat.forms"],
};
const MAX_FINDINGS: usize = 5;
const ACCESSIBLE_AUTH_JS: &str = r#"
(function() {
var PROBE = 'amsPasteProbe7!';
var AUTH_FIELD = 'input[type="password"], input[autocomplete~="one-time-code"], input[autocomplete~="current-password"]';
function isVisible(el) {
var r = el.getBoundingClientRect();
if (r.width === 0 && r.height === 0) return false;
var s = getComputedStyle(el);
return s.display !== 'none' && s.visibility !== 'hidden';
}
/*CSS_SELECTOR*/
function kindOf(el) {
var ac = (el.getAttribute('autocomplete') || '').toLowerCase();
return ac.indexOf('one-time-code') !== -1 ? 'one-time code' : 'password';
}
function pasteBlocked(el) {
var original = el.value;
var dt;
try {
dt = new DataTransfer();
dt.setData('text/plain', PROBE);
} catch (e) {
dt = null;
}
var ev;
try {
ev = new ClipboardEvent('paste', { bubbles: true, cancelable: true, clipboardData: dt });
} catch (e) {
ev = new Event('paste', { bubbles: true, cancelable: true });
}
var notCancelled = el.dispatchEvent(ev);
var inserted = (el.value || '').indexOf(PROBE) !== -1;
el.value = original;
return !notCancelled && !inserted;
}
var savedAlert = window.alert, savedConfirm = window.confirm, savedPrompt = window.prompt;
window.alert = function() {};
window.confirm = function() { return false; };
window.prompt = function() { return null; };
var blocked = [];
try {
var fields = document.querySelectorAll(AUTH_FIELD);
// Probe and report caps count real and excluded (#645) fields separately.
var probed = [];
for (var i = 0; i < fields.length && __amsReal(probed) < 20; i++) {
var f = fields[i];
if (f.disabled || f.readOnly || !isVisible(f)) continue;
if (!__amsPush(probed, f, f, 20)) continue;
if (pasteBlocked(f)) __amsPush(blocked, f, { selector: __amsCssSelector(f), kind: kindOf(f) }, /*MAX_FINDINGS*/);
}
} finally {
window.alert = savedAlert;
window.confirm = savedConfirm;
window.prompt = savedPrompt;
}
// Interactive CAPTCHA widgets inside authentication forms.
var CAPTCHA = [
'.g-recaptcha:not([data-size="invisible"])',
'iframe[src*="recaptcha/api2/anchor"]:not([src*="size=invisible"])',
'iframe[src*="recaptcha/enterprise/anchor"]:not([src*="size=invisible"])',
'.h-captcha:not([data-size="invisible"])',
'iframe[src*="hcaptcha.com"][src*="checkbox"]',
'img[src*="captcha" i]', 'img[alt*="captcha" i]', 'img[id*="captcha" i]', 'img[class*="captcha" i]'
].join(', ');
var captchas = [];
var forms = document.querySelectorAll('form');
for (var j = 0; j < forms.length; j++) {
var form = forms[j];
if (!form.querySelector(AUTH_FIELD)) continue;
var widget = form.querySelector(CAPTCHA);
if (!widget || !isVisible(widget)) continue;
__amsPush(captchas, form, {
form: __amsCssSelector(form),
widget: __amsCssSelector(widget)
}, /*MAX_FINDINGS*/);
}
return { blocked: blocked, captchas: captchas };
})()
"#;
pub async fn check_accessible_authentication_with_page(page: &Page) -> Vec<Violation> {
let val = match crate::wcag::types::evaluate_or_fail(
page,
&ACCESSIBLE_AUTH_PASTE_RULE,
&ACCESSIBLE_AUTH_JS
.replace(
"/*CSS_SELECTOR*/",
crate::accessibility::js_helpers::CSS_SELECTOR_JS,
)
.replace("/*MAX_FINDINGS*/", &MAX_FINDINGS.to_string()),
)
.await
{
Ok(v) => v,
Err(violations) => return violations,
};
let (Some(blocked), Some(captchas)) = (
val.get("blocked").and_then(|v| v.as_array()),
val.get("captchas").and_then(|v| v.as_array()),
) else {
return vec![crate::wcag::technical_rule_failure(
&ACCESSIBLE_AUTH_PASTE_RULE,
"invalid_evaluation_shape",
)];
};
let mut violations = Vec::new();
for field in blocked {
let (Some(selector), Some(kind)) = (
field.get("selector").and_then(|v| v.as_str()),
field.get("kind").and_then(|v| v.as_str()),
) else {
continue;
};
violations.push(
Violation::new(
ACCESSIBLE_AUTH_PASTE_RULE.id,
ACCESSIBLE_AUTH_PASTE_RULE.name,
ACCESSIBLE_AUTH_PASTE_RULE.level,
ACCESSIBLE_AUTH_PASTE_RULE.severity,
format!(
"The {kind} field '{selector}' blocks pasting, so the value must be typed from memory or transcribed instead of inserted by a password manager or the clipboard."
),
selector,
)
.with_selector(selector)
.with_fix(
"Remove the paste handler (or the `preventDefault()` / `return false` in it) \
from password and one-time-code fields so users can paste credentials or let a \
password manager fill them.",
)
.with_rule_id(ACCESSIBLE_AUTH_PASTE_RULE.axe_id)
.with_help_url(ACCESSIBLE_AUTH_PASTE_RULE.help_url),
);
}
for captcha in captchas {
let (Some(form), Some(widget)) = (
captcha.get("form").and_then(|v| v.as_str()),
captcha.get("widget").and_then(|v| v.as_str()),
) else {
continue;
};
violations.push(
Violation::new(
ACCESSIBLE_AUTH_CAPTCHA_RULE.id,
ACCESSIBLE_AUTH_CAPTCHA_RULE.name,
ACCESSIBLE_AUTH_CAPTCHA_RULE.level,
ACCESSIBLE_AUTH_CAPTCHA_RULE.severity,
format!(
"The authentication form '{form}' contains a CAPTCHA ('{widget}'). Verify that it is an object-recognition test or that an alternative without a cognitive function test is offered."
),
form,
)
.with_selector(form)
.with_fix(
"Offer a way to authenticate without solving a text or puzzle CAPTCHA — e.g. an \
object-recognition challenge, a passkey or e-mail link, or a non-interactive \
bot check.",
)
.with_rule_id(ACCESSIBLE_AUTH_CAPTCHA_RULE.axe_id)
.with_help_url(ACCESSIBLE_AUTH_CAPTCHA_RULE.help_url)
.as_warning(),
);
}
violations
}