You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
259 lines
9.4 KiB
259 lines
9.4 KiB
/** |
|
* @fileoverview Forbid target='_blank' attribute |
|
* @author Kevin Miller |
|
*/ |
|
|
|
'use strict'; |
|
|
|
const docsUrl = require('../util/docsUrl'); |
|
const linkComponentsUtil = require('../util/linkComponents'); |
|
const report = require('../util/report'); |
|
|
|
// ------------------------------------------------------------------------------ |
|
// 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 && 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; |
|
} |
|
|
|
const messages = { |
|
noTargetBlankWithoutNoreferrer: 'Using target="_blank" without rel="noreferrer" (which implies rel="noopener") is a security risk in older browsers: see https://mathiasbynens.github.io/rel-noopener/#recommendations', |
|
noTargetBlankWithoutNoopener: 'Using target="_blank" without rel="noreferrer" or rel="noopener" (the former implies the latter and is preferred due to wider support) is a security risk: see https://mathiasbynens.github.io/rel-noopener/#recommendations', |
|
}; |
|
|
|
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, |
|
|
|
schema: [{ |
|
type: 'object', |
|
properties: { |
|
allowReferrer: { |
|
type: 'boolean', |
|
}, |
|
enforceDynamicLinks: { |
|
enum: ['always', 'never'], |
|
}, |
|
warnOnSpreadAttributes: { |
|
type: 'boolean', |
|
}, |
|
links: { |
|
type: 'boolean', |
|
default: true, |
|
}, |
|
forms: { |
|
type: 'boolean', |
|
default: false, |
|
}, |
|
}, |
|
additionalProperties: false, |
|
}], |
|
}, |
|
|
|
create(context) { |
|
const configuration = Object.assign( |
|
{ |
|
allowReferrer: false, |
|
warnOnSpreadAttributes: false, |
|
links: true, |
|
forms: false, |
|
}, |
|
context.options[0] |
|
); |
|
const allowReferrer = configuration.allowReferrer; |
|
const warnOnSpreadAttributes = configuration.warnOnSpreadAttributes; |
|
const enforceDynamicLinks = configuration.enforceDynamicLinks || 'always'; |
|
const linkComponents = linkComponentsUtil.getLinkComponents(context); |
|
const formComponents = linkComponentsUtil.getFormComponents(context); |
|
|
|
return { |
|
JSXOpeningElement(node) { |
|
const targetIndex = findLastIndex(node.attributes, (attr) => attr.name && attr.name.name === 'target'); |
|
const spreadAttributeIndex = findLastIndex(node.attributes, (attr) => (attr.type === 'JSXSpreadAttribute')); |
|
|
|
if (linkComponents.has(node.name.name)) { |
|
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 = linkComponents.get(node.name.name); |
|
const hasDangerousLink = hasExternalLink(node, linkAttribute, warnOnSpreadAttributes, spreadAttributeIndex) |
|
|| (enforceDynamicLinks === 'always' && hasDynamicLink(node, linkAttribute)); |
|
if (hasDangerousLink && !hasSecureRel(node, allowReferrer, warnOnSpreadAttributes, spreadAttributeIndex)) { |
|
const messageId = allowReferrer ? 'noTargetBlankWithoutNoopener' : 'noTargetBlankWithoutNoreferrer'; |
|
const relValue = allowReferrer ? 'noopener' : 'noreferrer'; |
|
report(context, messages[messageId], messageId, { |
|
node, |
|
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="${relValue}"`); |
|
} |
|
|
|
if (!relAttribute.value) { |
|
return fixer.insertTextAfter(relAttribute, `="${relValue}"`); |
|
} |
|
|
|
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; |
|
}, |
|
}); |
|
} |
|
} |
|
if (formComponents.has(node.name.name)) { |
|
if (!attributeValuePossiblyBlank(node.attributes[targetIndex])) { |
|
const hasSpread = spreadAttributeIndex >= 0; |
|
|
|
if (warnOnSpreadAttributes && hasSpread) { |
|
// continue to check below |
|
} else if ( |
|
(hasSpread && targetIndex < spreadAttributeIndex) |
|
|| !hasSpread |
|
|| !warnOnSpreadAttributes |
|
) { |
|
return; |
|
} |
|
} |
|
|
|
if (!configuration.forms || hasSecureRel(node)) { |
|
return; |
|
} |
|
|
|
const formAttribute = formComponents.get(node.name.name); |
|
|
|
if ( |
|
hasExternalLink(node, formAttribute) |
|
|| (enforceDynamicLinks === 'always' && hasDynamicLink(node, formAttribute)) |
|
) { |
|
const messageId = allowReferrer ? 'noTargetBlankWithoutNoopener' : 'noTargetBlankWithoutNoreferrer'; |
|
report(context, messages[messageId], messageId, { |
|
node, |
|
}); |
|
} |
|
} |
|
}, |
|
}; |
|
}, |
|
};
|
|
|