pour-engine 1.6.0 → 1.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config/project.config.js +1 -1
- package/engine/rules/best-practice/aria-label-misuse.js +14 -0
- package/engine/rules/best-practice/fieldset-legend.js +15 -4
- package/engine/rules/best-practice/redundant-aria-label.js +12 -0
- package/engine/rules/wcag/1.3.1-aria-required-children.js +27 -21
- package/engine/rules/wcag/1.3.1-aria-required-parent.js +10 -0
- package/engine/rules/wcag/1.3.1-list-structure.js +9 -2
- package/engine/rules/wcag/1.3.1-listitem-parent.js +8 -4
- package/engine/rules/wcag/1.4.3-control-contrast.js +28 -7
- package/engine/rules/wcag/2.4.1-bypass-blocks.js +29 -6
- package/engine/rules/wcag/3.3.8-auth-field-obstruction.js +25 -10
- package/package.json +1 -1
package/config/project.config.js
CHANGED
|
@@ -5,6 +5,14 @@
|
|
|
5
5
|
// component", so no success criterion (4.1.2 included) actually fails.
|
|
6
6
|
// Best-practice scope is the honest home; demoted from WCAG scope after an
|
|
7
7
|
// independent audit found it dominating WCAG results on news/commerce pages.
|
|
8
|
+
/** Roles whose accessible name is built from their contents, so a label on
|
|
9
|
+
* anything inside them contributes to that name rather than being dropped. */
|
|
10
|
+
const NAME_FROM_CONTENT = 'a[href], button, summary, h1, h2, h3, h4, h5, h6, th, td,'
|
|
11
|
+
+ ' [role="button"], [role="link"], [role="menuitem"], [role="menuitemcheckbox"],'
|
|
12
|
+
+ ' [role="menuitemradio"], [role="option"], [role="tab"], [role="treeitem"],'
|
|
13
|
+
+ ' [role="checkbox"], [role="radio"], [role="switch"], [role="heading"],'
|
|
14
|
+
+ ' [role="cell"], [role="gridcell"], [role="columnheader"], [role="rowheader"], [role="tooltip"]';
|
|
15
|
+
|
|
8
16
|
export default {
|
|
9
17
|
id: 'aria-label-misuse',
|
|
10
18
|
impact: 'moderate',
|
|
@@ -13,6 +21,12 @@ export default {
|
|
|
13
21
|
helpUrl: 'https://www.w3.org/TR/using-aria/#practical-support-aria-label-aria-labelledby-and-aria-describedby',
|
|
14
22
|
selector: 'div[aria-label]:not([role]):not([tabindex]), span[aria-label]:not([role]):not([tabindex]), p[aria-label]:not([role]):not([tabindex])',
|
|
15
23
|
evaluate(element) {
|
|
24
|
+
// Inside a name-from-content ancestor the label is NOT ignored: accname
|
|
25
|
+
// applies the whole algorithm to each descendant while building the
|
|
26
|
+
// ancestor's name, so this label becomes the button's or link's name.
|
|
27
|
+
// Chromium reports exactly that. Claiming it does nothing is false, and
|
|
28
|
+
// the advised fix would delete the control's only name.
|
|
29
|
+
if (element.closest(NAME_FROM_CONTENT)) return { status: 'pass' };
|
|
16
30
|
return {
|
|
17
31
|
status: 'fail',
|
|
18
32
|
message: `aria-label="${element.getAttribute('aria-label')}" on a plain <${element.tagName.toLowerCase()}> does nothing — screen readers only announce labels on interactive elements or landmarks.`,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// WAI forms tutorial: radio/checkbox groups need a group label — each
|
|
2
2
|
// input's own label isn't enough to convey what the group asks.
|
|
3
|
+
import { labelledByName } from '../../lib/accessible-name.js';
|
|
3
4
|
export default {
|
|
4
5
|
id: 'fieldset-legend',
|
|
5
6
|
impact: 'moderate',
|
|
@@ -19,10 +20,20 @@ export default {
|
|
|
19
20
|
for (const indexes of Object.values(groups)) {
|
|
20
21
|
if (indexes.length < 2) continue;
|
|
21
22
|
const first = elements[indexes[0]];
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
// A <fieldset> IS a group; it does not need role="group" written on it
|
|
24
|
+
// to be one, and a legend is one way to name it rather than the only
|
|
25
|
+
// way. Requiring an explicit role attribute reported a correctly named
|
|
26
|
+
// group as unlabelled, which the browser plainly contradicts.
|
|
27
|
+
// Name the GROUP the three ways a group can be named: its own legend,
|
|
28
|
+
// a reference, or a label. Not a general accessible-name call, which
|
|
29
|
+
// would fall through to name-from-contents and read the options inside
|
|
30
|
+
// the fieldset as if they were its name.
|
|
31
|
+
const group = first.closest('fieldset, [role="group"], [role="radiogroup"]');
|
|
32
|
+
const grouped = group && (
|
|
33
|
+
group.querySelector(':scope > legend')?.textContent.trim()
|
|
34
|
+
|| labelledByName(group)
|
|
35
|
+
|| group.getAttribute('aria-label')?.trim()
|
|
36
|
+
);
|
|
26
37
|
if (grouped) continue;
|
|
27
38
|
outcomes[indexes[0]] = {
|
|
28
39
|
status: 'fail',
|
|
@@ -1,3 +1,14 @@
|
|
|
1
|
+
import { effectiveRole } from '../../lib/roles.js';
|
|
2
|
+
|
|
3
|
+
// "Redundant" only means anything for a role that takes its name FROM its
|
|
4
|
+
// contents. A landmark is never named by the text inside it, and neither is
|
|
5
|
+
// a select (its text is the option list), so an aria-label that happens to
|
|
6
|
+
// match that text is the element's ONLY name. Removing it, as this rule
|
|
7
|
+
// advises, leaves the element anonymous.
|
|
8
|
+
const NAME_FROM_CONTENT = new Set(['button', 'link', 'menuitem', 'menuitemcheckbox',
|
|
9
|
+
'menuitemradio', 'option', 'tab', 'treeitem', 'checkbox', 'radio', 'switch', 'heading',
|
|
10
|
+
'cell', 'gridcell', 'columnheader', 'rowheader', 'tooltip']);
|
|
11
|
+
|
|
1
12
|
export default {
|
|
2
13
|
id: 'redundant-aria-label',
|
|
3
14
|
impact: 'minor',
|
|
@@ -6,6 +17,7 @@ export default {
|
|
|
6
17
|
helpUrl: 'https://www.w3.org/TR/using-aria/#rule2',
|
|
7
18
|
selector: '[aria-label]',
|
|
8
19
|
evaluate(element) {
|
|
20
|
+
if (!NAME_FROM_CONTENT.has(effectiveRole(element))) return { status: 'pass' };
|
|
9
21
|
const label = element.getAttribute('aria-label').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
10
22
|
const visible = element.textContent.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
11
23
|
if (!visible || label !== visible) return { status: 'pass' };
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// WCAG SC 1.3.1 Info and Relationships (Level A)
|
|
2
2
|
// Composite roles are meaningless without their required child roles —
|
|
3
3
|
// role="list" with no listitems announces as an empty list.
|
|
4
|
+
import { implicitRole } from '../../lib/roles.js';
|
|
4
5
|
const REQUIRED_CHILDREN = {
|
|
5
6
|
list: ['listitem'],
|
|
6
7
|
listbox: ['option', 'group'],
|
|
@@ -15,30 +16,33 @@ const REQUIRED_CHILDREN = {
|
|
|
15
16
|
rowgroup: ['row'],
|
|
16
17
|
};
|
|
17
18
|
|
|
18
|
-
const IMPLICIT = { li: 'listitem', option: 'option', tr: 'row', tbody: 'rowgroup', thead: 'rowgroup', tfoot: 'rowgroup' };
|
|
19
|
-
|
|
20
19
|
const roleOf = (element) =>
|
|
21
20
|
element.getAttribute('role')?.trim().split(/\s+/)[0]?.toLowerCase() ??
|
|
22
|
-
|
|
21
|
+
implicitRole(element);
|
|
23
22
|
|
|
24
23
|
/**
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
24
|
+
* Everything the container could plausibly own: its whole subtree, plus any
|
|
25
|
+
* element it claims by aria-owns.
|
|
26
|
+
*
|
|
27
|
+
* This deliberately does not model ownership precisely. The rule's job is to
|
|
28
|
+
* catch a container that holds NONE of the children its role needs, which is
|
|
29
|
+
* what makes a screen reader announce an empty list. Enumerating which wrapper
|
|
30
|
+
* tags ownership passes through was the wrong shape for that: the allow-list
|
|
31
|
+
* held div and span only, so the ordinary tabs-in-list-items pattern was
|
|
32
|
+
* reported as a broken tablist. A required role anywhere inside means the
|
|
33
|
+
* container is not empty, and that is the only claim this rule should make.
|
|
30
34
|
*/
|
|
31
|
-
function
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
const
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
35
|
+
function candidateDescendants(element) {
|
|
36
|
+
const found = [...element.querySelectorAll('*')].filter((el) => !el.matches('script, style, template'));
|
|
37
|
+
const owns = element.getAttribute('aria-owns');
|
|
38
|
+
if (owns) {
|
|
39
|
+
const root = element.getRootNode();
|
|
40
|
+
for (const id of owns.split(/\s+/).filter(Boolean)) {
|
|
41
|
+
const target = root.getElementById?.(id);
|
|
42
|
+
if (target) found.push(target, ...target.querySelectorAll('*'));
|
|
43
|
+
}
|
|
40
44
|
}
|
|
41
|
-
return
|
|
45
|
+
return found;
|
|
42
46
|
}
|
|
43
47
|
|
|
44
48
|
export default {
|
|
@@ -55,11 +59,13 @@ export default {
|
|
|
55
59
|
const children = [...element.children].filter((c) => !c.matches('script, style, template'));
|
|
56
60
|
// A completely empty container is a lazy-load placeholder more often
|
|
57
61
|
// than a defect — it announces as an empty list, which is accurate.
|
|
58
|
-
if (!children.length) return { status: 'pass' };
|
|
59
|
-
if (
|
|
62
|
+
if (!children.length && !element.hasAttribute('aria-owns')) return { status: 'pass' };
|
|
63
|
+
if (candidateDescendants(element).some((child) => required.includes(roleOf(child)))) {
|
|
64
|
+
return { status: 'pass' };
|
|
65
|
+
}
|
|
60
66
|
return {
|
|
61
67
|
status: 'fail',
|
|
62
|
-
message: `role="${role}" contains no ${required.join('/')}
|
|
68
|
+
message: `role="${role}" contains no ${required.join('/')} anywhere inside it, and claims none by aria-owns — screen readers announce a broken, empty ${role}.`,
|
|
63
69
|
fix: `Give the item elements role="${required[0]}", or remove role="${role}" from the container.`,
|
|
64
70
|
};
|
|
65
71
|
},
|
|
@@ -28,6 +28,16 @@ export default {
|
|
|
28
28
|
evaluate(element) {
|
|
29
29
|
const role = element.getAttribute('role').trim().split(/\s+/)[0].toLowerCase();
|
|
30
30
|
const containers = REQUIRED_PARENT[role];
|
|
31
|
+
// aria-owns makes the relationship without containment: the owner can sit
|
|
32
|
+
// anywhere in the document and the browser still builds the structure.
|
|
33
|
+
// Judging by DOM ancestry alone failed every remotely-owned child.
|
|
34
|
+
if (element.id) {
|
|
35
|
+
const owner = element.getRootNode().querySelector?.(`[aria-owns~="${CSS.escape(element.id)}"]`);
|
|
36
|
+
const ownerRole = owner && (
|
|
37
|
+
owner.getAttribute('role')?.trim().split(/\s+/)[0]?.toLowerCase()
|
|
38
|
+
?? IMPLICIT_CONTAINER[owner.tagName.toLowerCase()]);
|
|
39
|
+
if (ownerRole && containers.includes(ownerRole)) return { status: 'pass' };
|
|
40
|
+
}
|
|
31
41
|
for (let parent = element.parentElement; parent; parent = parent.parentElement) {
|
|
32
42
|
const parentRole =
|
|
33
43
|
parent.getAttribute('role')?.trim().split(/\s+/)[0]?.toLowerCase() ??
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
// WCAG SC 1.3.1 Info and Relationships (Level A)
|
|
2
|
-
|
|
2
|
+
// Nothing here reaches the accessibility tree, so none of it can corrupt a
|
|
3
|
+
// list's structure. STYLE belongs with them: CSS-in-JS libraries inject
|
|
4
|
+
// <style> right where the markup sits, and it was being counted as a stray
|
|
5
|
+
// child that broke the list.
|
|
6
|
+
const NEVER_RENDERED = new Set(['SCRIPT', 'TEMPLATE', 'STYLE', 'LINK', 'META']);
|
|
3
7
|
const ITEM_ROLES = ['listitem', 'presentation', 'none'];
|
|
4
8
|
|
|
5
9
|
export default {
|
|
@@ -9,7 +13,7 @@ export default {
|
|
|
9
13
|
help: 'Lists must only contain list items',
|
|
10
14
|
helpUrl: 'https://www.w3.org/WAI/WCAG22/Understanding/info-and-relationships.html',
|
|
11
15
|
selector: 'ul, ol',
|
|
12
|
-
evaluate(element) {
|
|
16
|
+
evaluate(element, { isRendered }) {
|
|
13
17
|
// A role attribute replaces the HTML list semantics — a <ul role="tablist">
|
|
14
18
|
// is judged by ARIA structure rules, not HTML list rules.
|
|
15
19
|
if (element.hasAttribute('role') && element.getAttribute('role') !== 'list') return { status: 'pass' };
|
|
@@ -29,6 +33,9 @@ export default {
|
|
|
29
33
|
// genuinely corrupts the list.
|
|
30
34
|
const isValidChild = (child) => {
|
|
31
35
|
if (NEVER_RENDERED.has(child.tagName)) return true;
|
|
36
|
+
// A child the browser never renders is not in the accessibility tree
|
|
37
|
+
// either, so it cannot be corrupting the list a reader hears.
|
|
38
|
+
if (isRendered && !isRendered(child)) return true;
|
|
32
39
|
// An explicit role REPLACES an <li>'s listitem semantics: a carousel
|
|
33
40
|
// built as <ul> with <li role="group"> slides no longer owns any
|
|
34
41
|
// listitems, so the list announces broken/empty. Only a role-less
|
|
@@ -15,15 +15,19 @@ export default {
|
|
|
15
15
|
// List ownership passes through presentation/none wrappers and
|
|
16
16
|
// role-less div/span grouping wrappers (generic nodes) — an <li> in a
|
|
17
17
|
// framework's wrapping div inside a <ul> is still that list's item.
|
|
18
|
-
|
|
18
|
+
// A list CONTAINER stops the walk even when it is itself presentational:
|
|
19
|
+
// ARIA propagates an explicit presentation role to a role's required
|
|
20
|
+
// owned elements, so the <li>s in a <ul role="none"> are presentational
|
|
21
|
+
// too. There is no listitem left to be orphaned, and stepping over the
|
|
22
|
+
// <ul> used to blame whatever happened to be above it.
|
|
23
|
+
const listContainer = (el) => el.matches('ul, ol, menu') || el.getAttribute('role') === 'list';
|
|
24
|
+
while (parent && !listContainer(parent) && (
|
|
19
25
|
['presentation', 'none'].includes(parent.getAttribute('role') ?? '')
|
|
20
26
|
|| (!parent.hasAttribute('role') && (parent.tagName === 'DIV' || parent.tagName === 'SPAN'))
|
|
21
27
|
)) {
|
|
22
28
|
parent = parent.assignedSlot?.parentElement ?? parent.parentElement ?? parent.getRootNode()?.host;
|
|
23
29
|
}
|
|
24
|
-
if (parent && (parent
|
|
25
|
-
return { status: 'pass' };
|
|
26
|
-
}
|
|
30
|
+
if (parent && listContainer(parent)) return { status: 'pass' };
|
|
27
31
|
return {
|
|
28
32
|
status: 'fail',
|
|
29
33
|
message: `This <li> sits inside <${parent?.tagName.toLowerCase() ?? 'nothing'}> — outside a list, screen readers lose the item's list context entirely.`,
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
// endemic ~2.8:1 gray-on-white) was previously never measured by any rule.
|
|
5
5
|
import {
|
|
6
6
|
parseColor, contrastRatio, composite, effectiveBackground, isLargeText,
|
|
7
|
+
backgroundImageSource, cumulativeOpacity, opacityAnimating, restingOpacity,
|
|
7
8
|
} from '../../lib/contrast.js';
|
|
8
9
|
|
|
9
10
|
const showRatio = (ratio) => (Math.floor(ratio * 100) / 100).toFixed(2);
|
|
@@ -28,14 +29,31 @@ export default {
|
|
|
28
29
|
const rect = element.getBoundingClientRect();
|
|
29
30
|
if (rect.width <= 1 || rect.height <= 1) return { status: 'pass' };
|
|
30
31
|
const style = getComputedStyle(element);
|
|
32
|
+
// Judge the opacity the text RESTS at, matching the main contrast rule:
|
|
33
|
+
// a control faded out by an ancestor (fade-in-on-scroll, a collapsed
|
|
34
|
+
// panel) presents no text at all, and one held at a low opacity presents
|
|
35
|
+
// far less contrast than its declared colours suggest.
|
|
36
|
+
const opacity = opacityAnimating(element) ? restingOpacity(element) : cumulativeOpacity(element);
|
|
37
|
+
if (opacity < 0.05) return { status: 'pass' };
|
|
31
38
|
// The control's own background wins; else whatever shows through it.
|
|
32
39
|
const own = parseColor(style.backgroundColor);
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
40
|
+
let background;
|
|
41
|
+
if (own && own.a >= 1) {
|
|
42
|
+
background = own;
|
|
43
|
+
} else {
|
|
44
|
+
// An image or gradient painted under a see-through control is a
|
|
45
|
+
// backdrop of pixels this rule cannot sample. The main rule samples
|
|
46
|
+
// them; here the honest answer is to hand it to a person rather than
|
|
47
|
+
// judge against the colour layer alone as if the image were not there.
|
|
48
|
+
if (backgroundImageSource(element)) {
|
|
49
|
+
return {
|
|
50
|
+
status: 'incomplete',
|
|
51
|
+
message: 'This field is see-through and sits over a background image or gradient, so its real text contrast depends on the pixels behind it. Check it by eye.',
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
const behind = effectiveBackground(element);
|
|
55
|
+
background = behind && own && own.a > 0 ? composite(own, behind) : behind;
|
|
56
|
+
}
|
|
39
57
|
if (!background) {
|
|
40
58
|
return { status: 'incomplete', message: 'The control’s background could not be determined — check its text contrast by eye.' };
|
|
41
59
|
}
|
|
@@ -44,7 +62,10 @@ export default {
|
|
|
44
62
|
const judge = (color, what) => {
|
|
45
63
|
const parsed = parseColor(color);
|
|
46
64
|
if (!parsed || parsed.a === 0) return null;
|
|
47
|
-
|
|
65
|
+
// Same treatment the main rule gives faded text: what reaches the eye
|
|
66
|
+
// is the declared colour thinned by the opacity it is painted at.
|
|
67
|
+
const faded = opacity < 1 ? { ...parsed, a: parsed.a * opacity } : parsed;
|
|
68
|
+
const fg = faded.a < 1 ? composite(faded, background) : faded;
|
|
48
69
|
const ratio = contrastRatio(fg, background);
|
|
49
70
|
if (ratio >= required) return null;
|
|
50
71
|
return { what, ratio };
|
|
@@ -18,18 +18,41 @@ export default {
|
|
|
18
18
|
const doc = element.ownerDocument;
|
|
19
19
|
const roots = collectRoots(doc);
|
|
20
20
|
const inAnyRoot = (selector) => roots.some((root) => root.querySelector?.(selector));
|
|
21
|
-
|
|
21
|
+
// ARIA11 accepts landmarks generally, not the main landmark alone: a
|
|
22
|
+
// banner and a navigation region are exactly what landmark navigation
|
|
23
|
+
// skips between. Requiring `main` failed pages that were already using
|
|
24
|
+
// the technique the spec names.
|
|
25
|
+
const LANDMARKS = 'main, [role="main"], header, [role="banner"], nav, [role="navigation"],'
|
|
26
|
+
+ ' aside, [role="complementary"], footer, [role="contentinfo"], [role="search"], form[role="search"]';
|
|
27
|
+
if (inAnyRoot(LANDMARKS)) return { status: 'pass' };
|
|
22
28
|
if (inAnyRoot('h1, h2, h3, h4, h5, h6, [role="heading"]')) return { status: 'pass' };
|
|
29
|
+
// G1/G123/G124: ANY in-page link that resolves is a bypass mechanism.
|
|
30
|
+
// Reading only the first one hid a real skip link behind the href="#"
|
|
31
|
+
// that scripts use as a button.
|
|
23
32
|
for (const root of roots) {
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
33
|
+
for (const link of root.querySelectorAll?.('a[href^="#"]') ?? []) {
|
|
34
|
+
const id = link.getAttribute('href').slice(1);
|
|
35
|
+
if (!id) continue;
|
|
36
|
+
if (roots.some((r) => r.getElementById?.(id) || r.querySelector?.(`[id="${CSS.escape(id)}"]`))) {
|
|
37
|
+
return { status: 'pass' };
|
|
38
|
+
}
|
|
28
39
|
}
|
|
29
40
|
}
|
|
41
|
+
// H64: a titled iframe is itself a way past the block it holds.
|
|
42
|
+
const frames = [...(doc.querySelectorAll('iframe, frame') ?? [])];
|
|
43
|
+
if (frames.length && frames.every((frame) => frame.getAttribute('title')?.trim())) {
|
|
44
|
+
return { status: 'pass' };
|
|
45
|
+
}
|
|
46
|
+
// The criterion governs "blocks of content that are repeated on multiple
|
|
47
|
+
// Web pages". One page cannot prove repetition, but it can show there is
|
|
48
|
+
// no block to bypass at all: a page with barely any links has nothing a
|
|
49
|
+
// reader would need to skip, and failing it names a duty that never
|
|
50
|
+
// applied.
|
|
51
|
+
const linkCount = roots.reduce((n, root) => n + (root.querySelectorAll?.('a[href], button').length ?? 0), 0);
|
|
52
|
+
if (linkCount < 4) return { status: 'pass' };
|
|
30
53
|
return {
|
|
31
54
|
status: 'fail',
|
|
32
|
-
message:
|
|
55
|
+
message: `This page has ${linkCount} links and buttons but no landmark, heading, skip link, or titled frame — keyboard and screen-reader users must go through the whole header and nav to reach anything.`,
|
|
33
56
|
fix: 'Add a skip link like <a href="#content">Skip to content</a>, wrap primary content in <main>, or structure the page with headings.',
|
|
34
57
|
};
|
|
35
58
|
},
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
// WCAG SC 3.3.8 Accessible Authentication (Minimum) (Level AA)
|
|
2
|
-
// The SC
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
//
|
|
2
|
+
// The SC permits a cognitive function test when the step offers an
|
|
3
|
+
// Alternative, a Mechanism, Object Recognition, or Personal Content. Note 2
|
|
4
|
+
// names TWO example mechanisms, separately: password-manager support, and
|
|
5
|
+
// copy and paste.
|
|
6
|
+
//
|
|
7
|
+
// That separation is what this rule turns on. Blocking paste removes one of
|
|
8
|
+
// the two, but a password manager fills a field programmatically and never
|
|
9
|
+
// touches the clipboard, so the other mechanism usually survives — and a
|
|
10
|
+
// page may also offer an Alternative (a passkey, a federated sign-in, an
|
|
11
|
+
// emailed link) that this rule cannot see. Blocked paste is therefore
|
|
12
|
+
// reported for REVIEW, not failed. Both mechanisms obstructed at once is the
|
|
13
|
+
// case worth asserting.
|
|
8
14
|
export default {
|
|
9
15
|
id: 'auth-field-obstruction',
|
|
10
16
|
impact: 'serious',
|
|
@@ -19,14 +25,23 @@ export default {
|
|
|
19
25
|
const handler = element.getAttribute(attr) ?? '';
|
|
20
26
|
return /return\s+false|preventDefault/.test(handler);
|
|
21
27
|
});
|
|
22
|
-
|
|
28
|
+
const autocompleteOff = (element.getAttribute('autocomplete') ?? '').trim().toLowerCase() === 'off';
|
|
29
|
+
const action = blocking === 'onpaste' ? 'pasting' : 'dropping';
|
|
30
|
+
if (blocking && autocompleteOff) {
|
|
23
31
|
return {
|
|
24
32
|
status: 'fail',
|
|
25
|
-
message: `This authentication field blocks ${
|
|
26
|
-
fix: 'Remove the paste/drop blocking
|
|
33
|
+
message: `This authentication field blocks ${action} AND sets autocomplete="off", so both of the mechanisms 3.3.8 names are obstructed at once: copy and paste, and password-manager entry. What is left is typing the credential from memory.`,
|
|
34
|
+
fix: 'Remove the paste/drop blocking, and drop autocomplete="off" so a password manager can fill the field.',
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
if (blocking) {
|
|
38
|
+
return {
|
|
39
|
+
status: 'incomplete',
|
|
40
|
+
message: `This authentication field blocks ${action}, which removes copy and paste, one of the two mechanisms 3.3.8 names. A password manager fills the field directly and is unaffected, so this is only a failure if nothing else here helps. Check whether a password manager can complete this login, or whether the page offers another way in such as a passkey or a federated sign-in.`,
|
|
41
|
+
fix: `Remove the ${action === 'pasting' ? 'paste' : 'drop'} blocking, or make sure another way to sign in is available that does not rely on recalling the credential.`,
|
|
27
42
|
};
|
|
28
43
|
}
|
|
29
|
-
if (
|
|
44
|
+
if (autocompleteOff) {
|
|
30
45
|
return {
|
|
31
46
|
status: 'incomplete',
|
|
32
47
|
message: 'This authentication field sets autocomplete="off", discouraging password managers. Browsers often fill anyway — verify a password manager can complete this login without retyping.',
|
package/package.json
CHANGED