|
/** |
|
* @fileoverview Forbid target='_blank' attribute |
|
* @author Kevin Miller |
|
* |
|
* Modified from https://github.com/jsx-eslint/eslint-plugin-react/blob/4a92667e4b99221013e1d2aa61e97296895cecc1/lib/rules/jsx-no-target-blank.js |
|
* to avoid throwing an error on allowlisted hostnames |
|
*/ |
|
|
|
'use strict'; |
|
|
|
// ------------------------------------------------------------------------------ |
|
// Utilities |
|
// ------------------------------------------------------------------------------ |
|
|
|
const DEFAULT_LINK_COMPONENTS = ['a']; |
|
const DEFAULT_LINK_ATTRIBUTE = 'href'; |
|
|
|
const DEFAULT_FORM_COMPONENTS = ['form']; |
|
const DEFAULT_FORM_ATTRIBUTE = 'action'; |
|
|
|
function getFormComponents(context) { |
|
const settings = context.settings || {}; |
|
const formComponents = /** @type {typeof DEFAULT_FORM_COMPONENTS} */ ( |
|
DEFAULT_FORM_COMPONENTS.concat(settings.formComponents || []) |
|
); |
|
return new Map( |
|
formComponents.map((value) => { |
|
if (typeof value === 'string') { |
|
return [value, DEFAULT_FORM_ATTRIBUTE]; |
|
} |
|
return [value.name, value.formAttribute]; |
|
}) |
|
); |
|
} |
|
|
|
function getLinkComponents(context) { |
|
const settings = context.settings || {}; |
|
const linkComponents = /** @type {typeof DEFAULT_LINK_COMPONENTS} */ ( |
|
DEFAULT_LINK_COMPONENTS.concat(settings.linkComponents || []) |
|
); |
|
return new Map( |
|
linkComponents.map((value) => { |
|
if (typeof value === 'string') { |
|
return [value, DEFAULT_LINK_ATTRIBUTE]; |
|
} |
|
return [value.name, value.linkAttribute]; |
|
}) |
|
); |
|
} |
|
|
|
// The version in eslint-plugin-react checks eslint is >= 4.15 |
|
function getMessageData(messageId, message) { |
|
return messageId ? { messageId } : { message }; |
|
} |
|
|
|
function report(context, message, messageId, data) { |
|
context.report(Object.assign(getMessageData(messageId, message), data)); |
|
} |
|
|
|
// ------------------------------------------------------------------------------ |
|
// 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?.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 hostnameIsAllowed(node, linkAttribute, allowedHostnames) { |
|
const linkIndex = findLastIndex(node.attributes, (attr) => attr.name && attr.name.name === linkAttribute); |
|
if (linkIndex === -1) return false; |
|
|
|
try { |
|
const attr = node.attributes[linkIndex]; |
|
const hostname = new URL(attr.value.value).hostname; |
|
return allowedHostnames.includes(hostname); |
|
} catch (e) { |
|
return false; |
|
} |
|
} |
|
|
|
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; |
|
} |
|
} |
|
|
|
/** |
|
* Get the string(s) from a value |
|
* @param {ASTNode} value The AST node being checked. |
|
* @param {ASTNode} targetValue The AST node being checked. |
|
* @returns {String | String[] | null} The string value, or null if not a string. |
|
*/ |
|
function getStringFromValue(value, targetValue) { |
|
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; |
|
} |
|
const expr = value.expression; |
|
if (expr && expr.type === 'ConditionalExpression') { |
|
const relValues = [expr.consequent.value, expr.alternate.value]; |
|
if ( |
|
targetValue.type === 'JSXExpressionContainer' && |
|
targetValue.expression && |
|
targetValue.expression.type === 'ConditionalExpression' |
|
) { |
|
const targetTestCond = targetValue.expression.test.name; |
|
const relTestCond = value.expression.test.name; |
|
if (targetTestCond === relTestCond) { |
|
const targetBlankIndex = [ |
|
targetValue.expression.consequent.value, |
|
targetValue.expression.alternate.value, |
|
].indexOf('_blank'); |
|
return relValues[targetBlankIndex]; |
|
} |
|
} |
|
return relValues; |
|
} |
|
return expr.value; |
|
} |
|
} |
|
return null; |
|
} |
|
|
|
function hasSecureRel(node, allowReferrer, warnOnSpreadAttributes, spreadAttributeIndex) { |
|
const relIndex = findLastIndex(node.attributes, (attr) => attr.type === 'JSXAttribute' && attr.name.name === 'rel'); |
|
const targetIndex = findLastIndex( |
|
node.attributes, |
|
(attr) => attr.type === 'JSXAttribute' && attr.name.name === 'target' |
|
); |
|
if (relIndex === -1 || (warnOnSpreadAttributes && relIndex < spreadAttributeIndex)) { |
|
return false; |
|
} |
|
|
|
const relAttribute = node.attributes[relIndex]; |
|
const targetAttributeValue = node.attributes[targetIndex]?.value; |
|
const value = getStringFromValue(relAttribute.value, targetAttributeValue); |
|
return [].concat(value).every((item) => { |
|
const tags = typeof item === 'string' ? item.toLowerCase().split(' ') : false; |
|
const noreferrer = tags && tags.indexOf('noreferrer') >= 0; |
|
if (noreferrer) { |
|
return true; |
|
} |
|
const noopener = tags && tags.indexOf('noopener') >= 0; |
|
return allowReferrer && noopener; |
|
}); |
|
} |
|
|
|
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: 'Disallow `target="_blank"` attribute without `rel="noreferrer"`', |
|
category: 'Best Practices', |
|
recommended: true, |
|
}, |
|
|
|
messages, |
|
|
|
schema: [ |
|
{ |
|
type: 'object', |
|
properties: { |
|
allowReferrer: { |
|
type: 'boolean', |
|
}, |
|
allowedHostnames: { |
|
type: 'array', |
|
default: [], |
|
}, |
|
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 allowedHostnames = configuration.allowedHostnames; |
|
const linkComponents = getLinkComponents(context); |
|
const formComponents = 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, allowedHostnames) && |
|
!hostnameIsAllowed(node, linkAttribute, allowedHostnames)) || |
|
(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, |
|
}); |
|
} |
|
} |
|
}, |
|
}; |
|
}, |
|
}; |