/** * @fileoverview Forbid target='_blank' attribute * @author Kevin Miller */ 'use strict'; const docsUrl = require('../util/docsUrl'); const linkComponentsUtil = require('../util/linkComponents'); // ------------------------------------------------------------------------------ // Rule Definition // ------------------------------------------------------------------------------ function findLastIndex(arr, condition) { for (let i = arr.length - 1; i >= 0; i -= 1) { if (condition(arr[i])) { return i; } } return -1; } function attributeValuePossiblyBlank(attribute) { if (!attribute || !attribute.value) { return false; } const value = attribute.value; if (value.type === 'Literal') { return typeof value.value === 'string' && value.value.toLowerCase() === '_blank'; } if (value.type === 'JSXExpressionContainer') { const expr = value.expression; if (expr.type === 'Literal') { return typeof expr.value === 'string' && expr.value.toLowerCase() === '_blank'; } if (expr.type === 'ConditionalExpression') { if (expr.alternate.type === 'Literal' && expr.alternate.value && expr.alternate.value.toLowerCase() === '_blank') { return true; } if (expr.consequent.type === 'Literal' && expr.consequent.value && expr.consequent.value.toLowerCase() === '_blank') { return true; } } } return false; } function hasExternalLink(node, linkAttribute, warnOnSpreadAttributes, spreadAttributeIndex) { const linkIndex = findLastIndex(node.attributes, (attr) => attr.name && attr.name.name === linkAttribute); const foundExternalLink = linkIndex !== -1 && ((attr) => attr.value.type === 'Literal' && /^(?:\w+:|\/\/)/.test(attr.value.value))( node.attributes[linkIndex]); return foundExternalLink || (warnOnSpreadAttributes && linkIndex < spreadAttributeIndex); } function hasDynamicLink(node, linkAttribute) { const dynamicLinkIndex = findLastIndex(node.attributes, (attr) => attr.name && attr.name.name === linkAttribute && attr.value && attr.value.type === 'JSXExpressionContainer'); if (dynamicLinkIndex !== -1) { return true; } } function getStringFromValue(value) { if (value) { if (value.type === 'Literal') { return value.value; } if (value.type === 'JSXExpressionContainer') { if (value.expression.type === 'TemplateLiteral') { return value.expression.quasis[0].value.cooked; } return value.expression && value.expression.value; } } return null; } function hasSecureRel(node, allowReferrer, warnOnSpreadAttributes, spreadAttributeIndex) { const relIndex = findLastIndex(node.attributes, (attr) => (attr.type === 'JSXAttribute' && attr.name.name === 'rel')); if (relIndex === -1 || (warnOnSpreadAttributes && relIndex < spreadAttributeIndex)) { return false; } const relAttribute = node.attributes[relIndex]; const value = getStringFromValue(relAttribute.value); const tags = value && typeof value === 'string' && value.toLowerCase().split(' '); const noreferrer = tags && tags.indexOf('noreferrer') >= 0; if (noreferrer) { return true; } return allowReferrer && tags && tags.indexOf('noopener') >= 0; } module.exports = { meta: { fixable: 'code', docs: { description: 'Forbid `target="_blank"` attribute without `rel="noreferrer"`', category: 'Best Practices', recommended: true, url: docsUrl('jsx-no-target-blank') }, messages: { noTargetBlank: 'Using target="_blank" without rel="noreferrer" ' + 'is a security risk: see https://html.spec.whatwg.org/multipage/links.html#link-type-noopener' }, schema: [{ type: 'object', properties: { allowReferrer: { type: 'boolean' }, enforceDynamicLinks: { enum: ['always', 'never'] }, warnOnSpreadAttributes: { type: 'boolean' } }, additionalProperties: false }] }, create(context) { const configuration = context.options[0] || {}; const allowReferrer = configuration.allowReferrer || false; const warnOnSpreadAttributes = configuration.warnOnSpreadAttributes || false; const enforceDynamicLinks = configuration.enforceDynamicLinks || 'always'; const components = linkComponentsUtil.getLinkComponents(context); return { JSXOpeningElement(node) { if (!components.has(node.name.name)) { return; } const targetIndex = findLastIndex(node.attributes, (attr) => attr.name && attr.name.name === 'target'); const spreadAttributeIndex = findLastIndex(node.attributes, (attr) => (attr.type === 'JSXSpreadAttribute')); if (!attributeValuePossiblyBlank(node.attributes[targetIndex])) { const hasSpread = spreadAttributeIndex >= 0; if (warnOnSpreadAttributes && hasSpread) { // continue to check below } else if ((hasSpread && targetIndex < spreadAttributeIndex) || !hasSpread || !warnOnSpreadAttributes) { return; } } const linkAttribute = components.get(node.name.name); const hasDangerousLink = hasExternalLink(node, linkAttribute, warnOnSpreadAttributes, spreadAttributeIndex) || (enforceDynamicLinks === 'always' && hasDynamicLink(node, linkAttribute)); if (hasDangerousLink && !hasSecureRel(node, allowReferrer, warnOnSpreadAttributes, spreadAttributeIndex)) { context.report({ node, messageId: 'noTargetBlank', fix(fixer) { // eslint 5 uses `node.attributes`; eslint 6+ uses `node.parent.attributes` const nodeWithAttrs = node.parent.attributes ? node.parent : node; // eslint 5 does not provide a `name` property on JSXSpreadElements const relAttribute = nodeWithAttrs.attributes.find((attr) => attr.name && attr.name.name === 'rel'); if (targetIndex < spreadAttributeIndex || (spreadAttributeIndex >= 0 && !relAttribute)) { return null; } if (!relAttribute) { return fixer.insertTextAfter(nodeWithAttrs.attributes.slice(-1)[0], ' rel="noreferrer"'); } if (!relAttribute.value) { return fixer.insertTextAfter(relAttribute, '="noreferrer"'); } if (relAttribute.value.type === 'Literal') { const parts = relAttribute.value.value .split('noreferrer') .filter(Boolean); return fixer.replaceText(relAttribute.value, `"${parts.concat('noreferrer').join(' ')}"`); } if (relAttribute.value.type === 'JSXExpressionContainer') { if (relAttribute.value.expression.type === 'Literal') { if (typeof relAttribute.value.expression.value === 'string') { const parts = relAttribute.value.expression.value .split('noreferrer') .filter(Boolean); return fixer.replaceText(relAttribute.value.expression, `"${parts.concat('noreferrer').join(' ')}"`); } // for undefined, boolean, number, symbol, bigint, and null return fixer.replaceText(relAttribute.value, '"noreferrer"'); } } return null; } }); } } }; } };