mirror of
https://github.com/wekan/wekan.git
synced 2026-11-07 11:02:33 -05:00
The ${"regex":...} token of a string-template custom field runs a pattern a
board member writes against a value another member writes. Rule e-mails format
card details on the server, so a pattern like (a+)+$ on 'aaaa...!' stopped the
one event loop for every user. The server now runs the regex in its own V8
context under a 50 ms limit, which V8 enforces inside backtracking, and pattern
and value are capped in length everywhere (the browser too, so one member's
format cannot hang the others'). A stopped regex leaves the token visible. The
negative test fails on any server call of the formatter without the bounded
runner.
Thanks to xet7 !
49 lines
2.6 KiB
JavaScript
49 lines
2.6 KiB
JavaScript
'use strict';
|
|
|
|
// Only these public display values are addressable: never walk arbitrary
|
|
// document properties or evaluate expressions supplied in a format string.
|
|
const CONTEXT_KEYS = new Set(['card.title', 'board.title', 'list.title', 'swimlane.title']);
|
|
const TOKENS = /\$\{(?:[^"{}]|"(?:\\.|[^"\\])*")*\}|%\{[^{}]*\}|\{%[^{}]*\}/g;
|
|
|
|
// ReDoS (2026-10-02): the ${"regex":...} token runs a pattern that a board
|
|
// member writes against a value another member writes. A catastrophic pattern
|
|
// such as (a+)+$ on 'aaaa...!' backtracks for minutes, and on the SERVER - rule
|
|
// e-mails format card details - that stops the one event loop for every user.
|
|
// Pattern and value are capped here, and the server passes a runner that
|
|
// executes the regex under a time limit (server/lib/boundedRegex.js).
|
|
const MAX_TEMPLATE_REGEX = 200;
|
|
const MAX_TEMPLATE_VALUE = 10000;
|
|
const directRegexReplace = (value, regex, flags, replacement) => value.replace(new RegExp(regex, flags), replacement);
|
|
|
|
function formatStringTemplate(rawValue, format, separator = '', context = {}, { regexReplace = directRegexReplace } = {}) {
|
|
if (!Array.isArray(rawValue) || typeof format !== 'string') return '';
|
|
return rawValue.filter(value => typeof value === 'string' && value.trim())
|
|
.map(value => format.replace(TOKENS, token => {
|
|
if (token.startsWith('${')) {
|
|
// Retain the existing JSON regex/flags/replace syntax. A malformed
|
|
// format remains visible rather than becoming the word "undefined".
|
|
try {
|
|
const spec = JSON.parse(token.slice(1));
|
|
if (typeof spec.regex !== 'string' || typeof spec.replace !== 'string' ||
|
|
(spec.flags !== undefined && typeof spec.flags !== 'string')) return token;
|
|
if (spec.regex.length > MAX_TEMPLATE_REGEX || value.length > MAX_TEMPLATE_VALUE) return token;
|
|
return regexReplace(value, spec.regex, spec.flags, spec.replace);
|
|
} catch (error) { return token; }
|
|
}
|
|
const expression = token.slice(2, -1);
|
|
const [name, modifier, ...extra] = expression.toLowerCase().split('|');
|
|
if (extra.length || (modifier !== undefined && modifier !== 'urlencode')) return token;
|
|
let result;
|
|
if (name === 'value') result = value;
|
|
else if (CONTEXT_KEYS.has(name) && Object.hasOwn(context, name)) result = context[name];
|
|
if (typeof result !== 'string') return token;
|
|
if (modifier === 'urlencode') {
|
|
try { return encodeURIComponent(result); }
|
|
catch (error) { return token; }
|
|
}
|
|
return result;
|
|
})).join(separator ?? '');
|
|
}
|
|
|
|
module.exports = { formatStringTemplate, MAX_TEMPLATE_REGEX, MAX_TEMPLATE_VALUE };
|