diff --git a/libraries/eslint-plugin/index.js b/libraries/eslint-plugin/index.js
index 2d41474c51..6119735c05 100644
--- a/libraries/eslint-plugin/index.js
+++ b/libraries/eslint-plugin/index.js
@@ -13,6 +13,7 @@ module.exports = {
'require-script-runner': require('./require-script-runner'),
'require-vi-doMock-valid-path': require('./require-vi-doMock-valid-path'),
'require-loading-label': require('./require-loading-label'),
+ 'require-rel-for-docs-links': require('./require-rel-for-docs-links'),
'require-cio-snake-case-properties': require('./require-cio-snake-case-properties'),
'no-throw-in-callback': require('./no-throw-in-callback'),
'no-consecutive-spaces-in-locales': require('./no-consecutive-spaces-in-locales'),
diff --git a/libraries/eslint-plugin/require-rel-for-docs-links.js b/libraries/eslint-plugin/require-rel-for-docs-links.js
new file mode 100644
index 0000000000..726a977370
--- /dev/null
+++ b/libraries/eslint-plugin/require-rel-for-docs-links.js
@@ -0,0 +1,87 @@
+const DOCS_HOST = 'docs.overleaf.com'
+const REQUIRED_REL_TOKENS = ['noopener', 'noreferrer']
+// Elements that forward href/target/rel straight onto a rendered .
+const LINK_ELEMENT_NAMES = ['a', 'OLButton']
+
+function getStringLiteralValue(node) {
+ if (!node) return undefined
+ if (node.type === 'Literal' && typeof node.value === 'string') {
+ return node.value
+ }
+ if (
+ node.type === 'JSXExpressionContainer' &&
+ node.expression.type === 'Literal' &&
+ typeof node.expression.value === 'string'
+ ) {
+ return node.expression.value
+ }
+ return undefined
+}
+
+module.exports = {
+ meta: {
+ type: 'problem',
+ fixable: 'code',
+ docs: {
+ description:
+ 'Require rel="noopener noreferrer" on links to docs.overleaf.com that open in a new tab, to prevent reverse-tabnabbing',
+ },
+ schema: [],
+ },
+ create(context) {
+ const selector = `JSXOpeningElement[name.name=/^(${LINK_ELEMENT_NAMES.join('|')})$/]`
+
+ return {
+ [selector](node) {
+ const attributes = new Map(
+ node.attributes
+ .filter(attr => attr.type === 'JSXAttribute')
+ .map(attr => [attr.name.name, attr])
+ )
+
+ const hrefAttr = attributes.get('href')
+ const href = getStringLiteralValue(hrefAttr?.value)
+ if (!href || !href.includes(DOCS_HOST)) {
+ return
+ }
+
+ const targetAttr = attributes.get('target')
+ const target = getStringLiteralValue(targetAttr?.value)
+ if (!target || target.toLowerCase() !== '_blank') {
+ return
+ }
+
+ const relAttr = attributes.get('rel')
+ const rel = getStringLiteralValue(relAttr?.value) || ''
+ const relTokens = rel.toLowerCase().split(/\s+/).filter(Boolean)
+ const missingTokens = REQUIRED_REL_TOKENS.filter(
+ token => !relTokens.includes(token)
+ )
+
+ if (missingTokens.length === 0) {
+ return
+ }
+
+ context.report({
+ node: relAttr || node,
+ message:
+ 'Links to docs.overleaf.com with target="_blank" must have rel="noopener noreferrer" to prevent reverse-tabnabbing.',
+ fix(fixer) {
+ const newRel = Array.from(
+ new Set([...relTokens, ...REQUIRED_REL_TOKENS])
+ ).join(' ')
+
+ if (relAttr) {
+ if (!relAttr.value) {
+ return fixer.replaceText(relAttr, `rel="${newRel}"`)
+ }
+ return fixer.replaceText(relAttr.value, `"${newRel}"`)
+ }
+
+ return fixer.insertTextAfter(targetAttr, ` rel="${newRel}"`)
+ },
+ })
+ },
+ }
+ },
+}
diff --git a/libraries/eslint-plugin/rules.test.js b/libraries/eslint-plugin/rules.test.js
index 8d9c41470e..02178362be 100644
--- a/libraries/eslint-plugin/rules.test.js
+++ b/libraries/eslint-plugin/rules.test.js
@@ -8,6 +8,7 @@ const shouldUnescapeTrans = require('./should-unescape-trans')
const noGeneratedEditorThemes = require('./no-generated-editor-themes')
const viDoMockValidPath = require('./require-vi-doMock-valid-path')
const requireCioSnakeCaseProperties = require('./require-cio-snake-case-properties')
+const requireRelForDocsLinks = require('./require-rel-for-docs-links')
const noConsecutiveSpacesInLocales = require('./no-consecutive-spaces-in-locales')
const noStraightApostrophesInLocales = require('./no-straight-apostrophes-in-locales')
const frenchTypographyInLocales = require('./french-typography-in-locales')
@@ -340,6 +341,87 @@ ruleTester.run('no-throw-in-callback', noThrowInCallback, {
],
})
+ruleTester.run('require-rel-for-docs-links', requireRelForDocsLinks, {
+ valid: [
+ {
+ code: `x`,
+ },
+ {
+ code: `x`,
+ },
+ { code: `x` },
+ { code: `x` },
+ { code: `x` },
+ {
+ code: `
{t('this_action_cannot_be_reversed')} - + {t('learn_more_about_managed_users')}
diff --git a/services/web/frontend/js/features/ide-react/components/modals/out-of-sync-modal.tsx b/services/web/frontend/js/features/ide-react/components/modals/out-of-sync-modal.tsx index bceb0d5f6a..5909f5eef2 100644 --- a/services/web/frontend/js/features/ide-react/components/modals/out-of-sync-modal.tsx +++ b/services/web/frontend/js/features/ide-react/components/modals/out-of-sync-modal.tsx @@ -50,7 +50,7 @@ function OutOfSyncModal({ editorContent, show, onHide }: OutOfSyncModalProps) { , ]} /> diff --git a/services/web/frontend/js/features/monthly-texlive/rolling-compile-image-changed-alert.tsx b/services/web/frontend/js/features/monthly-texlive/rolling-compile-image-changed-alert.tsx index e04632e009..6b5e6bbdd8 100644 --- a/services/web/frontend/js/features/monthly-texlive/rolling-compile-image-changed-alert.tsx +++ b/services/web/frontend/js/features/monthly-texlive/rolling-compile-image-changed-alert.tsx @@ -31,7 +31,7 @@ const RollingCompileImageChangedAlert = () => { href="https://docs.overleaf.com/troubleshooting-and-support/tex-live#How_do_I_change_a_project%E2%80%99s_TeX_Live_version" target="_blank" key="getting-started-link" - rel="noopener" + rel="noopener noreferrer" />, ]} /> diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-preview-error-boundary-fallback.tsx b/services/web/frontend/js/features/pdf-preview/components/pdf-preview-error-boundary-fallback.tsx index 8e374deab3..cea207290b 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-preview-error-boundary-fallback.tsx +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-preview-error-boundary-fallback.tsx @@ -14,8 +14,9 @@ function PdfPreviewErrorBoundaryFallback({ components={[ // eslint-disable-next-line jsx-a11y/anchor-has-content , ]} diff --git a/services/web/frontend/js/features/pdf-preview/components/pdf-preview-error.tsx b/services/web/frontend/js/features/pdf-preview/components/pdf-preview-error.tsx index cb6be2f36f..d73eb8ec7f 100644 --- a/services/web/frontend/js/features/pdf-preview/components/pdf-preview-error.tsx +++ b/services/web/frontend/js/features/pdf-preview/components/pdf-preview-error.tsx @@ -46,8 +46,9 @@ function PdfPreviewError({ components={[ // eslint-disable-next-line jsx-a11y/anchor-has-content , ]} @@ -70,8 +71,9 @@ function PdfPreviewError({ components={[ // eslint-disable-next-line jsx-a11y/anchor-has-content , ]} @@ -93,8 +95,9 @@ function PdfPreviewError({,
/* eslint-disable-next-line jsx-a11y/anchor-has-content */
,
]}
@@ -224,8 +227,9 @@ function PdfPreviewError({
,
// eslint-disable-next-line jsx-a11y/anchor-has-content
,
// eslint-disable-next-line jsx-a11y/anchor-has-content
@@ -299,7 +303,7 @@ function TimedOutLogEntry() {
components={[
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
{
sendMB('paywall-info-click', {
'paywall-type': 'compile-timeout',
@@ -317,7 +321,7 @@ function TimedOutLogEntry() {
components={[
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
{
sendMB('paywall-info-click', {
'paywall-type': 'compile-timeout',
@@ -352,7 +356,7 @@ function TimedOutLogEntry() {
components={[
// eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key
{
sendMB('paywall-info-click', {
'paywall-type': 'compile-timeout',
diff --git a/services/web/frontend/js/features/pdf-preview/components/synctex-toasts.tsx b/services/web/frontend/js/features/pdf-preview/components/synctex-toasts.tsx
index c45d27b92f..ad0a7f53b9 100644
--- a/services/web/frontend/js/features/pdf-preview/components/synctex-toasts.tsx
+++ b/services/web/frontend/js/features/pdf-preview/components/synctex-toasts.tsx
@@ -10,8 +10,9 @@ export const SynctexFileErrorToast = () => {
{t('synctex_failed')}
{t('archiving_projects_wont_affect_collaborators')}{' '} {t('find_out_more_nt')} diff --git a/services/web/frontend/js/features/project-list/components/modals/trash-project-modal.tsx b/services/web/frontend/js/features/project-list/components/modals/trash-project-modal.tsx index 7c4837f86d..8b43af1f0d 100644 --- a/services/web/frontend/js/features/project-list/components/modals/trash-project-modal.tsx +++ b/services/web/frontend/js/features/project-list/components/modals/trash-project-modal.tsx @@ -43,9 +43,9 @@ function TrashProjectModal({
{t('trashing_projects_wont_affect_collaborators')}{' '}
{t('find_out_more_nt')}
diff --git a/services/web/frontend/js/features/project-list/components/notifications/groups/affiliation/reconfirm-affiliation.tsx b/services/web/frontend/js/features/project-list/components/notifications/groups/affiliation/reconfirm-affiliation.tsx
index dc5e908c0b..d7cc9bb602 100644
--- a/services/web/frontend/js/features/project-list/components/notifications/groups/affiliation/reconfirm-affiliation.tsx
+++ b/services/web/frontend/js/features/project-list/components/notifications/groups/affiliation/reconfirm-affiliation.tsx
@@ -46,8 +46,9 @@ function ReconfirmAffiliation({
/>
{t('learn_more_about_email_reconfirmation')}
diff --git a/services/web/frontend/js/features/project-list/components/notifications/groups/common.tsx b/services/web/frontend/js/features/project-list/components/notifications/groups/common.tsx
index d4f6a3f368..bf22d7c60b 100644
--- a/services/web/frontend/js/features/project-list/components/notifications/groups/common.tsx
+++ b/services/web/frontend/js/features/project-list/components/notifications/groups/common.tsx
@@ -152,7 +152,7 @@ function CommonNotification({ notification }: CommonNotificationProps) {
{t('find_out_more_nt')}
@@ -234,8 +234,9 @@ function CommonNotification({ notification }: CommonNotificationProps) {
components={[]} // eslint-disable-line react/jsx-key
/>{' '}
{t('learn_more')}
@@ -265,8 +266,9 @@ function CommonNotification({ notification }: CommonNotificationProps) {
t('confirm_affiliation_to_relink_dropbox')
)}{' '}
{t('learn_more')}
diff --git a/services/web/frontend/js/features/project-list/components/notifications/groups/institution.tsx b/services/web/frontend/js/features/project-list/components/notifications/groups/institution.tsx
index 87b0433270..117e17d6f5 100644
--- a/services/web/frontend/js/features/project-list/components/notifications/groups/institution.tsx
+++ b/services/web/frontend/js/features/project-list/components/notifications/groups/institution.tsx
@@ -55,8 +55,9 @@ function Institution() {
tOptions={{ interpolation: { escapeValue: true } }}
/>{' '}
{t('learn_more')}
@@ -122,8 +123,9 @@ function Institution() {
/>
{t('understand_managed_user_accounts')}
@@ -175,8 +177,9 @@ function Institution() {
action={
{description}{' '} - + {t('learn_more_about', { appName: title })}
diff --git a/services/web/frontend/js/features/settings/components/managed-account-alert.tsx b/services/web/frontend/js/features/settings/components/managed-account-alert.tsx index 02afa8d4c8..c4ea786b2e 100644 --- a/services/web/frontend/js/features/settings/components/managed-account-alert.tsx +++ b/services/web/frontend/js/features/settings/components/managed-account-alert.tsx @@ -30,8 +30,9 @@ export default function ManagedAccountAlert() { components={[ // eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key , ]} /> diff --git a/services/web/frontend/js/features/settings/components/password-section.tsx b/services/web/frontend/js/features/settings/components/password-section.tsx index 9128119dc3..7dacfc468a 100644 --- a/services/web/frontend/js/features/settings/components/password-section.tsx +++ b/services/web/frontend/js/features/settings/components/password-section.tsx @@ -42,7 +42,7 @@ function CanOnlyLogInThroughSSO() { i18nKey="you_cant_add_or_change_password_due_to_sso" components={[ // eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content - , + , ]} /> diff --git a/services/web/frontend/js/features/share-project-modal/components/link-sharing.tsx b/services/web/frontend/js/features/share-project-modal/components/link-sharing.tsx index 5c5a619156..3735765bf7 100644 --- a/services/web/frontend/js/features/share-project-modal/components/link-sharing.tsx +++ b/services/web/frontend/js/features/share-project-modal/components/link-sharing.tsx @@ -318,9 +318,9 @@ function LinkSharingInfo() { description={t('learn_more_about_link_sharing')} >,
// eslint-disable-next-line react/jsx-key
- ,
// eslint-disable-next-line react/jsx-key
- ,
// eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content
,
]}
/>
diff --git a/services/web/frontend/js/features/subscription/components/dashboard/premium-features-link.tsx b/services/web/frontend/js/features/subscription/components/dashboard/premium-features-link.tsx
index 14d9c8682a..104c8ea518 100644
--- a/services/web/frontend/js/features/subscription/components/dashboard/premium-features-link.tsx
+++ b/services/web/frontend/js/features/subscription/components/dashboard/premium-features-link.tsx
@@ -19,7 +19,7 @@ function PremiumFeaturesLink({
i18nKey="get_most_subscription_by_checking_ai_writefull"
components={[
// eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content
- ,
+ ,
// eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content
,
+ ,
// eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content
- ,
+ ,
// eslint-disable-next-line react/jsx-key, jsx-a11y/anchor-has-content
,
+ ,
]}
/>
diff --git a/services/web/frontend/js/features/subscription/components/group-invite/managed-user-cannot-join.tsx b/services/web/frontend/js/features/subscription/components/group-invite/managed-user-cannot-join.tsx
index 03e3a757ba..ca844ac648 100644
--- a/services/web/frontend/js/features/subscription/components/group-invite/managed-user-cannot-join.tsx
+++ b/services/web/frontend/js/features/subscription/components/group-invite/managed-user-cannot-join.tsx
@@ -21,7 +21,7 @@ export default function ManagedUserCannotJoin() {
tOptions={{ interpolation: { escapeValue: true } }}
components={[
/* eslint-disable-next-line jsx-a11y/anchor-has-content, react/jsx-key */
- ,
+ ,
]}
/>
diff --git a/services/web/frontend/js/shared/components/ai-consent-prompt-message.tsx b/services/web/frontend/js/shared/components/ai-consent-prompt-message.tsx
index 50ecc4f4b2..f72b00da79 100644
--- a/services/web/frontend/js/shared/components/ai-consent-prompt-message.tsx
+++ b/services/web/frontend/js/shared/components/ai-consent-prompt-message.tsx
@@ -39,7 +39,7 @@ export const AiConsentPromptMessageBody = ({
our docs
diff --git a/services/web/frontend/js/shared/components/deprecated-browser.tsx b/services/web/frontend/js/shared/components/deprecated-browser.tsx
index 38a9efe136..d614b717a3 100644
--- a/services/web/frontend/js/shared/components/deprecated-browser.tsx
+++ b/services/web/frontend/js/shared/components/deprecated-browser.tsx
@@ -22,7 +22,7 @@ export const DeprecatedBrowser: FC = () => {
i18nKey="to_continue_using_upgrade_or_change_your_browser"
components={[
// eslint-disable-next-line jsx-a11y/anchor-has-content,react/jsx-key
- ,
+ ,
]}
/>
}
diff --git a/services/web/test/frontend/features/group-management/components/domain-capture.spec.tsx b/services/web/test/frontend/features/group-management/components/domain-capture.spec.tsx
index 8ae9716235..09d0286cb2 100644
--- a/services/web/test/frontend/features/group-management/components/domain-capture.spec.tsx
+++ b/services/web/test/frontend/features/group-management/components/domain-capture.spec.tsx
@@ -135,7 +135,7 @@ describe('