diff --git a/packages/plugins/eslint-plugin-react-dom/src/rules/no-missing-button-type.ts b/packages/plugins/eslint-plugin-react-dom/src/rules/no-missing-button-type.ts index 451809cca..eb2040fab 100644 --- a/packages/plugins/eslint-plugin-react-dom/src/rules/no-missing-button-type.ts +++ b/packages/plugins/eslint-plugin-react-dom/src/rules/no-missing-button-type.ts @@ -1,8 +1,7 @@ import type { RuleContext, RuleFeature, RuleSuggest } from "@eslint-react/kit"; import type { RuleFixer, RuleListener } from "@typescript-eslint/utils/ts-eslint"; import type { CamelCase } from "string-ts"; -import * as ER from "@eslint-react/core"; -import { createJsxElementResolver, createRule, findCustomComponentProp } from "../utils"; +import { createJsxElementResolver, createRule, resolveAttribute } from "../utils"; export const RULE_NAME = "no-missing-button-type"; @@ -41,40 +40,26 @@ export function create(context: RuleContext): RuleListener { JSXElement(node) { const { attributes, domElementType } = resolver.resolve(node); if (domElementType !== "button") return; - const customComponentProp = findCustomComponentProp("type", attributes); - const propNameOnJsx = customComponentProp?.name ?? "type"; - const attributeNode = ER.getAttribute( - context, - propNameOnJsx, - node.openingElement.attributes, - context.sourceCode.getScope(node), - ); - if (attributeNode != null) { - const attributeValue = ER.getAttributeValue( - context, - attributeNode, - propNameOnJsx, - ); - if (attributeValue.kind !== "some" || typeof attributeValue.value !== "string") { - context.report({ - messageId: "noMissingButtonType", - node: attributeNode, - suggest: getSuggest((type) => (fixer: RuleFixer) => { - return fixer.replaceText(attributeNode, `${propNameOnJsx}="${type}"`); - }), - }); - } - return; - } - if (typeof customComponentProp?.defaultValue !== "string") { + const typeAttribute = resolveAttribute(context, attributes, node, "type"); + if (typeAttribute.attributeValueString != null) return; + if (typeAttribute.attribute == null) { context.report({ messageId: "noMissingButtonType", - node, + node: node.openingElement, suggest: getSuggest((type) => (fixer: RuleFixer) => { - return fixer.insertTextAfter(node.openingElement.name, ` ${propNameOnJsx}="${type}"`); + return fixer.insertTextAfter(node.openingElement.name, ` ${typeAttribute.attributeName}="${type}"`); }), }); + return; } + context.report({ + messageId: "noMissingButtonType", + node: typeAttribute.attributeValue?.node ?? typeAttribute.attribute, + suggest: getSuggest((type) => (fixer: RuleFixer) => { + if (typeAttribute.attribute == null) return null; + return fixer.replaceText(typeAttribute.attribute, `${typeAttribute.attributeName}="${type}"`); + }), + }); }, }; } diff --git a/packages/plugins/eslint-plugin-react-dom/src/rules/no-missing-iframe-sandbox.ts b/packages/plugins/eslint-plugin-react-dom/src/rules/no-missing-iframe-sandbox.ts index 139911e9d..ae25b901d 100644 --- a/packages/plugins/eslint-plugin-react-dom/src/rules/no-missing-iframe-sandbox.ts +++ b/packages/plugins/eslint-plugin-react-dom/src/rules/no-missing-iframe-sandbox.ts @@ -1,9 +1,8 @@ import type { RuleContext, RuleFeature } from "@eslint-react/kit"; import type { RuleListener } from "@typescript-eslint/utils/ts-eslint"; import type { CamelCase } from "string-ts"; -import * as ER from "@eslint-react/core"; -import { createJsxElementResolver, createRule, findCustomComponentProp } from "../utils"; +import { createJsxElementResolver, createRule, resolveAttribute } from "../utils"; export const RULE_NAME = "no-missing-iframe-sandbox"; @@ -41,52 +40,34 @@ export function create(context: RuleContext): RuleListener { JSXElement(node) { const { attributes, domElementType } = resolver.resolve(node); if (domElementType !== "iframe") return; - const customComponentProp = findCustomComponentProp("sandbox", attributes); - const propNameOnJsx = customComponentProp?.name ?? "sandbox"; - const attributeNode = ER.getAttribute( - context, - propNameOnJsx, - node.openingElement.attributes, - context.sourceCode.getScope(node), - ); - if (attributeNode != null) { - const attributeValue = ER.getAttributeValue( - context, - attributeNode, - propNameOnJsx, - ); - if (attributeValue.kind !== "some" || typeof attributeValue.value !== "string") { - context.report({ - messageId: "noMissingIframeSandbox", - node: attributeNode, - suggest: [ - { - messageId: "addIframeSandbox", - data: { value: "" }, - fix(fixer) { - return fixer.replaceText(attributeNode, `${propNameOnJsx}=""`); - }, - }, - ], - }); - } - return; - } - if (typeof customComponentProp?.defaultValue !== "string") { + const sandboxAttribute = resolveAttribute(context, attributes, node, "sandbox"); + if (sandboxAttribute.attributeValueString != null) return; + if (sandboxAttribute.attribute == null) { context.report({ messageId: "noMissingIframeSandbox", - node, - suggest: [ - { - messageId: "addIframeSandbox", - data: { value: "" }, - fix(fixer) { - return fixer.insertTextAfter(node.openingElement.name, ` ${propNameOnJsx}=""`); - }, + node: node.openingElement, + suggest: [{ + messageId: "addIframeSandbox", + data: { value: "" }, + fix(fixer) { + return fixer.insertTextAfter(node.openingElement.name, ` ${sandboxAttribute.attributeName}=""`); }, - ], + }], }); + return; } + context.report({ + messageId: "noMissingIframeSandbox", + node: sandboxAttribute.attributeValue?.node ?? sandboxAttribute.attribute, + suggest: [{ + messageId: "addIframeSandbox", + data: { value: "" }, + fix(fixer) { + if (sandboxAttribute.attribute == null) return null; + return fixer.replaceText(sandboxAttribute.attribute, `${sandboxAttribute.attributeName}=""`); + }, + }], + }); }, }; } diff --git a/packages/plugins/eslint-plugin-react-dom/src/rules/no-unsafe-iframe-sandbox.ts b/packages/plugins/eslint-plugin-react-dom/src/rules/no-unsafe-iframe-sandbox.ts index a679f4e0d..f399edd1c 100644 --- a/packages/plugins/eslint-plugin-react-dom/src/rules/no-unsafe-iframe-sandbox.ts +++ b/packages/plugins/eslint-plugin-react-dom/src/rules/no-unsafe-iframe-sandbox.ts @@ -1,9 +1,9 @@ +import type { unit } from "@eslint-react/eff"; import type { RuleContext, RuleFeature } from "@eslint-react/kit"; + import type { RuleListener } from "@typescript-eslint/utils/ts-eslint"; import type { CamelCase } from "string-ts"; -import * as ER from "@eslint-react/core"; - -import { createJsxElementResolver, createRule, findCustomComponentProp } from "../utils"; +import { createJsxElementResolver, createRule, resolveAttribute } from "../utils"; export const RULE_NAME = "no-unsafe-iframe-sandbox"; @@ -15,7 +15,7 @@ const unsafeSandboxValues = [ ["allow-scripts", "allow-same-origin"], ] as const; -function hasSafeSandbox(value: unknown) { +function isSafeSandbox(value: string | unit): value is string { if (typeof value !== "string") return false; return !unsafeSandboxValues.some((values) => { return values.every((v) => value.includes(v)); @@ -45,33 +45,11 @@ export function create(context: RuleContext): RuleListener { JSXElement(node) { const { attributes, domElementType } = resolver.resolve(node); if (domElementType !== "iframe") return; - const customComponentProp = findCustomComponentProp("sandbox", attributes); - const propNameOnJsx = customComponentProp?.name ?? "sandbox"; - const attributeNode = ER.getAttribute( - context, - propNameOnJsx, - node.openingElement.attributes, - context.sourceCode.getScope(node), - ); - if (attributeNode != null) { - const attributeValue = ER.getAttributeValue( - context, - attributeNode, - propNameOnJsx, - ); - if (attributeValue.kind === "some" && !hasSafeSandbox(attributeValue.value)) { - context.report({ - messageId: "noUnsafeIframeSandbox", - node: attributeNode, - }); - return; - } - } - if (customComponentProp?.defaultValue == null) return; - if (!hasSafeSandbox(customComponentProp.defaultValue)) { + const sandboxAttribute = resolveAttribute(context, attributes, node, "sandbox"); + if (!isSafeSandbox(sandboxAttribute.attributeValueString)) { context.report({ messageId: "noUnsafeIframeSandbox", - node, + node: sandboxAttribute.attributeValue?.node ?? sandboxAttribute.attribute ?? node, }); } }, diff --git a/packages/plugins/eslint-plugin-react-dom/src/rules/no-unsafe-target-blank.spec.ts b/packages/plugins/eslint-plugin-react-dom/src/rules/no-unsafe-target-blank.spec.ts index 3f62b31d3..dd65d3851 100644 --- a/packages/plugins/eslint-plugin-react-dom/src/rules/no-unsafe-target-blank.spec.ts +++ b/packages/plugins/eslint-plugin-react-dom/src/rules/no-unsafe-target-blank.spec.ts @@ -7,26 +7,80 @@ ruleTester.run(RULE_NAME, rule, { invalid: [ { code: '', - errors: [{ messageId: "noUnsafeTargetBlank" }], + errors: [ + { + messageId: "noUnsafeTargetBlank", + suggestions: [ + { + messageId: "addRelNoreferrerNoopener", + output: '', + }, + ], + }, + ], }, { code: '', - errors: [{ messageId: "noUnsafeTargetBlank" }], + errors: [ + { + messageId: "noUnsafeTargetBlank", + suggestions: [ + { + messageId: "addRelNoreferrerNoopener", + output: '', + }, + ], + }, + ], }, { code: '', - errors: [{ messageId: "noUnsafeTargetBlank" }], + errors: [ + { + messageId: "noUnsafeTargetBlank", + suggestions: [ + { + messageId: "addRelNoreferrerNoopener", + output: '', + }, + ], + }, + ], }, { code: tsx` const props = { href: "https://react.dev", target: "_blank" }; const a = ; `, - errors: [{ messageId: "noUnsafeTargetBlank" }], + errors: [ + { + messageId: "noUnsafeTargetBlank", + suggestions: [ + { + messageId: "addRelNoreferrerNoopener", + output: tsx` + const props = { href: "https://react.dev", target: "_blank" }; + const a = ; + `, + }, + ], + }, + ], }, { code: '', - errors: [{ messageId: "noUnsafeTargetBlank" }], + errors: [ + { + messageId: "noUnsafeTargetBlank", + suggestions: [ + { + messageId: "addRelNoreferrerNoopener", + output: + '', + }, + ], + }, + ], settings: { "react-x": { polymorphicPropName: "as", @@ -35,7 +89,18 @@ ruleTester.run(RULE_NAME, rule, { }, { code: '', - errors: [{ messageId: "noUnsafeTargetBlank" }], + errors: [ + { + messageId: "noUnsafeTargetBlank", + suggestions: [ + { + messageId: "addRelNoreferrerNoopener", + output: + '', + }, + ], + }, + ], settings: { "react-x": { polymorphicPropName: "component", @@ -44,7 +109,17 @@ ruleTester.run(RULE_NAME, rule, { }, { code: '', - errors: [{ messageId: "noUnsafeTargetBlank" }], + errors: [ + { + messageId: "noUnsafeTargetBlank", + suggestions: [ + { + messageId: "addRelNoreferrerNoopener", + output: '', + }, + ], + }, + ], settings: { "react-x": { additionalComponents: [ @@ -58,7 +133,17 @@ ruleTester.run(RULE_NAME, rule, { }, { code: '', - errors: [{ messageId: "noUnsafeTargetBlank" }], + errors: [ + { + messageId: "noUnsafeTargetBlank", + suggestions: [ + { + messageId: "addRelNoreferrerNoopener", + output: '', + }, + ], + }, + ], settings: { "react-x": { additionalComponents: [ @@ -75,7 +160,20 @@ ruleTester.run(RULE_NAME, rule, { const a = ; const b = ; `, - errors: [{ messageId: "noUnsafeTargetBlank" }], // should be 1 error + errors: [ + { + messageId: "noUnsafeTargetBlank", + suggestions: [ + { + messageId: "addRelNoreferrerNoopener", + output: tsx` + const a = ; + const b = ; + `, + }, + ], + }, + ], // should be 1 error settings: { "react-x": { additionalComponents: [ @@ -103,8 +201,30 @@ ruleTester.run(RULE_NAME, rule, { const b = ; `, errors: [ - { messageId: "noUnsafeTargetBlank" }, - { messageId: "noUnsafeTargetBlank" }, + { + messageId: "noUnsafeTargetBlank", + suggestions: [ + { + messageId: "addRelNoreferrerNoopener", + output: tsx` + const a = ; + const b = ; + `, + }, + ], + }, + { + messageId: "noUnsafeTargetBlank", + suggestions: [ + { + messageId: "addRelNoreferrerNoopener", + output: tsx` + const a = ; + const b = ; + `, + }, + ], + }, ], settings: { "react-x": { @@ -137,7 +257,20 @@ ruleTester.run(RULE_NAME, rule, { const a = ; const b = ; `, - errors: [{ messageId: "noUnsafeTargetBlank" }], + errors: [ + { + messageId: "noUnsafeTargetBlank", + suggestions: [ + { + messageId: "addRelNoreferrerNoopener", + output: tsx` + const a = ; + const b = ; + `, + }, + ], + }, + ], settings: { "react-x": { additionalComponents: [ @@ -168,7 +301,19 @@ ruleTester.run(RULE_NAME, rule, { code: tsx` const a = ; `, - errors: [{ messageId: "noUnsafeTargetBlank" }], + errors: [ + { + messageId: "noUnsafeTargetBlank", + suggestions: [ + { + messageId: "addRelNoreferrerNoopener", + output: tsx` + const a = ; + `, + }, + ], + }, + ], settings: { "react-x": { additionalComponents: [ diff --git a/packages/plugins/eslint-plugin-react-dom/src/rules/no-unsafe-target-blank.ts b/packages/plugins/eslint-plugin-react-dom/src/rules/no-unsafe-target-blank.ts index c4bb2d7e2..bccf3c32a 100644 --- a/packages/plugins/eslint-plugin-react-dom/src/rules/no-unsafe-target-blank.ts +++ b/packages/plugins/eslint-plugin-react-dom/src/rules/no-unsafe-target-blank.ts @@ -1,18 +1,19 @@ +import type { unit } from "@eslint-react/eff"; import type { RuleContext, RuleFeature } from "@eslint-react/kit"; import type { TSESTree } from "@typescript-eslint/types"; -import type { RuleListener } from "@typescript-eslint/utils/ts-eslint"; -import type { CamelCase } from "string-ts"; -import * as ER from "@eslint-react/core"; -import { unit } from "@eslint-react/eff"; +import type { RuleListener } from "@typescript-eslint/utils/ts-eslint"; -import { createJsxElementResolver, createRule, findCustomComponentProp } from "../utils"; +import type { CamelCase } from "string-ts"; +import { createJsxElementResolver, createRule, resolveAttribute } from "../utils"; export const RULE_NAME = "no-unsafe-target-blank"; export const RULE_FEATURES = [] as const satisfies RuleFeature[]; -export type MessageID = CamelCase; +export type MessageID = CamelCase | RuleSuggestMessageID; + +export type RuleSuggestMessageID = "addRelNoreferrerNoopener"; function isExternalLinkLike(value: string | unit) { if (value == null) return false; @@ -33,7 +34,10 @@ export default createRule<[], MessageID>({ description: 'Disallow `target="_blank"` without `rel="noreferrer noopener"`.', [Symbol.for("rule_features")]: RULE_FEATURES, }, + fixable: "code", + hasSuggestions: true, messages: { + addRelNoreferrerNoopener: `Add 'rel="noreferrer noopener"' to the link to prevent security risks.`, noUnsafeTargetBlank: `Using 'target="_blank"' on an external link without 'rel="noreferrer noopener"' is a security risk.`, }, @@ -50,37 +54,44 @@ export function create(context: RuleContext): RuleListener { JSXElement(node: TSESTree.JSXElement) { const { attributes, domElementType } = resolver.resolve(node); if (domElementType !== "a") return; - const elementScope = context.sourceCode.getScope(node); - - const getAttributeStringValue = (name: string) => { - const customComponentProp = findCustomComponentProp(name, attributes); - const propNameOnJsx = customComponentProp?.name ?? name; - const attributeNode = ER.getAttribute( - context, - propNameOnJsx, - node.openingElement.attributes, - elementScope, - ); - if (attributeNode == null) return customComponentProp?.defaultValue; - const attributeValue = ER.getAttributeValue(context, attributeNode, propNameOnJsx); - if (attributeValue.kind === "some" && typeof attributeValue.value === "string") { - return attributeValue.value; - } - return unit; - }; - - if (getAttributeStringValue("target") !== "_blank") { + const targetAttribute = resolveAttribute(context, attributes, node, "target"); + if (targetAttribute.attributeValueString !== "_blank") { + return; + } + const hrefAttribute = resolveAttribute(context, attributes, node, "href"); + if (!isExternalLinkLike(hrefAttribute.attributeValueString)) { return; } - if (!isExternalLinkLike(getAttributeStringValue("href"))) { + const relAttribute = resolveAttribute(context, attributes, node, "rel"); + if (isSafeRel(relAttribute.attributeValueString)) { return; } - if (isSafeRel(getAttributeStringValue("rel"))) { + if (relAttribute.attribute == null) { + context.report({ + messageId: "noUnsafeTargetBlank", + node: node.openingElement, + suggest: [{ + messageId: "addRelNoreferrerNoopener", + fix(fixer) { + return fixer.insertTextAfter( + node.openingElement.name, + ` ${relAttribute.attributeName}="noreferrer noopener"`, + ); + }, + }], + }); return; } context.report({ messageId: "noUnsafeTargetBlank", - node, + node: relAttribute.attributeValue?.node ?? relAttribute.attribute, + suggest: [{ + messageId: "addRelNoreferrerNoopener", + fix(fixer) { + if (relAttribute.attribute == null) return null; + return fixer.replaceText(relAttribute.attribute, `${relAttribute.attributeName}="noreferrer noopener"`); + }, + }], }); }, }; diff --git a/packages/plugins/eslint-plugin-react-dom/src/utils/index.ts b/packages/plugins/eslint-plugin-react-dom/src/utils/index.ts index 38afa0ffa..158b76cca 100644 --- a/packages/plugins/eslint-plugin-react-dom/src/utils/index.ts +++ b/packages/plugins/eslint-plugin-react-dom/src/utils/index.ts @@ -1,3 +1,4 @@ export * from "./create-jsx-element-resolver"; export * from "./create-rule"; export * from "./find-custom-component"; +export * from "./resolve-attribute"; diff --git a/packages/plugins/eslint-plugin-react-dom/src/utils/resolve-attribute.ts b/packages/plugins/eslint-plugin-react-dom/src/utils/resolve-attribute.ts new file mode 100644 index 000000000..5da19d613 --- /dev/null +++ b/packages/plugins/eslint-plugin-react-dom/src/utils/resolve-attribute.ts @@ -0,0 +1,50 @@ +import type { RuleContext } from "@eslint-react/kit"; +import type { CustomComponentPropNormalized } from "@eslint-react/shared"; +import type { TSESTree } from "@typescript-eslint/types"; +import * as ER from "@eslint-react/core"; +import { unit } from "@eslint-react/eff"; +import { findCustomComponentProp } from "./find-custom-component"; + +export function resolveAttribute( + context: RuleContext, + attributes: CustomComponentPropNormalized[], + elementNode: TSESTree.JSXElement, + attributeName: string, +): { + attribute: unit | TSESTree.JSXAttribute | TSESTree.JSXSpreadAttribute; + attributeName: string; + attributeValue: unit | ReturnType; + attributeValueString: unit | string; +} { + const customComponentProp = findCustomComponentProp(attributeName, attributes); + const propNameOnJsx = customComponentProp?.name ?? attributeName; + const attribute = ER.getAttribute( + context, + propNameOnJsx, + elementNode.openingElement.attributes, + context.sourceCode.getScope(elementNode), + ); + if (attribute == null) { + return { + attribute: unit, + attributeName: propNameOnJsx, + attributeValue: unit, + attributeValueString: customComponentProp?.defaultValue, + } as const; + } + const attributeValue = ER.getAttributeValue(context, attribute, propNameOnJsx); + if (attributeValue.kind === "some" && typeof attributeValue.value === "string") { + return { + attribute, + attributeName: propNameOnJsx, + attributeValue, + attributeValueString: attributeValue.value, + } as const; + } + return { + attribute, + attributeName: propNameOnJsx, + attributeValue: unit, + attributeValueString: customComponentProp?.defaultValue ?? unit, + } as const; +}