Files
wekan/models/lib/customFieldStringTemplate.js
Lauri Ojansivu c2905e17ce Stop a custom-field template regex from stopping the server (ReDoS).
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 !
2026-10-02 11:21:50 +03:00

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 };