From 133830f80553e6d28700d8e9b082f5c77a591317 Mon Sep 17 00:00:00 2001 From: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Date: Tue, 17 Feb 2026 15:45:55 -0500 Subject: [PATCH] Extract rule: template-link-rel-noopener --- README.md | 6 ++ docs/rules/template-link-rel-noopener.md | 33 +++++++++++ lib/rules/template-link-rel-noopener.js | 57 +++++++++++++++++++ tests/lib/rules/template-link-rel-noopener.js | 17 ++++++ 4 files changed, 113 insertions(+) create mode 100644 docs/rules/template-link-rel-noopener.md create mode 100644 lib/rules/template-link-rel-noopener.js create mode 100644 tests/lib/rules/template-link-rel-noopener.js diff --git a/README.md b/README.md index 1997fcd7ea..b81fbfc4dc 100644 --- a/README.md +++ b/README.md @@ -304,6 +304,12 @@ rules in templates can be disabled with eslint directives with mustache or html | [route-path-style](docs/rules/route-path-style.md) | enforce usage of kebab-case (instead of snake_case or camelCase) in route paths | | | 💡 | | [routes-segments-snake-case](docs/rules/routes-segments-snake-case.md) | enforce usage of snake_cased dynamic segments in routes | ✅ | | | +### Security + +| Name                       | Description | 💼 | 🔧 | 💡 | +| :--------------------------------------------------------------------- | :-------------------------------------------------------------- | :- | :- | :- | +| [template-link-rel-noopener](docs/rules/template-link-rel-noopener.md) | require rel="noopener noreferrer" on links with target="_blank" | | 🔧 | | + ### Services | Name                                      | Description | 💼 | 🔧 | 💡 | diff --git a/docs/rules/template-link-rel-noopener.md b/docs/rules/template-link-rel-noopener.md new file mode 100644 index 0000000000..9feb4eff8d --- /dev/null +++ b/docs/rules/template-link-rel-noopener.md @@ -0,0 +1,33 @@ +# ember/template-link-rel-noopener + +🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix). + + + +When you want to link to an external page from your app, it is very common to use `` +to make the browser open this link in a new tab. + +However, this practice has [performance problems](https://jakearchibald.com/2016/performance-benefits-of-rel-noopener/) +and also opens a door to some security attacks because the opened page can redirect the opener app +to a malicious clone to perform phishing on your users. + +Adding `rel="noopener noreferrer"` closes that door and avoids javascript in the opened tab to block the main +thread in the opener. Also note that Firefox versions prior 52 do not implement `noopener`, so `rel="noreferrer"` should be used instead ([see Firefox issue](https://bugzilla.mozilla.org/show_bug.cgi?id=1222516)). + +## Examples + +This rule **forbids** the following: + +```hbs +I'm a bait +``` + +This rule **allows** the following: + +```hbs +I'm a bait +``` + +## References + +- [Link type "noreferrer"](https://html.spec.whatwg.org/multipage/semantics.html#link-type-noreferrer) spec diff --git a/lib/rules/template-link-rel-noopener.js b/lib/rules/template-link-rel-noopener.js new file mode 100644 index 0000000000..e44475d469 --- /dev/null +++ b/lib/rules/template-link-rel-noopener.js @@ -0,0 +1,57 @@ +/** @type {import('eslint').Rule.RuleModule} */ +module.exports = { + meta: { + type: 'problem', + docs: { + description: 'require rel="noopener noreferrer" on links with target="_blank"', + category: 'Security', + strictGjs: true, + strictGts: true, + url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-link-rel-noopener.md', + }, + fixable: 'code', + schema: [], + messages: { + missingRel: 'links with target="_blank" must have rel="noopener noreferrer"', + }, + }, + create(context) { + return { + GlimmerElementNode(node) { + if (node.tag !== 'a') { + return; + } + + const targetAttr = node.attributes?.find((a) => a.name === 'target'); + if (!targetAttr?.value || targetAttr.value.type !== 'GlimmerTextNode') { + return; + } + if (targetAttr.value.chars !== '_blank') { + return; + } + + const relAttr = node.attributes?.find((a) => a.name === 'rel'); + const hasProperRel = + relAttr?.value?.type === 'GlimmerTextNode' && + /noopener/.test(relAttr.value.chars) && + /noreferrer/.test(relAttr.value.chars); + + if (!hasProperRel) { + context.report({ + node: targetAttr, + messageId: 'missingRel', + fix(fixer) { + const sourceCode = context.sourceCode; + const openTag = sourceCode.getText(node).match(/^]*/)[0]; + const insertPos = node.range[0] + openTag.length; + return fixer.insertTextBeforeRange( + [insertPos, insertPos], + ' rel="noopener noreferrer"' + ); + }, + }); + } + }, + }; + }, +}; diff --git a/tests/lib/rules/template-link-rel-noopener.js b/tests/lib/rules/template-link-rel-noopener.js new file mode 100644 index 0000000000..178fd6417f --- /dev/null +++ b/tests/lib/rules/template-link-rel-noopener.js @@ -0,0 +1,17 @@ +const rule = require('../../../lib/rules/template-link-rel-noopener'); +const RuleTester = require('eslint').RuleTester; + +const ruleTester = new RuleTester({ + parser: require.resolve('ember-eslint-parser'), + parserOptions: { ecmaVersion: 2022, sourceType: 'module' }, +}); +ruleTester.run('template-link-rel-noopener', rule, { + valid: [''], + invalid: [ + { + code: '', + output: '', + errors: [{ messageId: 'missingRel' }], + }, + ], +});