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: ['Link'],
+ invalid: [
+ {
+ code: 'Link',
+ output: 'Link',
+ errors: [{ messageId: 'missingRel' }],
+ },
+ ],
+});