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 <github@erik.michelson.eu>
This commit is contained in:
Erik Michelson
2026-06-09 11:25:33 +02:00
committed by Philip Molares
parent fa00e34635
commit 814271dc0b
8 changed files with 125 additions and 7 deletions

28
lib/links.js Normal file
View File

@@ -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: []
})
}
}

View File

@@ -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)

View File

@@ -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."
}

View File

@@ -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"
}
"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."
}

View File

@@ -1,5 +1,11 @@
# Release Notes
## <i class="fa fa-tag"></i> 1.x <i class="fa fa-calendar-o"></i> UNRELEASED
### Enhancements
- Added a warning page when clicking external links
## <i class="fa fa-tag"></i> 1.10.8 <i class="fa fa-calendar-o"></i> 2026-04-15
### Bugfixes

View File

@@ -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)}&note=${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) {

View File

@@ -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)

41
public/views/link.ejs Normal file
View File

@@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="en">
<head>
<%- include('hedgedoc/head') %>
<link rel="stylesheet" href="<%- serverURL %>/css/center.css">
</head>
<body>
<%- include('hedgedoc/header') %>
<div class="container-fluid text-center">
<div class="vertical-center-row">
<h1>
<% if (valid) { %>
<%= __('External link') %>
<% } else { %>
<%= __('Invalid link') %>
<% } %>
</h1>
<p>
<%= __('You\'ve clicked a link that points to an external page.') %>
<br>
<%= __('The content of that page is unrelated to HedgeDoc. Do not enter your HedgeDoc credentials there.') %>
</p>
<% if (valid) { %>
<p><code class="text-break"><%- targetURL %></code></p>
<p><%= __('Do you want to continue?') %></p>
<p>
<a class="btn btn-primary" href="<%- targetURL %>"><%= __('Continue to external page') %></a>
<a class="btn btn-default" href="<%- serverURL %>/<%- noteURL %>"><%= __('Back to your note') %></a>
</p>
<% } else { %>
<p class="text-danger"><strong><%= __('The provided link is not a valid URL.') %></strong></p>
<p>
<a class="btn btn-default" href="<%- serverURL %>/<%- noteURL %>"><%= __('Back to your note') %></a>
</p>
<% } %>
</div>
</div>
</body>
</html>