Files
wekan/server/lib/handlerLinkCanary.js
Lauri Ojansivu b4e5f90afb Card links never launch an OS handler with a code-execution history.
Custom URL schemes in card text link only when Admin Panel -> Features ->
URL lists them, and never javascript:, data: or another scheme that runs
code in the page. A listed operating-system handler was linked, though, and
some have a public code-execution history: ms-msdt (Follina,
CVE-2022-30190), search-ms and search, ms-officecmd, ms-appinstaller,
ms-cxh and ms-cxh-full, and telnet - the last step of the 2026 Edge full
chain, where Windows ran telnet.exe from a user-writable PATH directory.
An administrator listing one would let any board member plant a link that
runs something on whoever clicks it.

- They are never linked, listed or not, by the app's parser and by the
  markdown package's copy of the rule (the two lists must be identical),
  and a click on one does nothing should such a link reach the page.
- A card title or description, comment, checklist or checklist item that
  ADDS a link to one is shown in Admin Panel -> Problems (HandlerLink,
  CWE-939), with who wrote it; the text is saved as written and the writer
  is told nothing. The word in prose, or a link the text already had, is
  not recorded.
- docs/Features/Admin-Panel/Settings/Visibility.md lists both kinds of
  never-linked scheme, and says what a listed file: does on Windows (an SMB
  link outside the network sends the clicker's NTLM hash).

Tests: tests/urlSchemeAllowlist.test.cjs (positive and negative, through
the real DOMPurify, both sanitizer passes and the detector; fails on the
old code), and custom-url-schemes.e2e.js, which passes in Chromium and
WebKit: listed handlers do not link, a description saved with one through
the editor is recorded for its writer, and a kept link or a comment that
only mentions the word is not. Firefox does not start in this
environment.

Thanks to xet7 !
2026-10-30 01:19:16 +02:00

61 lines
2.5 KiB
JavaScript

import { Meteor } from 'meteor/meteor';
import Cards from '/models/cards';
import CardComments from '/models/cardComments';
import Checklists from '/models/checklists';
import ChecklistItems from '/models/checklistItems';
import { tripCanary } from '/server/lib/canary';
const { handlerLinkSchemes } = require('/models/lib/urlSchemeAllowlist');
// A link in card text to an operating-system handler with a code-execution
// history - ms-msdt:, search-ms:, telnet: and the rest of OS_HANDLERS in
// models/lib/urlSchemeAllowlist.js - is never made clickable, whatever Admin
// Panel -> Features -> URL lists. So writing one is never using the feature:
// it is an attempt on whoever would click it, and Admin Panel -> Problems
// shows who made it (canary 'sanitize.handler-link').
//
// SILENT, like every canary: the text is saved exactly as written and the
// writer is told nothing; the link stays plain text when it is shown. Only a
// link the write ADDS counts, so editing an old card that already had one,
// moving it or archiving it records nothing.
// Which fields of which collection hold text that is shown as markdown.
const WATCHED = [
['card', () => Cards, ['title', 'description']],
['comment', () => CardComments, ['text']],
['checklist', () => Checklists, ['title']],
['checklist item', () => ChecklistItems, ['title']],
];
// The OS-handler schemes `after` links to that `before` did not.
export function addedHandlerLinks(before, after) {
const had = new Set(handlerLinkSchemes(before));
return handlerLinkSchemes(after).filter(scheme => !had.has(scheme));
}
function watch(userId, where, field, before, after) {
try {
const added = addedHandlerLinks(before, after);
if (added.length) {
tripCanary('sanitize.handler-link', { userId, detail: `${where} ${field}: ${added.join(', ')}` });
}
} catch (e) { /* logging must never break the guard */ }
}
Meteor.startup(() => {
// Resolved at startup, not at import: these models import each other.
for (const [where, collectionOf, fields] of WATCHED) {
const collection = collectionOf();
if (!collection || !collection.before) continue;
collection.before.insert((userId, doc) => {
for (const field of fields) watch(userId, where, field, '', doc && doc[field]);
});
collection.before.update((userId, doc, fieldNames, modifier) => {
const set = (modifier && modifier.$set) || {};
for (const field of fields) {
if (Object.prototype.hasOwnProperty.call(set, field)) watch(userId, where, field, doc && doc[field], set[field]);
}
});
}
});