| CVE | Vulnerability name | Date | Responsible Security Disclosure by | Vulnerabilities |
|---|---|---|---|---|
|
-
|
SpliceBleed |
2026-07-22 |
GitHub CodeQL (code scanning alert #425) and xet7 (fix)
![]() Automated code scanning flagged the incomplete multi-character sanitization. |
|
Every filename WeKan shows anywhere (card attachment lists, download headers) is passed
through cleanFileName() in imports/lib/fileNameDisplay.js, which
calls stripExploitPatterns() to remove HTML/script/XML/template-injection
markup so the displayed name is always plain, readable text. The original helper applied
each removal exactly once, in a single pass:
// vulnerable — a single pass of chained removals
function stripExploitPatterns(s) {
return String(s == null ? '' : s)
.replace(/<\?[\s\S]*?(\?>|$)/g, '') // <?php … ?>
.replace(/<!\[[\s\S]*?(\]>|$)/g, '') // <![CDATA[ … ]]>
.replace(/\{\{[\s\S]*?\}\}|\$\{[\s\S]*?\}|<%[\s\S]*?%>/g, '') // templates
.replace(/<[^>]*>?/g, '') // any HTML/XML tag
.replace(/(javascript|vbscript|data)\s*:/gi, '') // dangerous schemes
.replace(/[<>]/g, ''); // stray angle brackets
}
A single pass is not enough when the removal of an INNER fragment splices two surviving
OUTER fragments into a brand-new dangerous token. For example the tag-stripping step
/<[^>]*>?/g matches the shortest <…> run, so given
<scr<x>ipt> it removes the inner <x> and leaves
<script> — a live tag the pass has already moved past. The template step
does the same: <scr{{y}}ipt> becomes <script> once the
{{y}} is deleted. This is exactly the weakness GitHub CodeQL reports as
js/incomplete-multi-character-sanitization: "this string may still contain
<script". It is the same class of defect as
EscapeBleed and InputBleed
(incomplete sanitization).
In WeKan this is defence-in-depth rather than a live cross-site-scripting hole: Blaze
{{ }} interpolation HTML-escapes every rendered filename, so a residual
<script> in the string is shown as literal text, never executed. The
single-pass strip was nonetheless genuinely incomplete — the whole point of
stripExploitPatterns() is that the stored/displayed text itself is plain, and a
crafted name could defeat that.
Fix: apply the removals REPEATEDLY until the string stops changing (a fixpoint loop). Each pass only ever deletes text, so the string strictly shrinks and the loop always terminates; any dangerous token that an earlier removal reveals is caught on the next iteration.
// fixed — loop until the string stops changing (a fixpoint)
function stripExploitPatterns(s) {
let out = String(s == null ? '' : s);
let prev;
do {
prev = out;
out = out
.replace(/<\?[\s\S]*?(\?>|$)/g, '')
.replace(/<!\[[\s\S]*?(\]>|$)/g, '')
.replace(/\{\{[\s\S]*?\}\}|\$\{[\s\S]*?\}|<%[\s\S]*?%>/g, '')
.replace(/<[^>]*>?/g, '')
.replace(/(javascript|vbscript|data)\s*:/gi, '')
.replace(/[<>]/g, '');
} while (out !== prev);
return out;
}
After the fix, <scr<x>ipt> and <scr{{y}}ipt> both
reduce to empty/plain text and no <script survives. CodeQL rule
js/incomplete-multi-character-sanitization was resolved by this change, and the
tests/fileNameDisplay.test.cjs suite pins the splice cases as regressions.