mirror of
https://github.com/wekan/wekan.git
synced 2026-11-07 11:02:33 -05:00
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 !
61 lines
2.5 KiB
JavaScript
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]);
|
|
}
|
|
});
|
|
}
|
|
});
|