Skip to content
Open
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
4 changes: 2 additions & 2 deletions packages/analysis/src/jsts/rules/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ If you have any questions, encounter any bugs, or have feature requests, please
| [confidential-information-logging](https://sonarsource.github.io/rspec/#/rspec/S5757/javascript) | Allowing confidential information to be logged is security-sensitive | ✅ | | | | |
| [constructor-for-side-effects](https://sonarsource.github.io/rspec/#/rspec/S1848/javascript) | Objects should not be created to be dropped immediately without being used | ✅ | | | | |
| [content-length](https://sonarsource.github.io/rspec/#/rspec/S5693/javascript) | HTTP request content length should be limited | ✅ | | | | |
| [content-security-policy](https://sonarsource.github.io/rspec/#/rspec/S5728/javascript) | Disabling content security policy fetch directives is security-sensitive | ✅ | | | | |
| [content-security-policy](https://sonarsource.github.io/rspec/#/rspec/S5728/javascript) | Content security policy fetch directives should not be disabled | ✅ | | | | |
| [cookie-no-httponly](https://sonarsource.github.io/rspec/#/rspec/S3330/javascript) | Creating cookies without the "HttpOnly" flag is security-sensitive | ✅ | | | | |
| [cookies](https://sonarsource.github.io/rspec/#/rspec/S2255/javascript) | Writing cookies is security-sensitive | | | | | ❌ |
| [cors](https://sonarsource.github.io/rspec/#/rspec/S5122/javascript) | Cross-Origin Resource Sharing (CORS) policy should be restricted to trusted origins | ✅ | | | | |
Expand All @@ -169,7 +169,7 @@ If you have any questions, encounter any bugs, or have feature requests, please
| [destructuring-assignment-syntax](https://sonarsource.github.io/rspec/#/rspec/S3514/javascript) | Destructuring syntax should be used for assignments | | | | | |
| [different-types-comparison](https://sonarsource.github.io/rspec/#/rspec/S3403/javascript) | Strict equality operators should not be used with dissimilar types | ✅ | | 💡 | 💭 | |
| [disabled-auto-escaping](https://sonarsource.github.io/rspec/#/rspec/S5247/javascript) | Disabling auto-escaping in template engines is security-sensitive | ✅ | | | 💭 | |
| [disabled-resource-integrity](https://sonarsource.github.io/rspec/#/rspec/S5725/javascript) | Using remote artifacts without integrity checks is security-sensitive | ✅ | | | 💭 | |
| [disabled-resource-integrity](https://sonarsource.github.io/rspec/#/rspec/S5725/javascript) | Remote artifacts should not be used without integrity checks | ✅ | | | 💭 | |
| [disabled-timeout](https://sonarsource.github.io/rspec/#/rspec/S6080/javascript) | Disabling Mocha timeouts should be explicit | ✅ | | | | |
| [dns-prefetching](https://sonarsource.github.io/rspec/#/rspec/S5743/javascript) | Allowing browsers to perform DNS prefetching is security-sensitive | | | | | ❌ |
| [dompurify-unsafe-config](https://sonarsource.github.io/rspec/#/rspec/S8479/javascript) | DOMPurify configuration should not be bypassable | ✅ | | | | |
Expand Down
48 changes: 48 additions & 0 deletions packages/analysis/src/jsts/rules/S6819/decorator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ const COMPOSITE_CHILD_ROLES = new Set([
* 1. Custom components (not standard HTML elements)
* 2. Valid ARIA patterns where semantic equivalents would lose functionality:
* - SVG with role="presentation"/"img" and aria-hidden="true" (decorative icons)
* - SVG with role="img" and a <title> child, aria-label, or aria-labelledby (semantic icons)
* - role="status" with aria-live (live region pattern)
* - role="slider" with complete aria-value* attributes
* - role="radio" with aria-checked
Expand Down Expand Up @@ -113,6 +114,7 @@ function isValidAriaPattern(node: TSESTree.JSXOpeningElement): boolean {

return (
isDecorativeSvg(elementName, role, attributes) ||
isSemanticSvgImg(elementName, role, attributes, node) ||
isLiveRegionStatus(role, attributes) ||
isCustomSlider(role, attributes) ||
isCustomRadio(role, attributes) ||
Expand All @@ -138,6 +140,52 @@ function isDecorativeSvg(
return ariaHiddenValue === true || ariaHiddenValue === 'true';
}

/**
* Checks if the element is a semantic SVG icon with role="img" and a proper accessible name.
*
* Inline SVG with role="img" is a WCAG-compliant pattern for icon components that need
* CSS class control, animation, or programmatic styling. It is not replaceable by <img>
* when the SVG provides an accessible name via <title>, aria-label, or aria-labelledby.
*/
function isSemanticSvgImg(
elementName: string | null,
role: string,
attributes: JSXOpeningElement['attributes'],
node: TSESTree.JSXOpeningElement,
): boolean {
if (elementName !== 'svg' || role !== 'img') {
return false;
}
const ariaLabelProp = getProp(attributes, 'aria-label');
if (ariaLabelProp && getLiteralPropValue(ariaLabelProp) !== '') {
return true;
}
const ariaLabelledbyProp = getProp(attributes, 'aria-labelledby');
if (ariaLabelledbyProp && getLiteralPropValue(ariaLabelledbyProp) !== '') {
return true;
}
return hasTitleChild(node);
}

/**
* Checks if the JSX element has a direct <title> child element with non-empty content.
*/
function hasTitleChild(node: TSESTree.JSXOpeningElement): boolean {
const parent = node.parent;
if (parent?.type !== 'JSXElement') {
return false;
}
return parent.children.some(
child =>
child.type === 'JSXElement' &&
child.openingElement.name.type === 'JSXIdentifier' &&
child.openingElement.name.name === 'title' &&
child.children.some(
c => (c.type === 'JSXText' && c.value.trim() !== '') || c.type === 'JSXExpressionContainer',
),
);
Comment thread
sonar-review-alpha[bot] marked this conversation as resolved.
}

function isLiveRegionStatus(role: string, attributes: JSXOpeningElement['attributes']): boolean {
return role === 'status' && Boolean(getProp(attributes, 'aria-live'));
}
Expand Down
57 changes: 57 additions & 0 deletions packages/analysis/src/jsts/rules/S6819/unit.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,24 @@ describe('S6819 upstream sentinel', () => {
],
});
});

it('upstream prefer-tag-over-role raises on svg role="img" with accessible labels that decorator suppresses', () => {
const ruleTester = new NoTypeCheckingRuleTester();
ruleTester.run('prefer-tag-over-role', upstreamRule, {
valid: [],
invalid: [
// svg role="img" with aria-label — suppressed by decorator, raised by upstream
{ code: `<svg role="img" aria-label="Arrow Down"><path d="M12 4v16"/></svg>`, errors: 1 },
// svg role="img" with aria-labelledby — suppressed by decorator, raised by upstream
{
code: `<svg role="img" aria-labelledby="icon-title"><title id="icon-title">Icon</title></svg>`,
errors: 1,
},
// svg role="img" with <title> child — suppressed by decorator, raised by upstream
{ code: `<svg role="img"><title>Settings</title><path d="M10 10"/></svg>`, errors: 1 },
],
});
});
});

describe('S6819', () => {
Expand Down Expand Up @@ -283,6 +301,45 @@ describe('S6819', () => {
});
});

// JS-1465: inline SVG with role="img" and proper accessible name
it('should not flag SVG with role="img" when it has an accessible name', () => {
const ruleTester = new NoTypeCheckingRuleTester();

ruleTester.run('prefer-tag-over-role - semantic svg img', rule, {
valid: [
// svg role="img" with <title> child — WCAG-recommended inline SVG icon pattern
{
code: `<svg role="img" viewBox="0 0 24 24"><title>Arrow Down</title><path d="M12 4v16"/></svg>`,
},
// svg role="img" with aria-label
{
code: `<svg role="img" aria-label="Settings" viewBox="0 0 24 24"><path d="M10 10"/></svg>`,
},
// svg role="img" with aria-labelledby referencing a <title> child
{
code: `<svg role="img" aria-labelledby="brand-title"><title id="brand-title">Brand Name</title></svg>`,
},
],
invalid: [
// svg role="img" without an accessible name is still a true positive
{
code: `<svg role="img" viewBox="0 0 24 24"><path d="M5 12h14"/></svg>`,
errors: 1,
},
// svg role="img" with an explicit empty aria-label provides no accessible name
{
code: `<svg role="img" aria-label=""><path d="M5 12h14"/></svg>`,
errors: 1,
},
// svg role="img" with an empty <title> provides no accessible name
{
code: `<svg role="img"><title></title><path d="M5 12h14"/></svg>`,
errors: 1,
},
],
});
});

// JS-1464: listbox/option composite widgets
it('should not flag ARIA listbox/option composite widget roles', () => {
Comment thread
sonar-review-alpha[bot] marked this conversation as resolved.
const ruleTester = new NoTypeCheckingRuleTester();
Expand Down
Loading