From 814271dc0bd0e79bcd8660b9111d01fc002bce68 Mon Sep 17 00:00:00 2001 From: Erik Michelson Date: Tue, 9 Jun 2026 11:25:33 +0200 Subject: [PATCH] feat(editor): add external link warning page A malicious user could modify a valid looking link (for example of the editor UI) to point to a malicous site, by using overlays and CSS. Since CSS should stay enabled, we need to make the user aware of possible risks when leaving the HedgeDoc instance, in order to protect them from credential-theft. This commit adds a new interstitial page for external links, that shows the target URL and asks the user, whether they really want to continue. Signed-off-by: Erik Michelson --- lib/links.js | 28 ++++++++++++++++++++++++ lib/web/baseRouter.js | 4 ++++ locales/de.json | 10 ++++++++- locales/en.json | 12 +++++++++-- public/docs/release-notes.md | 6 ++++++ public/js/extra.js | 29 +++++++++++++++++++++---- public/js/index.js | 2 ++ public/views/link.ejs | 41 ++++++++++++++++++++++++++++++++++++ 8 files changed, 125 insertions(+), 7 deletions(-) create mode 100644 lib/links.js create mode 100644 public/views/link.ejs diff --git a/lib/links.js b/lib/links.js new file mode 100644 index 00000000..0f6dffd2 --- /dev/null +++ b/lib/links.js @@ -0,0 +1,28 @@ +module.exports = { + serveLinkWarningPage: function (req, res) { + let targetURL = typeof req.query.url === 'string' ? req.query.url : '' + let noteURL = typeof req.query.note === 'string' ? req.query.note : '' + if (noteURL !== '' && !/^[\w-]+$/.test(noteURL)) { + noteURL = '' + } + let valid = false + try { + targetURL = decodeURIComponent(targetURL) + const parsed = new URL(targetURL) + valid = ['http:', 'https:'].includes(parsed.protocol) + targetURL = parsed.href + } catch (err) { + valid = false + } + res.set({ + 'Cache-Control': 'no-store' + }) + res.render('link.ejs', { + title: 'External link', + valid, + noteURL, + targetURL, + opengraph: [] + }) + } +} diff --git a/lib/web/baseRouter.js b/lib/web/baseRouter.js index df5e2777..9dfeffc7 100644 --- a/lib/web/baseRouter.js +++ b/lib/web/baseRouter.js @@ -8,6 +8,8 @@ const baseRouter = module.exports = Router() const errors = require('../errors') +const links = require('../links') + // get index baseRouter.get('/', response.showIndex) // get 403 forbidden @@ -22,3 +24,5 @@ baseRouter.get('/404', function (req, res) { baseRouter.get('/500', function (req, res) { errors.errorInternalError(res) }) +// get external link warning page +baseRouter.get('/_link', links.serveLinkWarningPage) diff --git a/locales/de.json b/locales/de.json index 6678d1ca..b587432f 100644 --- a/locales/de.json +++ b/locales/de.json @@ -126,5 +126,13 @@ "Protected - Only owner can edit (forbid guests)": "Geschützt - Nur Besitzer kann bearbeiten (Gäste verboten)", "Private - Only owner can view & edit": "Privat - Nur Besitzer kann anschauen & bearbeiten", "changed": "verändert", - "created": "erstellt" + "created": "erstellt", + "External link": "Externer Link", + "You've clicked a link that points to an external page.": "Es wurde ein Link zu einer externen Seite angeklickt.", + "The content of that page is unrelated to HedgeDoc. Do not enter your HedgeDoc credentials there.": "Der Inhalt dieser Seite ist unabhängig von HedgeDoc. Dort keine HedgeDoc-Zugangsdaten eingeben.", + "Do you want to continue?": "Fortfahren?", + "Continue to external page": "Weiter zur externen Seite", + "Back to your note": "Zurück zur Notiz", + "Invalid link": "Ungültiger Link", + "The provided link is not a valid URL.": "Die angegebene URL ist nicht gültig." } \ No newline at end of file diff --git a/locales/en.json b/locales/en.json index 3d30b66e..faecada7 100644 --- a/locales/en.json +++ b/locales/en.json @@ -126,5 +126,13 @@ "Protected - Only owner can edit (forbid guests)": "Protected - Only owner can edit (forbid guests)", "Private - Only owner can view & edit": "Private - Only owner can view & edit", "changed": "changed", - "created": "created" -} \ No newline at end of file + "created": "created", + "You've clicked a link that points to an external page.": "You've clicked a link that points to an external page.", + "External link": "External link", + "Invalid link": "Invalid link", + "Do you want to continue?": "Do you want to continue?", + "Continue to external page": "Continue to external page", + "Back to your note": "Back to your note", + "The provided link is not a valid URL.": "The provided link is not a valid URL.", + "The content of that page is unrelated to HedgeDoc. Do not enter your HedgeDoc credentials there.": "The content of that page is unrelated to HedgeDoc. Do not enter your HedgeDoc credentials there." +} diff --git a/public/docs/release-notes.md b/public/docs/release-notes.md index 06f7e1d4..f1f241f6 100644 --- a/public/docs/release-notes.md +++ b/public/docs/release-notes.md @@ -1,5 +1,11 @@ # Release Notes +## 1.x UNRELEASED + +### Enhancements + +- Added a warning page when clicking external links + ## 1.10.8 2026-04-15 ### Bugfixes diff --git a/public/js/extra.js b/public/js/extra.js index fc2405cd..cba0bfeb 100644 --- a/public/js/extra.js +++ b/public/js/extra.js @@ -557,10 +557,6 @@ export function postProcess (code) { html = html.replace(/@import url\(([^)]*)\);?/gi, '') $(value).html(html) }) - // link should open in new window or tab - // also add noopener to prevent clickjacking - // See details: https://mathiasbynens.github.io/rel-noopener/ - result.find('a:not([href^="#"]):not([target])').attr('target', '_blank').attr('rel', 'noopener') // If it's hashtag link then make it base uri independent result.find('a[href^="#"]').each((index, linkTag) => { @@ -568,6 +564,7 @@ export function postProcess (code) { currentLocation.hash = linkTag.hash linkTag.href = currentLocation.toString() }) + rewriteExternalLinks(result) // update continue line numbers const linenumberdivs = result.find('.gutter.linenumber').toArray() @@ -593,6 +590,30 @@ export function postProcess (code) { } window.postProcess = postProcess +// rewrite external links to go through the /_link warning page +export function rewriteExternalLinks (view) { + view.find('a[href]').each((index, linkTag) => { + const anchor = $(linkTag) + const href = anchor.attr('href') + if (!href || href.startsWith('#')) { + return + } + let parsed + try { + parsed = new URL(href) + } catch (err) { + return + } + // only rewrite links that have an absolute http(s) URL on a different origin + if (!['http:', 'https:'].includes(parsed.protocol) || parsed.origin === window.location.origin) { + return + } + const noteURL = window.location.pathname.split('/').pop() + anchor.attr('href', `${window.location.origin}/_link?url=${encodeURIComponent(parsed.href)}¬e=${noteURL}`) + }) +} +window.rewriteExternalLinks = rewriteExternalLinks + const domevents = Object.getOwnPropertyNames(document).concat(Object.getOwnPropertyNames(Object.getPrototypeOf(Object.getPrototypeOf(document)))).concat(Object.getOwnPropertyNames(Object.getPrototypeOf(window))).filter(function (i) { return !i.indexOf('on') && (document[i] === null || typeof document[i] === 'function') }).filter(function (elem, pos, self) { diff --git a/public/js/index.js b/public/js/index.js index 9e37e1fd..dd5d66ca 100644 --- a/public/js/index.js +++ b/public/js/index.js @@ -55,6 +55,7 @@ import { renderTOC, renderTags, renderTitle, + rewriteExternalLinks, scrollToHash, smoothHashScroll, updateLastChange, @@ -3498,6 +3499,7 @@ function updateViewInner () { } removeDOMEvents(ui.area.markdown) finishView(ui.area.markdown) + rewriteExternalLinks(ui.area.markdown) autoLinkify(ui.area.markdown) deduplicatedHeaderId(ui.area.markdown) renderTOC(ui.area.markdown) diff --git a/public/views/link.ejs b/public/views/link.ejs new file mode 100644 index 00000000..62b4e04a --- /dev/null +++ b/public/views/link.ejs @@ -0,0 +1,41 @@ + + + + + <%- include('hedgedoc/head') %> + + + + + <%- include('hedgedoc/header') %> +
+
+

+ <% if (valid) { %> + <%= __('External link') %> + <% } else { %> + <%= __('Invalid link') %> + <% } %> +

+

+ <%= __('You\'ve clicked a link that points to an external page.') %> +
+ <%= __('The content of that page is unrelated to HedgeDoc. Do not enter your HedgeDoc credentials there.') %> +

+ <% if (valid) { %> +

<%- targetURL %>

+

<%= __('Do you want to continue?') %>

+

+ <%= __('Continue to external page') %> + <%= __('Back to your note') %> +

+ <% } else { %> +

<%= __('The provided link is not a valid URL.') %>

+

+ <%= __('Back to your note') %> +

+ <% } %> +
+
+ +