Skip to content

Instantly share code, notes, and snippets.

@IanVS
Created April 25, 2023 13:27
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save IanVS/669b0365551b771d1c0d613807aa6b74 to your computer and use it in GitHub Desktop.
Save IanVS/669b0365551b771d1c0d613807aa6b74 to your computer and use it in GitHub Desktop.
A modified version of the react/jsx-no-target-blank ESLint rule which adds config for allowed hostnames

jsx-no-target-blank

This custom ESLint rule is based on a rule from eslint-plugin-react, but it adds one additional option to allowlist hostnames in external links.

Motivation

I want to be able to use target="_blank" links for documentation articles (a site I control), and I want to keep referrer for analytics purposes. See jsx-eslint/eslint-plugin-react#2941 (comment).

New option: allowedHostnames

This is an array of hostnames which will not trigger an error from this rule. For example if I own example.com and docs.example.com, I might use:

  'jsx-no-target-blank': ['error', { allowedHostnames: ['docs.example.com'] }],

Using this rule

The simplest way is as a runtime rule. Or you could create your own internal plugin (which is the recommended way).

/**
* @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,
});
}
}
},
};
},
};
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment