Wekan
Open source kanban board application built with Meteor
Alternative to: trello
v9.90
2026-07-14v9.90 2026-07-15 WeKan ® release
This release fixes the following CRITICAL SECURITY ISSUE of MimeBleed:
- MimeBleed:
file-upload MIME-type validation bypass → stored XSS on deployments without the
filebinary (GHSA-jhph-whx8-wq6p, CWE-434 Unrestricted Upload of File with Dangerous Type). WeKan’s upload validation (models/fileValidation.js) detects a file’s real MIME type by running the Unixfilecommand. On minimal Docker/Alpine images wherefileis not installed,detectMimeFromFile()silently returnedundefinedand the code fell back to the client-suppliedfileObj.type. An authenticated board member (withWITH_API=true) could therefore upload an HTML file containing JavaScript while settingfileType: "image/png": the spoofed type is not on the dangerous-MIME deny-list, so the dangerous-content scan was skipped and the file was stored, yielding stored XSS served under the WeKan origin (session theft / actions as the victim, including admin).- Fixed so the client-supplied type can never gate the safety scan: when
content-based detection via
fileis unavailable, WeKan now falls back to a dependency-free JS content sniff (looksLikeDangerousMarkup()) that inspects the real bytes for HTML/SVG/XML/<script>signatures and forces the dangerous-content scan regardless of the claimed MIME — so a spoofedimage/pngthat is actually HTML+JS is caught and rejected. The sniff only matches definitive markup signatures, so genuine binary uploads (real PNG/JPEG/PDF, including large ones) are unaffected. WeKan also now logs a one-time warning when thefilecommand is missing (previously the failure was silent). A regression test (server/lib/tests/fileValidationBypass.security.tests.js) covers both the spoofed dangerous uploads and safe binaries. CVSS:3.1 8.3 High (AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N). - Affected container deployments without the
filebinary andWITH_API=true; fixed at the upcoming WeKan release. Reported by HNUfwj. Also install thefilepackage for full content-based MIME detection (WeKan’s official images already do). Thanks to HNUfwj and xet7.
- Fixed so the client-supplied type can never gate the safety scan: when
content-based detection via
and adds the following new features:
-
Admin Panel / Features / Security: import/export privacy controls. Six new optional toggles govern how boards and user data cross the WeKan boundary:
- Disable all import / Disable all export — master switches that turn off every import / export feature (WeKan JSON, Trello, CSV/Excel, Jira, Kanboard, NextCloud Deck, OpenProject, GitHub/GitLab/Gitea/Forgejo, board clone, and the single-attachment export). The server rejects any such request, and the import / export menu options are hidden in the UI.
- Disable import avatars / Disable export avatars — never carry avatars
(profile pictures) into / out of WeKan. Import covers WeKan JSON import, Trello
import and external identity-provider avatar sync on login (LDAP, OIDC/OAuth2),
gated at the single
localizeAvatarFromBufferchoke point; export covers WeKan JSON and CSV export. - Anonymize import users / Anonymize export users — replace every user’s
username, full name and initials with counter placeholders (user1, user2, …),
drop their avatar, and rewrite
@usernamementions plus the requested-by / assigned-by fields inside card and comment content, so the imported board / exported file carries no real user identity. The placeholder word “user” follows the language of the person importing/exporting (e.g. “käyttäjä1” in Finnish). Both export paths are covered — the in-memorybuild()and the streamingbuildStream()(which does a lightweight id-only pre-scan so mentions streamed before the users array still resolve to matching labels).
All six default to off (current behaviour). Enforcement lives server-side in the Exporter, the WekanCreator import path and the avatar localizer, so it cannot be bypassed from the client. Thanks to xet7.
-
Admin Panel / Features / Security: new optional “Always show all code as plain text” toggle. When enabled, rich text is never rendered as markdown or HTML — the entire source is shown as escaped plain text in every rich text field (board and card titles, descriptions, comments, checklists, etc.), so hidden content is always revealed: HTML comments (
<!-- -->), the target URL inside a markdown link, JavaScript and any other code. All code is always visible, not clickable, and not running. This extends the existing invisiblebleed protection (which already showed raw source for description-less markdown links) to all content. Thewekan-markdownpackage cannot import app code, so the setting is bridged to the markdown renderer through a reactive flag on the exportedMarkdownobject, kept in sync by the rich text viewer. Stored as the globalalwaysShowCodeAsTextsetting; default off, so markdown renders normally. Thanks to xet7. -
Admin Panel / Features: new optional “Render links as plain text” security toggle. When enabled, all links — both markdown links like
[label](url)and raw HTML<a href>tags — are always shown as plain, non-clickable text in every rich text field (board and card titles, descriptions, comments, checklists, etc.), so a link can never be clicked and cannot present misleading anchor text. This is a hardening option on top of the existing XSS sanitization (which already stripsjavascript:/data:schemes, event-handler attributes and dangerous tags): it addresses #6453, where a board title could render as a clickable link. The toggle lives under Admin Panel / Features / Security, is stored as the globalrenderLinksAsPlainTextsetting, and defaults to off (links stay clickable). Implemented by forbidding the<a>tag (while keeping its visible text) in the shared DOMPurify sanitizer used by the rich text viewer, gated on the setting so toggling it re-renders reactively. Thanks to bcook-konza and xet7.
and adds the following updates:
- Outgoing webhooks / notifications: include the card description
(#5143). A card’s description was
saved to the activity log (a
a-changedDescriptionactivity, #5482) but was never put into the outgoing-webhook / notification payload, so integrations received an event with no description text. The payload now carriesdescription(the card’s current description, added only when non-empty) for every card event, and — for a description change — the before/after text asoldValue/value(previously onlytimeValue/timeOldValuewere forwarded). New fields are additive, so existing webhook consumers are unaffected. Thanks to xet7. - Update Sandstorm Docs about how to install newest test version. Thanks to xet7.
and fixes the following tests:
-
Test infrastructure: fix
meteor test(Mocha, server-side) crashing at boot with 0 tests run (scripts/patch-yargs-dirname.cjs). The wholeyargspackage is dragged into the Meteor test server bundle as a transitive devDependency, and Meteor 3 wraps every module in a CommonJS functionfunction(require, exports, module, __filename, __dirname){…}. yargs (and its ESM dependencies) ship native-ESM constructs that are illegal inside that wrapper and crash the bundle before a single test runs:import.meta.url/import.meta.resolve(…)→ “Cannot use ‘import.meta’ outside a module”const __dirname = …→ “Identifier ‘__dirname’ has already been declared”const require = createRequire(import.meta.url)(and the guarded bundler variantconst require = createRequire ? createRequire(import.meta.url) : undefined) → “Identifier ‘require’ has already been declared”
A
postinstallpatch (patch-yargs-dirname.cjs) rewrites those constructs to the wrapper’s own__filename/__dirname/require(yargs is never executed here — it only needs to parse as CommonJS). Getting the patch right took several iterations, each of which failed in a way that looked like an unrelated source-level syntax error:- The patch expanded
import.meta.urlbefore strippingconst require = createRequire(…). The expansion injected arequire('url')…call whose)then terminated the paren-naivecreateRequire\([^)]*\)match early, leaving a dangling.pathToFileURL(__filename).href))— reify then died withSyntaxError: Unexpected token (19:33)on the generated yargsesm.mjs, not on any repo file. Diagnosing it required instrumenting reify’s Babel parser in the Meteor dev_bundle copy to dump the exact source string it was choking on, because every scan of the repo’s own*.jscame back clean (the broken file was innode_modules, was.mjs, and was produced by the patch itself). Fixed by removing thecreateRequireline before expandingimport.meta.url, plus a repair rule that collapses any already-corrupted…href))leftover back to a marker. - With parsing fixed, a second ESM shim surfaced at boot — this time in a yargs
dependency,
node_modules/yargs-parser/build/lib/index.js(not undernode_modules/yargs, soimport.meta.urlthere was still unexpanded, a tell that the old yargs-only scan had never touched it). Its line 30 used the guarded ternaryconst require = createRequire ? createRequire(import.meta.url) : undefined;, whosecreateRequire ?(space, not() slipped past the directcreateRequire\(matcher → the leftoverconst requirecollided with the wrapper parameter and the server bundle crashed at boot with “Identifier ‘require’ has already been declared”. Two changes fixed it: (a) generalize the removal (and its trigger) fromconst require = createRequire\(…\)to a line-wiseconst require = …createRequire…;so it matches both the direct call and the guarded-ternary form, running it before theimport.meta.urlexpansion so the injectedrequire('url')…parens can never confuse it; and (b) broaden the scan from justnode_modules/yargsto yargs and its ESM dependency packages (yargs-parseris the actual offender; cliui, escalade, string-width, y18n, get-caller-file, require-directory are scanned too and are harmless to include).
The patch is idempotent, best-effort (never fails an install), and re-runnable by hand (
node scripts/patch-yargs-dirname.cjs) to repair anode_modulestree left broken by an older version. This is a pre-existing test-only build breakage (the yargs bundling predates these fixes); production runtime bundles were never affected. Separately, three server test files were present on disk but missing from the curated loaderserver/lib/tests/index.js(which themeteor testentry imports explicitly rather than by*.tests.jsconvention), so they had silently never run — the MimeBleed file-validation bypass regression, the import/export privacy settings, and the impersonation report query are now registered. With all of the above, the server suite boots and runs clean: 450 passing, 0 failing (411 before the three files were wired in). Thanks to xet7. -
Test infrastructure:
rebuild-wekan.shandrebuild-wekan.batnow show live progress in every test path. Several places used to sit silent for minutes, which looked like a hang. Audited both scripts and closed every gap:- Server-start wait (both “Run ALL tests” modes). While the
:3000server came up, the readiness wait printed only dots..shnow shows live progress (see the.build/bundleitem below, where it streams the boot log scrolling);.batprints a check counter now and then and points at the live server log totypein another window (on cmd, echoing arbitrary log lines is unsafe as they can contain> < | &). - Fixed the readiness poll hanging so nothing showed for minutes (the progress
line froze at
[0s]). The wait polledcurl http://127.0.0.1:3000/sign-inwith no timeout; Meteor binds the :3000 proxy early and accepts the TCP connection while the app is still building but sends no HTTP response until it finishes, socurlblocked on that first connection for the whole build and the loop never advanced. Added--connect-timeout 2 --max-time 4so each poll returns quickly, and the wait is now bounded by wall-clock time (.sh1200s /.bat~240 polls) rather than a fixed count (also applied to the Playwright-ALL precheck). - Sequential per-job (menu option 2). Each job used to block/redirect to a log
with nothing on screen while it built and ran. Because only one suite runs at a
time in this mode,
.shnow streams each suite’s reporter output straight to the console viatee(also saving the log), so you see every test tick by one-by-one as Mocha / Playwright / the E2E harness prints it, then a final PASS/FAIL + count line;.batruns each job in its own minimized window (reusing the proven parallel start-commands +.done-<key>flags) and polls a live pass counter, one at a time. - Playwright “ALL browsers” single menu item (
.shoption 10 /run_playwright_parallel). It redirected each browser to a log and only dumped the output after all finished. It now streams each browser’s Playwrightlistreporter live viatee(progress visible per test) while still saving the log;PIPESTATUS[0]keeps the per-browser pass/fail accurate through the pipe. - The single-suite menu items (Mocha, import regression, Node E2E, single Playwright browser) already stream straight to the terminal, and the parallel-mode combined progress table is unchanged. Thanks to xet7.
- Server-start wait (both “Run ALL tests” modes). While the
-
Test infrastructure: “Run ALL tests” reuses the precompiled
.build/bundlefor the :3000 server instead of recompiling withmeteor run(bothrebuild-wekan.shandrebuild-wekan.bat). Node E2E and Playwright drive a live WeKan over HTTP; that server is now started from the production bundle you already built withmeteor build .build --directory—node main.jsboots in seconds with no recompile, using Meteor’s bundlednodeandmongod(mongod on :3001, or an already-running MongoDB there is reused). Its one-timeprograms/servernpm install and its boot log now stream live (scrolling) so you can see exactly what is happening. Two caveats that are inherent to Meteor and are called out in the script: (1) the bundle is run as-is, so after changing source you must rebuild it or the tests run old code; (2) the server-side Mocha suite still usesmeteor test(its own.meteor/local-testbuild) — the in-process unit/security tests cannot run from a production bundle, so copying the bundle would not help them. In sequential mode the suites run strictly one at a time, each streaming its own output, and the parallel-only combined table is skipped (only the live WeKan server + MongoDB run alongside — they are the system under test, not parallel test jobs). Before starting, the run checks for the.build/bundledirectory specifically (not just.build) and builds it once withmeteor build .build --directoryif it is missing or incomplete, so a first run with no bundle still works. The bundle server talks to themeteordatabase on :3001 — the DB name Meteor’s built-in mongo used undermeteor runand the one the Playwright / Node E2E tests seed into (tests/playwright/helpers/db.js,tests/e2e/list-regressions.js); an earlier/wekanname made the app read an empty database while the tests seeded a different one, so every seeded test failed until the names were aligned. Both the Bash and the Windows batch runners now use this bundle-based :3000 server (the.batresolves Meteor’s bundlednode/mongodfrom the dev_bundle, starts mongod in a minimized window, and stops it on exit only when it started it). Thanks to xet7.
and fixes the following bugs:
- Sandstorm: the WeKan Admin Panel is now always available in a migrated grain,
not just a freshly created one. On Sandstorm the grain owner (Sandstorm
configurepermission) is mapped to a WeKan admin, which gates the Admin Panel (and Admin Panel / Attachments / Sandstorm, used to delete leftover files after a migration). A brand-new grain worked becauseUsers.after.insertderives the role for the new user, but a grain migrated from an older WeKan already contains the user, so that insert hook never runs. Sandstorm auto-login usesconnection.setUserId()(bypassing accounts-base, soAccounts.onLoginnever fires), and the only remaining hook — anobserveChangesonservices.sandstorm— fires solely when that field actually changes on login. When the migrated user’s stored permissions already equal the grain’s current permissions, the login$setis a no-op with no oplog entry, so the role was never derived and the owner had no Admin Panel. WeKan now reconciles this at grain startup (a migrated grain reboots once migration completes): every Sandstorm user whose stored permissions includeconfigureis granted WeKan admin. It only promotes, never revokes, so a stale/empty stored permission set can never lock the owner out. Thanks to xet7. - Admin Panel / Version: show the MongoDB storage engine even with
per-database credentials (no more “unknown”). The storage engine was read
only from the
serverStatuscommand, which requires the cluster-levelclusterMonitorrole. A per-database WeKan user (readWriteon thewekandatabase only, as in thedocs/Platforms/FOSS/Docker/Meteor3/setup) is not authorized to run it, so the command was rejected and the field stayedunknown. WeKan now falls back to a$collStatsaggregation (which needs only thecollStatsaction thatread/readWritealready grant) and reads the real engine —wiredTiger(orinMemory) — fromstorageStats. The MongoDB compatible version and Database commit were already correct: they come frombuildInfo, which needs no special privileges. Thanks to xet7.
Thanks to above GitHub users for their contributions and translators for their translations.