diff --git a/CHANGELOG.md b/CHANGELOG.md index 505391dc42..4ba1f27342 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange * add [`hook-use-state`] rule to enforce symmetric useState hook variable names ([#2921][] @duncanbeevers) * [`jsx-no-target-blank`]: Improve fixer with option `allowReferrer` ([#3167][] @apepper) * [`jsx-curly-brace-presence`]: add "propElementValues" config option ([#3191][] @ljharb) +* add [`iframe-missing-sandbox`] rule ([#2753][] @tosmolka @ljharb) ### Fixed * [`prop-types`], `propTypes`: add support for exported type inference ([#3163][] @vedadeepta) @@ -37,6 +38,7 @@ This change log adheres to standards from [Keep a CHANGELOG](https://keepachange [#3160]: https://github.com/yannickcr/eslint-plugin-react/pull/3160 [#3133]: https://github.com/yannickcr/eslint-plugin-react/pull/3133 [#2921]: https://github.com/yannickcr/eslint-plugin-react/pull/2921 +[#2753]: https://github.com/yannickcr/eslint-plugin-react/pull/2753 ## [7.28.0] - 2021.12.22 @@ -3532,6 +3534,7 @@ If you're still not using React 15 you can keep the old behavior by setting the [`forbid-prop-types`]: docs/rules/forbid-prop-types.md [`function-component-definition`]: docs/rules/function-component-definition.md [`hook-use-state`]: docs/rules/hook-use-state.md +[`iframe-missing-sandbox`]: docs/rules/iframe-missing-sandbox.md [`jsx-boolean-value`]: docs/rules/jsx-boolean-value.md [`jsx-child-element-spacing`]: docs/rules/jsx-child-element-spacing.md [`jsx-closing-bracket-location`]: docs/rules/jsx-closing-bracket-location.md diff --git a/README.md b/README.md index 2a78e841b3..b1efbcd0de 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,7 @@ Enable the rules that you would like to use. | | | [react/forbid-prop-types](docs/rules/forbid-prop-types.md) | Forbid certain propTypes | | | 🔧 | [react/function-component-definition](docs/rules/function-component-definition.md) | Standardize the way function component get defined | | | | [react/hook-use-state](docs/rules/hook-use-state.md) | Ensure symmetric naming of useState hook value and setter variables | +| | | [react/iframe-missing-sandbox](docs/rules/iframe-missing-sandbox.md) | Enforce sandbox attribute on iframe elements | | | | [react/no-access-state-in-setstate](docs/rules/no-access-state-in-setstate.md) | Reports when this.state is accessed within setState | | | | [react/no-adjacent-inline-elements](docs/rules/no-adjacent-inline-elements.md) | Prevent adjacent inline elements not separated by whitespace. | | | | [react/no-array-index-key](docs/rules/no-array-index-key.md) | Prevent usage of Array index in keys | diff --git a/docs/rules/iframe-missing-sandbox.md b/docs/rules/iframe-missing-sandbox.md new file mode 100644 index 0000000000..f21f98ad9f --- /dev/null +++ b/docs/rules/iframe-missing-sandbox.md @@ -0,0 +1,40 @@ +# Enforce sandbox attribute on iframe elements (react/iframe-missing-sandbox) + +The sandbox attribute enables an extra set of restrictions for the content in the iframe. Using sandbox attribute is considered a good security practice. + +See https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#attr-sandbox + +## Rule Details + +This rule checks all React iframe elements and verifies that there is sandbox attribute and that it's value is valid. In addition to that it also reports cases where attribute contains `allow-scripts` and `allow-same-origin` at the same time as this combination allows the embedded document to remove the sandbox attribute and bypass the restrictions. + +The following patterns are considered warnings: + +```jsx +var React = require('react'); + +var Frame = () => ( +
+ + {React.createElement('iframe')} +
+); +``` + +The following patterns are **not** considered warnings: + +```jsx +var React = require('react'); + +var Frame = + {React.createElement('iframe', { sandbox: "allow-popups" })} + +); +``` + +## When not to use + +If you don't want to enforce sandbox attribute on iframe elements. diff --git a/index.js b/index.js index 367688457d..baabf87df4 100644 --- a/index.js +++ b/index.js @@ -17,6 +17,7 @@ const allRules = { 'forbid-prop-types': require('./lib/rules/forbid-prop-types'), 'function-component-definition': require('./lib/rules/function-component-definition'), 'hook-use-state': require('./lib/rules/hook-use-state'), + 'iframe-missing-sandbox': require('./lib/rules/iframe-missing-sandbox'), 'jsx-boolean-value': require('./lib/rules/jsx-boolean-value'), 'jsx-child-element-spacing': require('./lib/rules/jsx-child-element-spacing'), 'jsx-closing-bracket-location': require('./lib/rules/jsx-closing-bracket-location'), diff --git a/lib/rules/iframe-missing-sandbox.js b/lib/rules/iframe-missing-sandbox.js new file mode 100644 index 0000000000..9a8bd4774d --- /dev/null +++ b/lib/rules/iframe-missing-sandbox.js @@ -0,0 +1,142 @@ +/** + * @fileoverview TBD + */ + +'use strict'; + +const docsUrl = require('../util/docsUrl'); +const isCreateElement = require('../util/isCreateElement'); +const report = require('../util/report'); + +const messages = { + attributeMissing: 'An iframe element is missing a sandbox attribute', + invalidValue: 'An iframe element defines a sandbox attribute with invalid value "{{ value }}"', + invalidCombination: 'An iframe element defines a sandbox attribute with both allow-scripts and allow-same-origin which is invalid', +}; + +const ALLOWED_VALUES = [ + // From https://developer.mozilla.org/en-US/docs/Web/HTML/Element/iframe#attr-sandbox + '', + 'allow-downloads-without-user-activation', + 'allow-downloads', + 'allow-forms', + 'allow-modals', + 'allow-orientation-lock', + 'allow-pointer-lock', + 'allow-popups', + 'allow-popups-to-escape-sandbox', + 'allow-presentation', + 'allow-same-origin', + 'allow-scripts', + 'allow-storage-access-by-user-activation', + 'allow-top-navigation', + 'allow-top-navigation-by-user-activation', +]; + +function validateSandboxAttribute(context, node, attribute) { + if (typeof attribute !== 'string') { + // Only string literals are supported for now + return; + } + const values = attribute.split(' '); + let allowScripts = false; + let allowSameOrigin = false; + values.forEach((attributeValue) => { + const trimmedAttributeValue = attributeValue.trim(); + if (ALLOWED_VALUES.indexOf(trimmedAttributeValue) === -1) { + report(context, messages.invalidValue, 'invalidValue', { + node, + data: { + value: trimmedAttributeValue, + }, + }); + } + if (trimmedAttributeValue === 'allow-scripts') { + allowScripts = true; + } + if (trimmedAttributeValue === 'allow-same-origin') { + allowSameOrigin = true; + } + }); + if (allowScripts && allowSameOrigin) { + report(context, messages.invalidCombination, 'invalidCombination', { + node, + }); + } +} + +function checkAttributes(context, node) { + let sandboxAttributeFound = false; + node.attributes.forEach((attribute) => { + if (attribute.type === 'JSXAttribute' + && attribute.name + && attribute.name.type === 'JSXIdentifier' + && attribute.name.name === 'sandbox' + ) { + sandboxAttributeFound = true; + if ( + attribute.value + && attribute.value.type === 'Literal' + && attribute.value.value + ) { + validateSandboxAttribute(context, node, attribute.value.value); + } + } + }); + if (!sandboxAttributeFound) { + report(context, messages.attributeMissing, 'attributeMissing', { + node, + }); + } +} + +function checkProps(context, node) { + let sandboxAttributeFound = false; + if (node.arguments.length > 1) { + const props = node.arguments[1]; + const sandboxProp = props.properties && props.properties.find((x) => x.type === 'Property' && x.key.name === 'sandbox'); + if (sandboxProp) { + sandboxAttributeFound = true; + if (sandboxProp.value && sandboxProp.value.type === 'Literal' && sandboxProp.value.value) { + validateSandboxAttribute(context, node, sandboxProp.value.value); + } + } + } + if (!sandboxAttributeFound) { + report(context, messages.attributeMissing, 'attributeMissing', { + node, + }); + } +} + +module.exports = { + meta: { + docs: { + description: 'Enforce sandbox attribute on iframe elements', + category: 'Best Practices', + recommended: false, + url: docsUrl('iframe-missing-sandbox'), + }, + + schema: [], + + messages, + }, + + create(context) { + return { + 'JSXOpeningElement[name.name="iframe"]'(node) { + checkAttributes(context, node); + }, + + CallExpression(node) { + if (isCreateElement(node, context) && node.arguments && node.arguments.length > 0) { + const tag = node.arguments[0]; + if (tag.type === 'Literal' && tag.value === 'iframe') { + checkProps(context, node); + } + } + }, + }; + }, +}; diff --git a/tests/lib/rules/iframe-missing-sandbox.js b/tests/lib/rules/iframe-missing-sandbox.js new file mode 100644 index 0000000000..82c4304e14 --- /dev/null +++ b/tests/lib/rules/iframe-missing-sandbox.js @@ -0,0 +1,124 @@ +/** + * @fileoverview TBD + */ + +'use strict'; + +// ------------------------------------------------------------------------------ +// Requirements +// ------------------------------------------------------------------------------ + +const RuleTester = require('eslint').RuleTester; +const rule = require('../../../lib/rules/iframe-missing-sandbox'); + +const parsers = require('../../helpers/parsers'); + +const parserOptions = { + ecmaVersion: 2018, + sourceType: 'module', + ecmaFeatures: { + jsx: true, + }, +}; + +// ------------------------------------------------------------------------------ +// Tests +// ------------------------------------------------------------------------------ + +const ruleTester = new RuleTester({ parserOptions }); +ruleTester.run('iframe-missing-sandbox', rule, { + valid: parsers.all([ + { code: '
;' }, + + { code: '' }, + { code: 'React.createElement("iframe", { src: "foo.htm", sandbox: true })' }, + + { code: '' }, + + { code: '' }, + { code: '' }, + { code: '' }, + { code: '' }, + { code: '' }, + { code: '' }, + { code: '' }, + { code: '' }, + { code: '' }, + { code: '' }, + { code: '' }, + { code: '' }, + { code: '' }, + { code: 'React.createElement("iframe", { sandbox: "allow-forms" })' }, + { code: 'React.createElement("iframe", { sandbox: "allow-modals" })' }, + { code: 'React.createElement("iframe", { sandbox: "allow-orientation-lock" })' }, + { code: 'React.createElement("iframe", { sandbox: "allow-pointer-lock" })' }, + { code: 'React.createElement("iframe", { sandbox: "allow-popups" })' }, + { code: 'React.createElement("iframe", { sandbox: "allow-popups-to-escape-sandbox" })' }, + { code: 'React.createElement("iframe", { sandbox: "allow-presentation" })' }, + { code: 'React.createElement("iframe", { sandbox: "allow-same-origin" })' }, + { code: 'React.createElement("iframe", { sandbox: "allow-scripts" })' }, + { code: 'React.createElement("iframe", { sandbox: "allow-top-navigation" })' }, + { code: 'React.createElement("iframe", { sandbox: "allow-top-navigation-by-user-activation" })' }, + { code: 'React.createElement("iframe", { sandbox: "allow-forms allow-modals" })' }, + { code: 'React.createElement("iframe", { sandbox: "allow-popups allow-popups-to-escape-sandbox allow-pointer-lock allow-same-origin allow-top-navigation" })' }, + ]), + invalid: parsers.all([ + { + code: ';', + errors: [{ messageId: 'attributeMissing' }], + }, + { + code: '', + errors: [{ messageId: 'invalidValue', data: { value: '__unknown__' } }], + }, + { + code: 'React.createElement("iframe", { sandbox: "__unknown__" })', + errors: [{ messageId: 'invalidValue', data: { value: '__unknown__' } }], + }, + + { + code: ';', + errors: [{ messageId: 'invalidCombination' }], + }, + { + code: '