Compare commits

...
41 Commits
Author SHA1 Message Date
github-actions[bot] ab29f74f6a Bump versions for v10.76 2026-08-09 02:36:55 +00:00
Lauri Ojansivu 48d8dc9de8 CHANGELOG: repoint commit links after history moved.
A rebase rewrote the commits these entries link to, so the links named commits
that no longer exist and would have 404ed on GitHub. Repointed by
releases/fix-changelog-hashes.sh, which only touches a link no ref in this clone
can reach.

Thanks to xet7 !
2026-08-09 05:35:11 +03:00
Lauri Ojansivu 983aba8488 Prepare v10.76 release 2026-08-09 05:35:10 +03:00
Lauri Ojansivu 988e2f0794 CHANGELOG: the three round-trip fixes, under a new Upcoming section.
v10.75 was cut while they were being written, so they get their own Upcoming
above the release. The FerretDB one is summarised here and told in full in that
repo's CHANGELOG, since it is the database that changed.

Thanks to xet7 !
2026-08-09 05:35:10 +03:00
Lauri Ojansivu 90a41dbb22 Two queries per request instead of two per card.
Neither of these gave a wrong answer. They asked the right question one document
at a time, and each await is a full round-trip that starts only once the last
one finished - invisible on a board with four cards, and the whole cost of the
request on a real one.

The bulk label endpoint (PUT .../cards/labels) awaited a getCard per id and then
an update per id. At BULK_CARDS_MAX = 500 that is ONE THOUSAND sequential
round-trips for a single request. The reads are all the same question, so they
are one `$in` query indexed into a Map; the writes genuinely differ - each card
merges its own labelIds - so they stay individual updates, but they are issued
together and awaited once instead of each waiting for the last.

Two details that had to survive: the loop still iterates cardIds rather than the
query result, because a batch read comes back in the database's order and
without the ids that matched nothing - iterating it would silently reorder
`updated` and lose `notFound`. And every write is awaited before the 200 is
sent, so the response still means what it says.

The bulk DELETE beside it is deliberately left alone: its per-card work is real
(cardRemover runs the sub-item hooks, each card gets its own activity), so
batching the reads would save one query inside a loop that does far more than
query.

Global search resolved each `user:`/`member:`/`assignee:`/`creator:` name with
its own awaited findOne, so `member:ann member:bob member:carol` was three
serial round-trips before the search itself could start. They are all the same
question - which of these names is an account - so it is asked once, with `$in`,
and answered from a map. A name typed under two operators is one lookup now, not
two. Unknown names are still reported against the operator they were typed
under, because "ann is not a user" is not useful without saying where ann was
typed.

tests/queryBatching.test.cjs pins both, including the negatives: no per-card
read remains in the label handler, no per-username query remains in search, the
response order and notFound list are preserved, and the delete handler is still
per-card on purpose.

Thanks to xet7 !
2026-08-09 05:35:10 +03:00
github-actions[bot] 8da91adaa7 Bump versions for v10.75 2026-08-09 01:48:39 +00:00
Lauri Ojansivu 80830cd43e Prepare v10.75 release 2026-08-09 04:46:10 +03:00
Lauri Ojansivu f2edde0651 CHANGELOG: a new Upcoming section for the two commits after v10.74.
The release script renamed the old Upcoming to v10.74, and two commits landed
after it: the linked-card cache that could not see a card added later, and the
build-script parity fix. They get a fresh Upcoming section above the release,
which is where development entries go.

Both were rebased onto the version bump, so their hashes changed; the links
point at the ones that will be pushed, not the ones that will be collected.

Thanks to xet7 !
2026-08-09 04:44:59 +03:00
Lauri Ojansivu ada480c93a Linked-card ids must not be cached for the life of a subscription.
The helper the five linked-card cursors share memoized its result per board, to
spare them the duplicate queries the five copies of that preamble used to make.
That is wrong: publishComposite re-runs a child's find() when the parent
document changes, and a cache living for the whole subscription then serves the
ids computed the FIRST time forever - a linked card added later would never be
published, and one removed would go on being published.

The five cursors each ran these same queries before the helper existed, so
computing per call is exactly the cost they always had, and it is correct. The
guard that pinned the memoization now pins its absence, with the reason.

Thanks to xet7 !
2026-08-09 04:43:30 +03:00
Lauri Ojansivu 114d6c0599 Build script parity: git pull and git push, on Windows too.
The one red suite in the test run, and it was right twice over.

`Setup: "git pull" and "git push" that finish the job` added two entries to
build.sh's menu and left build.bat behind, so `build.bat git-pull` answered
nothing. It answers to both names now, with its own :gitpull/:gitpush labels -
the ones its interactive menu already used.

The guard also had to learn what a `!` entry is. It marks something build.sh
runs ITSELF rather than by executing a file in releases/. Most are raw commands
and the .bat runs those the same way, so those still compare as before; the
exception is an entry naming a build.sh FUNCTION, which is shell the .bat has no
way to call. Comparing those as scripts is what made this suite fail the moment
"Update git" was replaced. A function entry is now exempt from the script
comparison and checked separately: build.bat must implement a label of the same
name, so one dropping out of a menu still fails.

The node suites are 339 run, 0 failed for the first time in this branch.

Thanks to xet7 !
2026-08-09 04:43:30 +03:00
github-actions[bot] fc0ce5137b Bump versions for v10.74 2026-08-09 01:01:29 +00:00
Lauri Ojansivu c3c95cefb9 Prepare v10.74 release 2026-08-09 04:00:21 +03:00
Lauri Ojansivu aea9811a76 CHANGELOG: link the follow-up commit the ParentBleed entry describes.
The five linked-card cursors are its own commit, and its story is told inside
the ParentBleed entry - so a reader could read about the fix and had no way to
reach it.

Thanks to xet7 !
2026-08-09 03:54:58 +03:00
Lauri Ojansivu 5a1227da6c CHANGELOG: the dependency bumps and the two tooling commits, and the subsections in the order CLAUDE.md gives.
Six dependabot bumps and two developer-facing commits since v10.73 had no entry
at all. Adding them showed the subsections were also out of order - fixes came
before features - so they are now CRITICAL SECURITY, features, dependencies,
bug fixes, developer-facing, which is what CLAUDE.md specifies.

Thanks to dependabot and xet7 !
2026-08-09 03:50:24 +03:00
Lauri Ojansivu 8a5ca2c2de CHANGELOG: the canary tokens, the filesystem integrity check and the two Database problems fixes, under Upcoming.
Thanks to xet7 !
2026-08-09 03:44:21 +03:00
Lauri Ojansivu 30e8e38f6c Fix the two bugs Admin Panel / Problems / Database problems was reporting.
The page was doing its job and nobody had acted on it. Two real WeKan bugs were
sitting there as rows marked "unknown / unclassified", whose advice was to add a
rule to models/lib/databaseErrors.js. Both are fixed, and both now classify.

1. moveSwimlane, four times over a week:

     Error: update is not available on the server. Please use updateAsync()

   Board.ensureDefaultSwimlaneId() called the SYNCHRONOUS Swimlanes.upsert().
   Meteor 3 removed it on the server, so every move that reached the default
   swimlane self-heal threw. The async twin already existed one function below.
   This getter is synchronous and cannot await, so the upsert is now STARTED and
   not waited for: the self-heal still happens, and the call returns what is in
   the cache - which every caller already had to handle, because it was
   undefined before the self-heal existed at all. A caller that needs the
   swimlane back in the same tick uses ensureDefaultSwimlaneIdAsync().

2. moveList:

     ValidationError: Failed validation
     Cannot read properties of undefined (reading 'title')

   List.move() inserted a new list with `title: this.title`. When a list has no
   title, `title` is required by the schema, so the insert fails validation - and
   collection2's own error formatter then reads a property of the undefined field
   and throws, which is why the admin saw a crash naming neither the list nor the
   real problem. It now says what is wrong before the insert: "This list has no
   title, so it cannot be moved to another board. Give it a title first." The two
   console.log lines that used to sit there printed the title to a log nobody
   reads and did not stop the crash; they are gone.

And two classifier rules so neither reads as "unknown" again. Both say plainly
that this is WeKan's bug rather than the database's or the admin's - there is
nothing to configure - and send the reporter to the right place: upgrade, then
open an issue with the method name. Severity `warning`, the vocabulary this
stream uses.

Thanks to xet7 !
2026-08-09 03:43:24 +03:00
Lauri Ojansivu 2cc0d42ddf Filesystem integrity: is that still the file WeKan stored, and did this server stop cleanly?
Attachments and avatars are files under WRITABLE_PATH, and the database holds one
document per file. NOTHING checked that the two still agree. A file can be
replaced, truncated, back-dated or deleted by anything that reaches the
filesystem - a bad restore, a sync tool, a container rebuild, a shell on the
volume - and WeKan would keep serving whatever is there now, silently.

A baseline is recorded per file in the existing WeKan database (no new files
under WRITABLE_PATH): path, size, modification time, and md5, sha256 AND sha512.
Three digests, because they answer different questions. md5 is what other tools
print, so an admin can compare against a backup with what they already have - it
is never alone, its collision resistance is gone. sha256 is the working digest.
sha512 earns its place on the day the two DISAGREE: two digests over the same
bytes cannot, so when they do, the bytes were not read the same way twice - a
failing disk, a partial write, a truncated copy - which is a different problem
from a substitution and is its own critical finding.

ed25519 is the fourth check and it is NOT a hash - it is a signature, and it
answers the one thing three digests cannot: who says these are the right hashes?
Anybody who can rewrite a file can rewrite a row of hashes. Each entry is signed
over a canonical line (fixed field order, no JSON, whose escaping is not
guaranteed stable) and verified on every scan. A record that does not match its
signature is critical and is NEVER explained away by an ordinary edit. The key
comes from WEKAN_INTEGRITY_PRIVATE_KEY, which WeKan never stores - that is the
configuration that means something against an attacker who reaches the database
too - or is generated and kept in the database, which detects filesystem-only
tampering and is honest about being no more than that. A MALFORMED supplied key
is reported rather than silently replaced by a generated one, which would look
like it was working while checking nothing the operator meant.

The scan is paced by policy, not by hope: once a day, checked hourly; nothing
starts or continues at or above 60% CPU; 50 ms between files plus 20 ms per
megabyte; a fifteen-minute budget, after which it stops and continues tomorrow;
and ONE read per file for all three digests. A run that stopped early does not
report the files it never reached as missing.

The finding is a change WITH NO RECORD SAYING WHY. Files change when people use
WeKan, and those changes are accounted for, reported once at info and
re-baselined so they do not repeat daily. A change with nothing to explain it is
at least medium and is deliberately NOT re-baselined, so it keeps showing until
somebody looks. A modification time that moved BACKWARDS is called out
separately: nothing does that by accident.

Crashes and downtime, in the same stream, because a crash leaves nothing behind:
the process is gone, so it cannot write a message about being gone. A heartbeat
every minute is the only thing that works, and the NEXT start reads it. A first
run and a clean stop record nothing - reporting every deliberate restart is how
a Problems page becomes one nobody opens. A short gap with no clean-shutdown
mark is a note; a long one is a high-severity "it stopped without shutting down
cleanly, and here is how long it was down". Uncaught exceptions and unhandled
rejections land here too, with their cause, before the process goes.

Admin Panel / Problems / Filesystem integrity is the new stream, with the
Username / IP address / Attempts columns. The baseline itself is never published
and never client-writable: it is a map of every file on the server, and the key
document is a private key.

tests/fileIntegrity.test.cjs, 34 checks: every digest case including same-size
substitution and disagreeing digests, the ed25519 forgery attempt, the canonical
line, explained vs unexplained, and the pacing - including that an unreadable
clock does not start a scan, which it did until the test said so.

Thanks to xet7 !
2026-08-09 03:43:07 +03:00
Lauri Ojansivu d577f07700 Canary tokens: who tried to override permissions, and from where.
The security event log said what a guard DID - a request blocked, a filename
sanitized - which answers "is WeKan defending itself" and not the question an
admin asks next: who did that, and from where. It also could not tell a browser
that got confused from somebody working through the Hall of Fame one entry at a
time.

A CANARY is a tripwire at a point that only a permission-override attempt
reaches. Ordinary use never gets there - the code path runs when a request asks
for something the rules forbid - so a trip is not noise, and it is worth an
admin's attention with the actor attached. Design:
docs/Security/Remediation/WeKan.md §12.

Three properties, each enforced by a test:

SILENT. Tripping one changes NOTHING the caller can observe. tripCanary()
always returns false and tripCanaryDeny() always returns true, so a call site
reads as the refusal it replaces:

    if (forbidden) return false;                          // before
    if (forbidden) return tripCanary('card.vote-field', { userId });

The return value is decided before any reporting is attempted and the reporting
is wrapped, so a canary can never throw into, delay or disturb the request it
watches. The REST handler re-throws the ORIGINAL error object. This is not
politeness: a canary that announces itself is a map of which paths are watched,
and a probe would simply avoid them.

BOUNDED. A canary sits where an attacker can loop, so one row per attempt would
be a denial of service they get for free. models/lib/canaryTokens.js - pure, no
Meteor, no clock of its own - records the first trip of a (canary, actor) pair
at once, COUNTS the rest of the window, flushes one summary carrying the total
when the window closes, and stops writing after 60 summaries for a pair that
never gives up. The tracked-pair map is capped at 5000 and evicts the LEAST
RECENTLY seen, so a long-running attacker is not pushed out by a passing one and
a botnet varying its address cannot grow it. Idle pairs are swept on a timer. A
thousand attempts in a minute cost ONE row, and a suppressed trip costs one map
lookup. The username lookup is cached and capped, and neither it nor the insert
is awaited on the caller's path.

ATTRIBUTED. Every event carries userId, username, ip and count. The username is
denormalised at write time on purpose: it is what the account was CALLED when it
tried, so a rename does not rewrite history and a deletion does not erase it.
The address uses the same spoofing-safe resolver as the login throttle -
X-Forwarded-For only as far as HTTP_FORWARDED_COUNT says to trust it - or an
attacker could write a colleague's address into the security log by sending a
header. `detail` says what was attempted in the words of the feature, never the
payload.

Seventeen canaries are wired at the permission checks that refuse the attempts
behind BoardBleed, ParentBleed, ChecklistBleed, PathBleed and CommentBleed, plus
the direct vote/poker field writes and a stored path that points outside the
storage root. All server-side: no Playwright, no browser, nothing to install.

Admin Panel / Problems / Security gains Username, IP address and Attempts
columns, and both new columns are searchable - the thing an admin does with one
security event is pivot on it.

tests/canaryTokens.test.cjs (25 checks) drives the limiter through bursts, an
endless probe, eviction order and junk input; tests/canaryCoverage.test.cjs (18)
pins every canary to its call site and asserts none of them changes what the
caller gets back.

Thanks to xet7 !
2026-08-09 03:29:20 +03:00
Lauri Ojansivu 839d3a820c CHANGELOG: the audit findings and the security-test coverage, under Upcoming.
The five linked-card cursors and the streaming attachment read join the
ParentBleed and PathBleed entries they extend; the comment-reaction ownership
fix and the security-test coverage guard get subsections of their own.

Thanks to Alpastx and xet7 !
2026-08-09 02:58:35 +03:00
Lauri Ojansivu a4a9c03b1f Security tests: say which vulnerability you guard, and check the list.
"Are the security tests enough to prevent what is in the Hall of Fame" was
unanswerable. WeKan publishes 58 named vulnerabilities at
https://wekan.fi/hall-of-fame/ ; some suites name the one they belong to, most
did not, and the Hall of Fame lives in a different repository that CI never
checks out. Answering it meant reading 58 pages against 300 suites by hand,
which means it was never answered - and a regression test that quietly stops
existing is worth nothing, the failure mode tests/testsAreRegistered.test.cjs
already caught once when two *bleed suites drifted out of the mocha index.

So the list lives in the repository that has the tests, and
tests/securityRegressionCoverage.test.cjs keeps the two in step. Every published
vulnerability is either GUARDED - named by a suite that still exists - or
RECORDED, a gap with a written reason, which is the CHANGELOG's "TODO Later"
pattern applied to tests. The gap count is pinned, so a vulnerability cannot be
published with neither a test nor a note; a gap that turns out to be guarded
after all fails too, so coverage is never understated; and a file that merely
REGISTERS suites is not accepted as coverage, or the guard would pass itself.

Getting there meant naming vulnerabilities in the suites that already guarded
them, which is most of what this commit is:

- tests/securityMeifukun.test.cjs guards eight reports and named one. Its
  sections are RedirectBleed, SourceBleed, LiveBleed, CasBleed, OidcBleed,
  MetricsBleed, ImpersonateBleed and InviteBleed - seven vulnerabilities that
  looked untested and were not.
- tests/noIdentityReplacement.test.cjs guards IdentityBleed and PatternBleed,
  the same CodeQL finding twice.
- ExportBleed, CrashBleed, MimeBleed and the four LockoutBleed suites now say so.
- tests/crossBoardParentCardLeak.test.cjs also guards BoardBleed's cross-board
  move deny, and now checks it on Lists and Swimlanes too, not only Cards.

The count that comes out of it: 29 of 58 published vulnerabilities have a named
regression test, and 29 are recorded gaps - mostly older fixes from before WeKan
tested its security fixes at all. They are not known to be unprotected; they are
known to be unchecked, which is a different and more honest statement, and each
one now says what it would take to close it.

Thanks to xet7 !
2026-08-09 02:55:13 +03:00
Lauri Ojansivu 156121c4bc Comment reactions: react as yourself, not as somebody else.
Found while auditing for more of GHSA-pqr4-rxgp-hv2m (CommentBleed - the REST
comment delete that checked board membership and nothing about WHOSE comment it
was). A CardCommentReactions document holds

    { cardCommentId, reactions: [ { reactionCodepoint, userIds: [...] } ] }

and the whole array is ONE field, whose allow rule was board membership for
insert, update and remove alike. So any member could $set `reactions` to
anything: add a colleague's userId to a reaction they never made, or remove one
they did. `toggleReaction()` only ever touches Meteor.userId(), so no legitimate
client sends anything else - the rule simply never said so.

Integrity rather than confidentiality: reactions are visible to the whole board
already. But it puts words in another person's mouth, and it is the same shape
as the advisory - a per-user object gated only by "are you on this board".

A deny rule now refuses an update that changes any OTHER user's presence in any
reaction. The decision is pure and lives in models/lib/reactionOwnership.js:
it compares MEMBERSHIP rather than array order, because the client rebuilds the
array on every toggle and a reordered array with the same membership is the same
set of reactions. The modifier forms that cannot be checked that way -
$push/$pull/$addToSet/$unset, or a dotted `reactions.0.userIds` - are refused
outright, since toggleReaction() always $sets the whole array and letting them
through would be a hole the size of the one being closed.

The membership rule it sits on top of is unchanged: read-only and no-comment
members still may not react at all.

tests/reactionOwnership.test.cjs covers both directions - adding and removing my
own reaction, the first reaction and the last one going away, a reordered array,
several of my own toggled at once - and the attacks: adding somebody else,
deleting somebody else's, wiping an array that was not only mine, moving
somebody else between reactions, an anonymous caller, and junk in either array.

Thanks to xet7 !
2026-08-09 02:54:56 +03:00
Lauri Ojansivu 2c4e81ae34 ParentBleed, five cursors further: linked cards leaked the same way.
GHSA-jvv9-498p-hxrg was reported against the ANCESTOR cursor of the `board`
publication - a cross-board `parentId` made it publish a private board's cards
to another board's subscribers. Auditing for more of the same found the
linked-card cursors beside it with the identical hole and a wider blast radius:
five of them, publishing the linked card, its comments, its attachments, its
checklists and its checklist items.

A `cardType-linkedCard` names a card by id exactly as `parentId` does, and that
card may live on any board. Creating one requires read access to that board -
but the person who created it is not the person the publication is sending to.
Every subscriber of the board carrying the linked card was getting the source
card's full document and all of its children, whether or not they may see the
board it lives on.

The same answer as the ancestor cursor: a linked card whose source board the
subscriber cannot see is not sent. Cards on THIS board are their own answer,
since they are already going to this subscriber. The five cursors repeated the
same fifteen-line preamble verbatim; they share one helper now, memoized per
board, which is also what stops the sixth from being written without the check.

Two more findings from the same audit, in the same file and its neighbour:

- Three further hand-written copies of the board-visibility `$or` (the boards
  publication, getAllBoardsPage, getAllBoardsTileData). All three were correct,
  which is exactly the state RevokeBleed's copy was in before it drifted. They
  call boardVisibilitySelectors() now, so there is no hand-written copy left in
  the file.

- The STREAMING attachment export read `versions.original.path` with nothing but
  an existsSync. The attachment allow rule refuses a client-supplied path, so
  this is not the PathBleed hole - but it was the last place a stored path was
  read without the containment check, and a path is only as trustworthy as every
  way it could have been written.

tests/crossBoardParentCardLeak.test.cjs grows six checks: all five cursors go
through the helper, the helper asks the same visibility question, no cursor
builds its own linkedId list any more, the assigned-only member narrowing
survived the de-duplication, the helper is memoized, and no hand-written
visibility array remains.

Thanks to Alpastx and xet7 !
2026-08-09 02:54:41 +03:00
Lauri Ojansivu b37adda8a4 CHANGELOG: the four Alpastx security reports, under Upcoming.
PathBleed, RevokeBleed, ParentBleed and CommentBleed, each linking its own fix
commit and its Hall of Fame page, under a CRITICAL SECURITY ISSUES header.

Thanks to Alpastx and xet7 !
2026-08-09 02:27:23 +03:00
Lauri Ojansivu a8fa6cfa3f CommentBleed: the REST API let any board member delete anyone's comment.
Over DDP, deleting a comment is author-or-board-admin, and a board that sets
`restrictCommentEditing` takes even the admin's ability away (#5906). That rule
was enforced in a CardComments collection hook keyed off the Meteor userId, with
an early `if (!userId) return` for the genuine server-internal callers - board
copy, cleanup, migrations - that run with no authenticated user.

An HTTP request carries no Meteor userId into the invocation context. So

    DELETE /api/boards/:boardId/cards/:cardId/comments/:commentId

which checked only `Authentication.checkBoardAccess` - board MEMBERSHIP - reached
the collection with no user, the hook took its "server-internal, trust it" path,
and any normal member could delete any comment on the board. HTTP 200, the
comment gone, restrictCommentEditing or not, while the same deletion over DDP
was correctly refused (CWE-639, CWE-863). Broken object-level authorization:
harassment and evidence destruction on a shared board.

The fix does not rely on the hook seeing something it cannot see. The handler
loads the comment - 404 when there is none - and applies the same rule itself,
with req.userId, before removing anything. That rule is now an exported function
(`assertCanMutateComment` in models/cardComments.js) that the hooks and the REST
handler both call, so DDP and HTTP cannot enforce different things; it carries a
403 statusCode so a refusal answers Forbidden rather than 500. The no-userId
path stays, documented for the internal callers it was written for.

tests/restCommentDeleteAcl.test.cjs pins that the handler loads the comment,
that the ACL runs BEFORE the removal, that membership alone is no longer
sufficient, that the DDP hooks still call the same function, and - so a future
edit route cannot repeat this - that no REST PUT for comments exists without the
same check.

Thanks to Alpastx and xet7 !

Fixes GHSA-pqr4-rxgp-hv2m.
2026-08-09 02:20:52 +03:00
Lauri Ojansivu 35daaaa279 ParentBleed: one card id bridged a private board into another board's feed.
A card's `parentId` may name a card on ANOTHER board, and setting it was
authorized only against the CHILD board's write ACL - nothing asked whether the
person setting it, or the people subscribed to the child board, were allowed to
see the other board at all.

The board publication walks the whole ancestor chain, because the
'prefix-with-full-path' subtask setting renders a subtask's complete path
(#3453), and it published the complete ancestor card DOCUMENTS to every
subscriber of the child board. So a member who could write on shared board B and
knew a card id on private board A could point a card at it and have A's card -
title, description, custom fields - delivered over DDP to people who are not
members of A (CWE-200, CWE-862).

Closed at both ends, because either alone leaves half the hole open.

The WRITE refuses a parent whose board the actor cannot see. That is one
question, asked in one place (server/lib/visibleBoardIds.js) with the same
selectors the publication and All Boards use - so an ACTIVE org, team or domain
share counts and a revoked one does not. It is enforced on the REST card create,
on the REST card update (the PUT the report used), and on DDP by a deny rule
covering both insert and update, beside the cross-board MOVE deny that
GHSA-gm7v-pc38-53jr added. A parent that does not exist is refused too. Clearing
or omitting a parent is untouched.

The PUBLICATION sends only the ancestors whose board the subscriber may see. The
board being published is its own answer, so a normal same-board subtask path is
unchanged; an ancestor on a board the subscriber cannot see is simply not sent,
and a chain that yields nothing publishes nothing.

This is the shape of the check the linked-card path already made - creating a
linked card requires read access to the source card's board - applied to the
field that did not have it.

tests/crossBoardParentCardLeak.test.cjs pins the filtered cursor, that the
unfiltered publish of every ancestor is gone, that both REST paths validate
before writing, that the deny rule covers insert as well as update, and that
clearing a parent and the pre-existing cross-board move deny still work.

Thanks to Alpastx and xet7 !

Fixes GHSA-jvv9-498p-hxrg.
2026-08-09 02:20:37 +03:00
Lauri Ojansivu 08baf7fd1b RevokeBleed: revoking an org, team or domain share did not revoke it.
`isActive: false` is how a board admin REVOKES a share with an organization, a
team or an email domain. The All Boards list honoured it - it matches shares
with `$elemMatch: { ..., isActive: true }` - so a revoked user watched the board
disappear from their list and everything looked correct.

The `board` publication did not. That is the publication that sends the board
document and its lists, swimlanes, cards, comments and attachments, and it
matched shares with a dotted selector instead:

    $or.push({ 'orgs.orgId': { $in: orgsIds } });
    $or.push({ 'teams.teamId': { $in: teamsIds } });
    $or.push({ 'domains.domain': { $in: emailDomains } });

A dotted path matches an element of the array without saying anything about
that element's other fields, so isActive was never consulted. Anyone who still
knew the boardId - a bookmark, a browser tab, a note - could subscribe and
receive the whole private board after their access had been taken away
(CWE-639, CWE-863). A revoke the primary data publication does not honour is
not a revoke.

The rule was written out twice and the two copies disagreed, so it is written
once now: models/lib/boardVisibilitySelectors.js builds the `$or`, both
Boards.userBoards and the `board` publication call it, and every share kind is
matched with `$elemMatch` requiring `isActive: true`. `includePublic: false`
still drops the public clause and only that clause, which is what the search
over all boards needs.

tests/boardShareRevokeBypass.test.cjs pins each share kind's selector shape,
that no clause anywhere matches a share without requiring isActive, that the
three dotted selectors are not produced at all, and that junk in the id lists is
dropped rather than handed to Mongo. tests/searchBoardScope.test.cjs asked its
question of the old array literal; it now asks it of the builder - the
behaviour it protects is unchanged.

Thanks to Alpastx and xet7 !

Fixes GHSA-gwc4-fw7p-gw58.
2026-08-09 02:20:13 +03:00
Lauri Ojansivu 0a9eb0ef4f PathBleed: an avatar could name any file on disk, and board export read it.
Attachments blocked client-supplied `versions.*.path`; avatars did not. The
avatar allow rule was the single line `update: isOwner` - being the owner let
you write ANY field of your own avatar document, `versions.original.path`
included. That field is server-managed metadata: it says where on disk the
bytes are.

Board export then read it. For every member whose avatar is a local WeKan file,
the exporter opens `versions.original.path` and embeds the bytes as base64 in
`profile.avatarFile`, so an authenticated user could point their own avatar at
/etc/passwd - or at anything else under WRITABLE_PATH - export a board they are
a member of, and decode the file out of the JSON. Arbitrary file read as the
WeKan OS user (CWE-22, CWE-73), from any account that can own an avatar.

Both halves are closed.

The WRITE: the guards that attachments had are now in one module,
models/lib/fileVersionFields.js, and BOTH permission files import it - avatars
had gone without them precisely because each collection wrote its own copy.
Avatars refuse an insert carrying versions.*.path or .storage, refuse any update
touching the versions subtree, and restrict updates to the same field whitelist
attachments use. Being the owner is still required; it is no longer sufficient.

The READ: nothing is read from a stored path unless the path RESOLVES to
somewhere inside WeKan's own storage (models/lib/storagePathContainment.js).
That is the half that also holds when a path is poisoned some other way - a
document written before this fix, a restored backup, a bad migration. The check
sits at getBase64Data, the one place the export turns a stored path into bytes
for attachments and avatars alike, and at both avatar call sites. The download
path in fileStoreStrategy.js has always checked containment this way; its
private copy of the function is gone and it uses the shared one, so the download
and the export ask the same question.

tests/avatarVersionPathTraversal.test.cjs pins the payload being refused on
insert and on update, that renaming an avatar still works, that ..-escapes are
resolved rather than string-matched, that a sibling directory with the root as a
prefix is not inside it, and that no unguarded read of a stored version path
remains in the exporter.

Thanks to Alpastx and xet7 !

Fixes GHSA-4mxf-m8pq-xc9p.
2026-08-09 02:19:15 +03:00
Lauri Ojansivu b2ef10d9d1 Merge pull request #6579 from wekan/dependabot/github_actions/actions/upload-artifact-7
Bump actions/upload-artifact from 4 to 7
2026-08-08 23:41:43 +03:00
Lauri Ojansivu ad62101e10 Merge pull request #6578 from wekan/dependabot/npm_and_yarn/aws-sdk/lib-storage-3.1104.0
Bump @aws-sdk/lib-storage from 3.1085.0 to 3.1104.0
2026-08-08 23:41:27 +03:00
Lauri Ojansivu a1c500e33b Merge pull request #6577 from wekan/dependabot/npm_and_yarn/markdown-it-15.0.0
Bump markdown-it from 14.3.0 to 15.0.0
2026-08-08 23:41:09 +03:00
Lauri Ojansivu 454ec04b97 Merge pull request #6576 from wekan/dependabot/npm_and_yarn/tests/playwright/playwright/test-1.62.1
Bump @playwright/test from 1.62.0 to 1.62.1 in /tests/playwright
2026-08-08 23:40:53 +03:00
Lauri Ojansivu f4e5843016 Merge pull request #6575 from wekan/dependabot/github_actions/actions/checkout-7
Bump actions/checkout from 4 to 7
2026-08-08 23:40:40 +03:00
Lauri Ojansivu 0dbcfc44e0 Merge pull request #6574 from wekan/dependabot/github_actions/actions/download-artifact-8
Bump actions/download-artifact from 4 to 8
2026-08-08 23:40:26 +03:00
dependabot[bot] 0612630836 Bump actions/upload-artifact from 4 to 7
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 7.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-08 20:33:36 +00:00
dependabot[bot] eab75eab70 Bump @aws-sdk/lib-storage from 3.1085.0 to 3.1104.0
Bumps [@aws-sdk/lib-storage](https://github.com/aws/aws-sdk-js-v3/tree/HEAD/lib/lib-storage) from 3.1085.0 to 3.1104.0.
- [Release notes](https://github.com/aws/aws-sdk-js-v3/releases)
- [Changelog](https://github.com/aws/aws-sdk-js-v3/blob/main/lib/lib-storage/CHANGELOG.md)
- [Commits](https://github.com/aws/aws-sdk-js-v3/commits/v3.1104.0/lib/lib-storage)

---
updated-dependencies:
- dependency-name: "@aws-sdk/lib-storage"
  dependency-version: 3.1104.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-08 20:33:15 +00:00
dependabot[bot] aa8f591839 Bump markdown-it from 14.3.0 to 15.0.0
Bumps [markdown-it](https://github.com/markdown-it/markdown-it) from 14.3.0 to 15.0.0.
- [Changelog](https://github.com/markdown-it/markdown-it/blob/master/CHANGELOG.md)
- [Commits](https://github.com/markdown-it/markdown-it/compare/14.3.0...15.0.0)

---
updated-dependencies:
- dependency-name: markdown-it
  dependency-version: 15.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-08 20:32:49 +00:00
dependabot[bot] e16ffbc12b Bump @playwright/test from 1.62.0 to 1.62.1 in /tests/playwright
Bumps [@playwright/test](https://github.com/microsoft/playwright) from 1.62.0 to 1.62.1.
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.62.0...v1.62.1)

---
updated-dependencies:
- dependency-name: "@playwright/test"
  dependency-version: 1.62.1
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-08 20:32:48 +00:00
dependabot[bot] d911d71e53 Bump actions/checkout from 4 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 7.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-08 20:32:46 +00:00
dependabot[bot] 79b10a1b43 Bump actions/download-artifact from 4 to 8
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 8.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v8)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '8'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-08 20:32:35 +00:00
Lauri Ojansivu 8c2565e996 releases/: delete two superseded scripts, and two exemptions that outlived them.
An audit of releases/ against who actually calls each script. It holds 89 of
them, and they divide cleanly:

  20  invoked by .github/workflows - CI checks out the repo and runs the FILE, so
      these cannot move anywhere;
  65  reachable from the build.sh / build.bat Releases menu, which is what the
      menu is for;
   1  sourced by another script (ensure-tools.sh);
   3  referenced by nothing that runs.

Of those last three, wekan-entrypoint.sh is the Docker image's entrypoint - named
by the Dockerfile, not by a caller in this repo - so it stays.
old-build-bundle-arm64.sh and translations/old-pull-translations.sh are the other
two, and both were already recorded as superseded, by build-bundle-arm64.sh and
pull-translations.sh. Nothing referenced them but the exemption list that excused
them from being menu entries. They are deleted, and their exemptions with them.

Two more exemption notes were wrong rather than stale-adjacent:
fix-changelog-hashes.sh said "Setup -> Update git runs it", an option that no
longer exists - it is Setup -> git pull and git push now, both of which run it -
and resolve-fork-node-tag.sh excused a file that is not in the tree at all. An
exemption naming a missing script is an exemption nobody can check.

Checked after: every remaining releases/ script is either a menu entry or exempt
with a reason, and no exemption names a file that does not exist.

Thanks to xet7 !
2026-08-08 14:22:12 +03:00
Lauri Ojansivu 6def3a14dd Setup: "git pull" and "git push" that finish the job, replacing "Update git".
"Update git" fetched, rebased, ran the CHANGELOG hash repair, printed git status
and left the rest to the reader - and the rest is where the mistakes were. A
rebase rewrites local commits, so CHANGELOG links written before it name commits
that no longer exist; the repair fixes the file but commits nothing, so the next
push carries a dirty tree; and a push rejected as non-fast-forward leaves a repo
that needs a pull the caller has to know to run. Three manual steps after an
option whose name did not say what it did.

Two options now, one per direction, each ending in a state that is either correct
or unchanged:

  git pull  - fetch; fast-forward when there is nothing local to replay (no
              commit is rewritten, so no link can go stale); otherwise rebase,
              then repoint the CHANGELOG links the rebase moved AND commit that,
              so the tree is clean. A conflict is the one thing a script must not
              paper over - a half-applied rebase IS the wrong state - so the
              rebase is aborted, the repo is exactly as it was, and the message
              says what to run by hand.
  git push  - repair the links BEFORE publishing them, since a stale link that
              reaches GitHub 404s for every reader; say so when there is nothing
              to push; and on the one rejection with an obvious answer, origin
              moved, pull and retry ONCE. Never force: force-pushing a branch
              somebody has pulled rewrites their history too, and a second
              rejection is a different problem that retrying does not fix.

build.bat had a 41-line batch REIMPLEMENTATION of the old flow. It is gone: both
options there call `bash ./build.sh git-pull` / `git-push`, so there is one
implementation of this logic and Windows cannot drift from it. The same two are
CLI commands (./build.sh git-pull, git-push), so the menu, the .bat and the
command line are the same code.

Verified against throwaway repositories - an origin plus two clones - rather than
by reading: already-up-to-date does nothing; origin-only-moved fast-forwards and
rewrites no commit; both-sides-moved rebases, and a CHANGELOG link to a rewritten
commit is repointed to the replayed commit with the same subject and committed,
leaving the tree clean; a conflicting rebase aborts with HEAD, the working tree
and the index exactly as before and no rebase in progress; a rejected push pulls
and lands on the second try; and pushing with nothing to push says so.

Thanks to xet7 !
2026-08-08 14:22:12 +03:00
90 changed files with 6853 additions and 843 deletions

No files matched your search

+4 -4
View File
@@ -84,7 +84,7 @@ jobs:
tag: ${{ steps.find.outputs.tag }}
version: ${{ steps.find.outputs.version }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- id: find
env:
@@ -130,7 +130,7 @@ jobs:
BUILD_THIS: ${{ inputs.only == '' || contains(fromJSON(inputs.only), matrix.appimage_arch) }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
if: ${{ env.BUILD_THIS == 'true' }}
# An AppImage only WRAPS an existing release bundle
@@ -298,7 +298,7 @@ jobs:
fi
echo "WeKan answered on port 8080."
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
if: ${{ env.BUILD_THIS == 'true' }}
with:
name: appimage-${{ matrix.appimage_arch }}
@@ -311,7 +311,7 @@ jobs:
if: ${{ always() && inputs.publish != false }}
runs-on: ubuntu-24.04
steps:
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v8
with:
pattern: appimage-*
path: assets
+7 -7
View File
@@ -89,7 +89,7 @@ jobs:
tag: ${{ steps.find.outputs.tag }}
version: ${{ steps.find.outputs.version }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- id: find
env:
GH_TOKEN: ${{ github.token }}
@@ -133,7 +133,7 @@ jobs:
BUILD_THIS: ${{ inputs.only == '' || contains(fromJSON(inputs.only), matrix.flatpak_arch) }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
if: ${{ env.BUILD_THIS == 'true' }}
- name: Install flatpak
@@ -273,14 +273,14 @@ jobs:
sha256sum "${name}.flatpak" > "${name}.flatpak.sha256sum" )
ls -lh dist
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
if: ${{ env.BUILD_THIS == 'true' }}
with:
name: flatpak-${{ matrix.flatpak_arch }}
path: dist/
if-no-files-found: error
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
if: ${{ env.BUILD_THIS == 'true' }}
with:
name: flatpak-repo-${{ matrix.flatpak_arch }}
@@ -293,7 +293,7 @@ jobs:
if: ${{ always() }}
runs-on: ubuntu-24.04
steps:
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v8
with:
pattern: flatpak-[0-9a-z]*
path: assets
@@ -322,7 +322,7 @@ jobs:
echo "Uploaded to $(gh release view '${{ needs.release.outputs.tag }}' --json url --jq .url)"
# ---- the repository -------------------------------------------------
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v8
if: ${{ inputs.publish_repo != false }}
with:
pattern: flatpak-repo-*
@@ -395,7 +395,7 @@ jobs:
TXT
du -sh flatpak-repo
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
if: ${{ inputs.publish_repo != false }}
with:
name: flatpak-repository
+3 -3
View File
@@ -68,7 +68,7 @@ jobs:
manual: ${{ steps.plan.outputs.manual }}
total: ${{ steps.plan.outputs.total }}
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- id: plan
env:
@@ -199,7 +199,7 @@ jobs:
uses: docker/setup-qemu-action@v4
# Before the download, because actions/checkout cleans the workspace.
- uses: actions/checkout@v4
- uses: actions/checkout@v7
if: ${{ steps.want.outputs.build == 'yes' }}
# THE DIFFERENCE from release-all.yml, and the whole point of this
@@ -285,7 +285,7 @@ jobs:
if: ${{ always() }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
V: ${{ needs.plan.outputs.version }}
+9 -9
View File
@@ -659,7 +659,7 @@ jobs:
done
echo "OK: $zip is $((bytes/1024/1024)) MiB and contains bundle/main.js and bundle/node."
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
with:
name: provenance-amd64
path: provenance/
@@ -864,7 +864,7 @@ jobs:
done
echo "OK: $zip is $((bytes/1024/1024)) MiB and contains bundle/main.js and bundle/node."
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
if: steps.preflight.outputs.skip != 'true'
with:
name: provenance-arm64
@@ -921,7 +921,7 @@ jobs:
# 404'd on wekan-<version>-amd64.zip - v10.63 and v10.64 both. Checking out
# first, then downloading on top, keeps the bundles. (The checkout is here
# for the provenance script under releases/.)
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Download all bundles
uses: actions/download-artifact@v8
@@ -931,7 +931,7 @@ jobs:
- name: Collect where the binaries came from
continue-on-error: true
uses: actions/download-artifact@v4
uses: actions/download-artifact@v8
with:
pattern: provenance-*
path: provenance
@@ -2290,7 +2290,7 @@ jobs:
# checkout - the job would stop at "No such file or directory". It goes
# first because actions/checkout cleans the workspace, which would remove
# the bundle the next step downloads.
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- name: Download amd64 bundle
uses: actions/download-artifact@v8
@@ -2490,7 +2490,7 @@ jobs:
mkdir -p provenance
[ -f provenance.tsv ] && mv provenance.tsv "provenance/${{ matrix.arch }}.tsv" || true
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
if: steps.preflight.outputs.skip != 'true'
with:
name: provenance-${{ matrix.arch }}
@@ -3567,7 +3567,7 @@ jobs:
cp -f "$HOME"/.local/state/snapcraft/log/*.log snap-logs/ 2>/dev/null || true
ls -la snap-logs || true
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
if: always()
with:
name: snap-launchpad-logs-${{ matrix.arch }}
@@ -4089,9 +4089,9 @@ jobs:
if: ${{ always() && needs.release.result == 'success' }}
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v7
- uses: actions/download-artifact@v4
- uses: actions/download-artifact@v8
continue-on-error: true
with:
pattern: provenance-*
+643
View File
@@ -264,6 +264,649 @@ browser build to verify).
</details>
# v10.76 2026-08-09 WeKan ® release
**In short:** three round-trip counts that were linear in the input, and are not
any more. The **bulk label** endpoint asked the database twice per card - a
thousand sequential round-trips at its 500-card cap - **global search** resolved
each named user with its own lookup before the search could start, and
**FerretDB** skipped every top-level `$or` when building a WHERE clause, so the
board-list query narrowed nothing in SQL and filtered every row in Go. None of
them was a wrong answer; each was the right question asked one document at a
time. The binaries below are v10.75's: nothing here rebuilds them.
| Platform | Binary | From | Version | SHA256 |
| --- | --- | --- | --- | --- |
| amd64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-x64.tar.xz) | v24.19.0 | `14b342e71204f811bde6153be8e04b62aef63c236fef92b55f9c83154b409647` |
| amd64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-amd64) | v1.45.0 | `94713f605167abb45a3717482d35de4824cb4a8f199c1400e826a8a2b04f3893` |
| arm64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-arm64.tar.xz) | v24.19.0 | `01443c1e1a29e531ccad5a46fefa6df490d2189c49f7955904aecdbb0fe86fdc` |
| arm64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-arm64) | v1.45.0 | `275ae50ac97e6a70eee72e6de37766c458775c5997c896352db5189c6cf1f04b` |
| loong64 | Node.js | [unofficial-builds.nodejs.org](https://unofficial-builds.nodejs.org/download/release/v24.19.0/node-v24.19.0-linux-loong64.tar.xz) | v24.19.0 | `c24f224726f2d785bd18a1fd09f5e6d1fecf0269928451a60c5da9eac8e92e68` |
| loong64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-loong64) | v1.45.0 | `28bf67981168dfc4bd67698b41dd62628aafe347a77f2b1e6ffcadf009d575e0` |
| mac-arm64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-darwin-arm64.tar.xz) | v24.19.0 | `3f1cf157479c1480352083105e13faf9d008ede98e7e157746b6df940d197b94` |
| mac-arm64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-mac-arm64) | v1.45.0 | `639ed58b84820b3d588f4161c64d0ab940d0cc6e7d022088d60c2b0b97f99f8e` |
| mac-x64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-darwin-x64.tar.xz) | v24.19.0 | `d35e95230f46f6f0751df497c56622c6735e05d5e1fb1630996a005b9d328fe4` |
| mac-x64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-mac-amd64) | v1.45.0 | `fd519903f5630e881e38e7c5814f00c0e89ad26f6785f1ddcbab4058356fc9f3` |
| ppc64le | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-ppc64le.tar.xz) | v24.19.0 | `c510c6ce12f07010f771e6edb22a3fe23f4f2e6f40b1ffd4941aed0646a0d8b3` |
| ppc64le | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-ppc64le) | v1.45.0 | `de4518c7774d302533369c477759ddd866785d6741d98d399388eb8de3df175a` |
| riscv64 | Node.js | [unofficial-builds.nodejs.org](https://unofficial-builds.nodejs.org/download/release/v24.19.0/node-v24.19.0-linux-riscv64.tar.xz) | v24.19.0 | `cd1f14af2812148002f58b58a5f9af512a50e3b8e8c148e0db44019dcb68edfd` |
| riscv64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-riscv64) | v1.45.0 | `7dc2952f554e8800c4029577901999e06e10272da686f7e402177080067028f9` |
| s390x | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-s390x.tar.xz) | v24.19.0 | `a4792e65962ffa0af42627aacf1122a60c3c88dbf4e4184f06820d66f9da8ba4` |
| s390x | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-s390x) | v1.45.0 | `0ae2e2f2cffdc5dd2ea4f125281a5e12eea216fbe49b5561d9c001700c3fc0c1` |
| win64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-win-x64.zip) | v24.19.0 | `57f71ab3652e797d84acddc79c81cc9ff1c6ddb2a1974cdb83f00fee9bff4c73` |
| win64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-win64.exe) | v1.45.0 | `f6337994368a52d011d438c82b914b0cedb3178fd030acac8db3dab8017cee85` |
This release makes the following faster:
**The REST API** - how many times one request talks to the database.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/90a41dbb2">Bulk label changes read every card in one query, and write them together</a>. Thanks to xet7.</summary>
`PUT .../cards/labels` awaited a `getCard` per id and then an update per id. At
the `BULK_CARDS_MAX` of 500 that is **one thousand sequential round-trips** for
a single request, each starting only once the last one finished.
The reads are all the same question, so they are one `$in` query indexed into a
Map. The writes genuinely differ - each card merges its own `labelIds` - so they
stay individual updates, but they are issued together and awaited once instead
of each waiting for the last.
Two details had to survive. The loop still iterates the caller's `cardIds`
rather than the query result, because a batch read comes back in the database's
order and without the ids that matched nothing: iterating it would silently
reorder `updated` and lose `notFound`. And every write is awaited before the 200
is sent, so the response still means what it says.
The bulk DELETE beside it is deliberately left alone. Its per-card work is real
- `cardRemover` runs the sub-item hooks and each card gets its own activity - so
batching the reads would save one query inside a loop that does far more than
query.
</details>
**Global search** - what happens before the search itself starts.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/90a41dbb2">Every username a query names is resolved in one lookup</a>. Thanks to xet7.</summary>
Each `user:`, `member:`, `assignee:` and `creator:` predicate resolved its name
with its own awaited `findOne`, so `member:ann member:bob member:carol` was
three serial round-trips before the search could begin.
They are all the same question - which of these names is an account - so it is
asked once, with `$in`, and answered from a map. A name typed under two
operators is one lookup now rather than two.
An unknown name is still reported against the operator it was typed under: "ann
is not a user" is not useful without saying where ann was typed.
</details>
**The database** - what SQLite is asked, and what is filtered afterwards.
<details>
<summary><a href="https://github.com/wekan/FerretDB/commit/dab729eb">FerretDB pushes a top-level $or down to SQL when every branch can be</a>. Thanks to xet7.</summary>
FerretDB skipped every top-level `$`-key when building its WHERE clause, so a
selector whose only SELECTIVE terms sit inside an `$or` produced a clause that
narrowed nothing: SQLite returned the rows, and every one was decoded and
filtered in Go to return a handful.
That is the shape of WeKan's "which boards may this user see" query, and the
worst possible one for it - `archived = false` and `type = 'board'` push down
and match nearly everything, while the membership clauses that actually select
stayed in Go. On an instance with ten thousand boards where a user belongs to
five, that decoded ten thousand documents to return five, on every All Boards
load.
It is all or nothing, and that is the whole subtlety. Every other pushdown
NARROWS: a condition that cannot be expressed is dropped, the WHERE returns a
superset, and the Go filter removes the rest. An OR that drops a branch REMOVES
rows that match it, and the Go filter never sees them. So one unpushable branch
refuses the whole `$or`, as does a nested-operator branch and an empty one.
See [the FerretDB CHANGELOG](https://github.com/wekan/FerretDB/blob/main-v1/CHANGELOG.md)
for the database side.
</details>
Thanks to above GitHub users for their contributions and translators for their
translations.
# v10.75 2026-08-09 WeKan ® release
**In short:** two things the full test run turned up, one in WeKan and one in
the scripts that run it. A **linked-card cache** that could not see a card
added after the first time a subscription asked, and the **build script parity**
guard that was the one red suite - right twice over, because `build.bat` really
was missing the two entries and the guard really could not tell a shell function
from a script. The binaries below are v10.74's: nothing here rebuilds them.
| Platform | Binary | From | Version | SHA256 |
| --- | --- | --- | --- | --- |
| amd64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-x64.tar.xz) | v24.19.0 | `14b342e71204f811bde6153be8e04b62aef63c236fef92b55f9c83154b409647` |
| amd64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-amd64) | v1.45.0 | `94713f605167abb45a3717482d35de4824cb4a8f199c1400e826a8a2b04f3893` |
| arm64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-arm64.tar.xz) | v24.19.0 | `01443c1e1a29e531ccad5a46fefa6df490d2189c49f7955904aecdbb0fe86fdc` |
| arm64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-arm64) | v1.45.0 | `275ae50ac97e6a70eee72e6de37766c458775c5997c896352db5189c6cf1f04b` |
| loong64 | Node.js | [unofficial-builds.nodejs.org](https://unofficial-builds.nodejs.org/download/release/v24.19.0/node-v24.19.0-linux-loong64.tar.xz) | v24.19.0 | `c24f224726f2d785bd18a1fd09f5e6d1fecf0269928451a60c5da9eac8e92e68` |
| loong64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-loong64) | v1.45.0 | `28bf67981168dfc4bd67698b41dd62628aafe347a77f2b1e6ffcadf009d575e0` |
| mac-arm64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-darwin-arm64.tar.xz) | v24.19.0 | `3f1cf157479c1480352083105e13faf9d008ede98e7e157746b6df940d197b94` |
| mac-arm64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-mac-arm64) | v1.45.0 | `639ed58b84820b3d588f4161c64d0ab940d0cc6e7d022088d60c2b0b97f99f8e` |
| mac-x64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-darwin-x64.tar.xz) | v24.19.0 | `d35e95230f46f6f0751df497c56622c6735e05d5e1fb1630996a005b9d328fe4` |
| mac-x64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-mac-amd64) | v1.45.0 | `fd519903f5630e881e38e7c5814f00c0e89ad26f6785f1ddcbab4058356fc9f3` |
| ppc64le | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-ppc64le.tar.xz) | v24.19.0 | `c510c6ce12f07010f771e6edb22a3fe23f4f2e6f40b1ffd4941aed0646a0d8b3` |
| ppc64le | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-ppc64le) | v1.45.0 | `de4518c7774d302533369c477759ddd866785d6741d98d399388eb8de3df175a` |
| riscv64 | Node.js | [unofficial-builds.nodejs.org](https://unofficial-builds.nodejs.org/download/release/v24.19.0/node-v24.19.0-linux-riscv64.tar.xz) | v24.19.0 | `cd1f14af2812148002f58b58a5f9af512a50e3b8e8c148e0db44019dcb68edfd` |
| riscv64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-riscv64) | v1.45.0 | `7dc2952f554e8800c4029577901999e06e10272da686f7e402177080067028f9` |
| s390x | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-s390x.tar.xz) | v24.19.0 | `a4792e65962ffa0af42627aacf1122a60c3c88dbf4e4184f06820d66f9da8ba4` |
| s390x | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-s390x) | v1.45.0 | `0ae2e2f2cffdc5dd2ea4f125281a5e12eea216fbe49b5561d9c001700c3fc0c1` |
| win64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-win-x64.zip) | v24.19.0 | `57f71ab3652e797d84acddc79c81cc9ff1c6ddb2a1974cdb83f00fee9bff4c73` |
| win64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-win64.exe) | v1.45.0 | `f6337994368a52d011d438c82b914b0cedb3178fd030acac8db3dab8017cee85` |
This release fixes the following bug:
**The board publication** - what a subscriber is sent, and when.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/ada480c93">Linked-card ids are no longer cached for the life of a subscription</a>. Thanks to xet7.</summary>
The helper the five linked-card cursors share memoized its result per board, to
spare them the duplicate queries the five copies of that preamble used to make.
That is wrong: `publishComposite` re-runs a child's `find()` when the parent
document changes, and a cache living for the whole subscription then serves the
ids computed the FIRST time forever - a linked card added later would never be
published, and one removed would go on being published.
The five cursors each ran these same queries before the helper existed, so
computing per call is exactly the cost they always had, and it is correct. The
guard that pinned the memoization now pins its absence, with the reason, so the
next reader does not put it back.
</details>
and has the following developer-facing change:
**The build scripts** - what each menu offers, and what the guard between them
compares.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/114d6c059">git pull and git push work on Windows too, and the parity guard knows what a function entry is</a>. Thanks to xet7.</summary>
The one red suite in the full test run, and it was right twice over.
The commit that replaced `Update git` with a `git pull` and a `git push` that
finish the job added both to `build.sh`'s menu and left `build.bat` behind, so
`build.bat git-pull` answered nothing. It answers to both names now, with its
own `:gitpull` / `:gitpush` labels - the ones its interactive menu already used.
The guard also had to learn what a `!` entry is. It marks something `build.sh`
runs ITSELF rather than by executing a file in `releases/`. Most are raw
commands and the `.bat` runs those the same way, so those still compare as
before; the exception is an entry naming a `build.sh` FUNCTION, which is shell
the `.bat` has no way to call. Comparing those as scripts is what made the suite
fail the moment `Update git` was replaced. A function entry is exempt from the
script comparison now and checked separately: `build.bat` must implement a label
of the same name, so one dropping out of a menu still fails.
</details>
Thanks to above GitHub users for their contributions and translators for their
translations.
# v10.74 2026-08-09 WeKan ® release
**In short:** four security reports from **Alpastx**, all of them the same
mistake in four places - a rule that exists, is correct, and is not asked.
**PathBleed** let an avatar's owner write the on-disk path of their own avatar
and have board export read that file back out, because the guard attachments
have was never copied to avatars. **RevokeBleed** left a revoked organization,
team or domain share working in the `board` publication, because that
publication had its own copy of the visibility query and the copy ignored
`isActive`. **ParentBleed** let a card on one board name a parent on a private
one and had the publication deliver that private card to everybody, because
nothing checked who may see the parent's board. **CommentBleed** let any board
member delete anyone's comment over REST, because the object-level rule lived in
a collection hook that cannot see an HTTP caller. Each fix puts the rule in ONE
place that both callers use, and each comes with a plain-node suite that pins
the attack and the negatives. Auditing for more of the same found **five more
cursors** leaking cross-board content the way ParentBleed did, three more
hand-written copies of the visibility query, and a **comment reaction** anybody
on the board could put in somebody else's name.
The features under them are two answers to "and then what": **canary tokens**,
which record WHO tried a permission override and from WHERE without telling them
they were seen, and a daily **filesystem integrity** check that asks whether
every stored file is still the file WeKan stored - name, date, md5, sha256,
sha512 and an ed25519 signature - and warns when one changed with no record
saying why. Below that: dependency updates, the two bugs Admin Panel / Problems
was itself reporting, and the security tests, which now say WHICH published
vulnerability they guard so a new guard can check the whole Hall of Fame list
against them - **33 of 58** covered, the other 25 recorded gaps with reasons.
The binaries below are v10.73's: nothing here rebuilds them.
| Platform | Binary | From | Version | SHA256 |
| --- | --- | --- | --- | --- |
| amd64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-x64.tar.xz) | v24.19.0 | `14b342e71204f811bde6153be8e04b62aef63c236fef92b55f9c83154b409647` |
| amd64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-amd64) | v1.45.0 | `94713f605167abb45a3717482d35de4824cb4a8f199c1400e826a8a2b04f3893` |
| arm64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-arm64.tar.xz) | v24.19.0 | `01443c1e1a29e531ccad5a46fefa6df490d2189c49f7955904aecdbb0fe86fdc` |
| arm64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-arm64) | v1.45.0 | `275ae50ac97e6a70eee72e6de37766c458775c5997c896352db5189c6cf1f04b` |
| loong64 | Node.js | [unofficial-builds.nodejs.org](https://unofficial-builds.nodejs.org/download/release/v24.19.0/node-v24.19.0-linux-loong64.tar.xz) | v24.19.0 | `c24f224726f2d785bd18a1fd09f5e6d1fecf0269928451a60c5da9eac8e92e68` |
| loong64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-loong64) | v1.45.0 | `28bf67981168dfc4bd67698b41dd62628aafe347a77f2b1e6ffcadf009d575e0` |
| mac-arm64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-darwin-arm64.tar.xz) | v24.19.0 | `3f1cf157479c1480352083105e13faf9d008ede98e7e157746b6df940d197b94` |
| mac-arm64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-mac-arm64) | v1.45.0 | `639ed58b84820b3d588f4161c64d0ab940d0cc6e7d022088d60c2b0b97f99f8e` |
| mac-x64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-darwin-x64.tar.xz) | v24.19.0 | `d35e95230f46f6f0751df497c56622c6735e05d5e1fb1630996a005b9d328fe4` |
| mac-x64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-mac-amd64) | v1.45.0 | `fd519903f5630e881e38e7c5814f00c0e89ad26f6785f1ddcbab4058356fc9f3` |
| ppc64le | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-ppc64le.tar.xz) | v24.19.0 | `c510c6ce12f07010f771e6edb22a3fe23f4f2e6f40b1ffd4941aed0646a0d8b3` |
| ppc64le | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-ppc64le) | v1.45.0 | `de4518c7774d302533369c477759ddd866785d6741d98d399388eb8de3df175a` |
| riscv64 | Node.js | [unofficial-builds.nodejs.org](https://unofficial-builds.nodejs.org/download/release/v24.19.0/node-v24.19.0-linux-riscv64.tar.xz) | v24.19.0 | `cd1f14af2812148002f58b58a5f9af512a50e3b8e8c148e0db44019dcb68edfd` |
| riscv64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-riscv64) | v1.45.0 | `7dc2952f554e8800c4029577901999e06e10272da686f7e402177080067028f9` |
| s390x | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-linux-s390x.tar.xz) | v24.19.0 | `a4792e65962ffa0af42627aacf1122a60c3c88dbf4e4184f06820d66f9da8ba4` |
| s390x | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-s390x) | v1.45.0 | `0ae2e2f2cffdc5dd2ea4f125281a5e12eea216fbe49b5561d9c001700c3fc0c1` |
| win64 | Node.js | [nodejs.org](https://nodejs.org/dist/v24.19.0/node-v24.19.0-win-x64.zip) | v24.19.0 | `57f71ab3652e797d84acddc79c81cc9ff1c6ddb2a1974cdb83f00fee9bff4c73` |
| win64 | FerretDB | [wekan/FerretDB](https://github.com/wekan/FerretDB/releases/download/v1.45.0/ferretdb-win64.exe) | v1.45.0 | `f6337994368a52d011d438c82b914b0cedb3178fd030acac8db3dab8017cee85` |
This release fixes the following CRITICAL SECURITY ISSUES:
**Avatars and board export** - where an uploaded picture lives on disk, and
what an export is allowed to read.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/0a9eb0ef4fb3600917ef8b55d4c6afb089873e98">PathBleed: an avatar could name any file on disk, and board export read it</a>. Thanks to Alpastx and xet7.</summary>
[PathBleed](https://wekan.fi/hall-of-fame/pathbleed/) -
[GHSA-4mxf-m8pq-xc9p](https://github.com/wekan/wekan/security/advisories/GHSA-4mxf-m8pq-xc9p),
High, CWE-22 and CWE-73. Attachments blocked client-supplied
`versions.*.path`; avatars did not. The avatar allow rule was the single line
`update: isOwner` - being the owner let you write ANY field of your own avatar
document, `versions.original.path` included. That field is server-managed: it
says where on disk the bytes are.
Board export then read it. For every member whose avatar is a local WeKan file
the exporter opens that path and embeds the bytes as base64 in
`profile.avatarFile`, so an authenticated user could point their own avatar at
`/etc/passwd` - or anything else under `WRITABLE_PATH` - export a board they
are a member of, and decode the file out of the JSON. Arbitrary file read as
the WeKan OS user, from any account that can own an avatar.
Both halves are closed. The WRITE: the guards attachments had now live in one
module and BOTH permission files import it - avatars had gone without them
precisely because each collection wrote its own copy. Avatars refuse an insert
carrying `versions.*.path` or `.storage`, refuse any update touching the
versions subtree, and restrict updates to the same field whitelist attachments
use; being the owner is still required and no longer sufficient. The READ:
nothing is read from a stored path unless it RESOLVES to somewhere inside
WeKan's own storage. That half also holds when a path is poisoned some other way
- a document written before this fix, a restored backup, a bad migration - and
it sits at the one place export turns a stored path into bytes, for attachments
and avatars alike. The download path had always checked containment this way;
its private copy of the function is gone, so download and export ask the same
question. The streaming attachment export, which read a stored path with
nothing but an `existsSync`, was the last place left and takes the same check -
not the reported hole, since the attachment allow rule refuses a client-supplied
path, but a path is only as trustworthy as every way it could have been written.
</details>
**The board publication** - who is sent a board, and which of its cards.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/08baf7fd1b76a9bb889de2220bdc10c36aad6a65">RevokeBleed: revoking an org, team or domain share did not revoke it</a>. Thanks to Alpastx and xet7.</summary>
[RevokeBleed](https://wekan.fi/hall-of-fame/revokebleed/) -
[GHSA-gwc4-fw7p-gw58](https://github.com/wekan/wekan/security/advisories/GHSA-gwc4-fw7p-gw58),
Moderate, CWE-639 and CWE-863. `isActive: false` is how a board admin REVOKES a
share with an organization, a team or an email domain. All Boards honoured it -
it matches shares with `$elemMatch: { ..., isActive: true }` - so the board
disappeared from the revoked user's list and everything looked correct.
The `board` publication did not. That is the publication that sends the board
document and its lists, swimlanes, cards, comments and attachments, and it
matched shares with `'orgs.orgId': { $in: orgsIds }` instead. A dotted path
matches an element of the array without saying anything about that element's
other fields, so `isActive` was never consulted: anyone who still knew the
boardId - a bookmark, a browser tab, a note - could subscribe and receive the
whole private board after their access had been taken away. A revoke the
primary data publication does not honour is not a revoke.
The rule was written out twice and the two copies disagreed, so it is written
once now: one builder makes the `$or`, both `Boards.userBoards` and the `board`
publication call it, and every share kind is matched with `$elemMatch`
requiring `isActive: true`. `includePublic: false` still drops the public clause
and only that clause, which is what the search over all boards needs.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/35daaaa2795e83da9a23172d6279a9ba63ef1b0f">ParentBleed: one card id bridged a private board into another board's feed</a>. Thanks to Alpastx and xet7.</summary>
[ParentBleed](https://wekan.fi/hall-of-fame/parentbleed/) -
[GHSA-jvv9-498p-hxrg](https://github.com/wekan/wekan/security/advisories/GHSA-jvv9-498p-hxrg),
Moderate, CWE-200 and CWE-862. A card's `parentId` may name a card on ANOTHER
board, and setting it was authorized only against the CHILD board's write ACL.
Nothing asked whether the person setting it, or the people subscribed to the
child board, were allowed to see the other board at all.
The board publication walks the whole ancestor chain, because the
prefix-with-full-path subtask setting renders a subtask's complete path
(\#3453), and it published the complete ancestor card DOCUMENTS to every
subscriber of the child board. A member who could write on shared board B and
knew a card id on private board A could point a card at it and have A's card -
title, description, custom fields - delivered over DDP to people who are not
members of A.
Closed at both ends, because either alone leaves half the hole open. The WRITE
refuses a parent whose board the actor cannot see - one question, asked in one
place with the same selectors the publication and All Boards use, so an ACTIVE
org, team or domain share counts and a revoked one does not. It is enforced on
the REST card create, on the REST card update, and on DDP by a deny rule
covering insert as well as update, beside the cross-board MOVE deny that
[GHSA-gm7v-pc38-53jr](https://wekan.fi/hall-of-fame/boardbleed/) added. The
PUBLICATION sends only the ancestors whose board the subscriber may see; the
board being published is its own answer, so an ordinary same-board subtask path
is unchanged. This is the shape of check the linked-card path already made -
creating a linked card requires read access to the source card's board -
applied to the field that did not have it.
[Auditing for more of the same](https://github.com/wekan/wekan/commit/2c4e81ae3)
found the LINKED-CARD cursors beside the ancestor one with the identical hole
and a wider blast radius: five of them, publishing the linked card, its
comments, its attachments, its checklists and its checklist items. A
`cardType-linkedCard` names a card by id exactly as `parentId` does,
and that card may live on any board. They take the same answer - a linked card
whose source board the subscriber cannot see is not sent - and they share one
helper now instead of repeating the same fifteen-line preamble five times, which
is what stops the sixth from being written without the check.
</details>
**The REST API** - what an HTTP caller may do to somebody else's content.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/a8fa6cfa3fb6c519245a4425399da3a9838bea95">CommentBleed: the REST API let any board member delete anyone's comment</a>. Thanks to Alpastx and xet7.</summary>
[CommentBleed](https://wekan.fi/hall-of-fame/commentbleed/) -
[GHSA-pqr4-rxgp-hv2m](https://github.com/wekan/wekan/security/advisories/GHSA-pqr4-rxgp-hv2m),
Moderate, CWE-639 and CWE-863. Over DDP, deleting a comment is
author-or-board-admin, and a board that sets `restrictCommentEditing` takes even
the admin's ability away (\#5906). That rule was enforced in a collection hook
keyed off the Meteor userId, with an early return for the genuine
server-internal callers - board copy, cleanup, migrations - that run with no
authenticated user.
An HTTP request carries no Meteor userId into the invocation context. So
`DELETE /api/boards/:boardId/cards/:cardId/comments/:commentId`, which checked
only board MEMBERSHIP, reached the collection with no user, the hook took its
"server-internal, trust it" path, and any normal member could delete any comment
on the board: HTTP 200, the comment gone, `restrictCommentEditing` or not, while
the same deletion over DDP was correctly refused. Harassment and evidence
destruction on a shared board.
The fix does not rely on the hook seeing something it cannot see. The handler
loads the comment - 404 when there is none - and applies the same rule itself,
with the REST caller's id, before removing anything. That rule is now an
exported function the hooks and the handler both call, so DDP and HTTP cannot
enforce different things, and it carries a 403 so a refusal answers Forbidden
rather than 500. The no-userId path stays, documented for the internal callers
it was written for.
</details>
and adds the following new features:
**Admin Panel / Problems / Security** - what an admin is told when somebody
probes.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/d577f0770">Canary tokens record who tried to override permissions, and from where</a>. Thanks to xet7.</summary>
The security event log said what a guard DID - a request blocked, a filename
sanitized - which answers "is WeKan defending itself" and not the question an
admin asks next: **who did that, and from where**. It also could not tell a
browser that got confused from somebody working through the
[Hall of Fame](https://wekan.fi/hall-of-fame/) one entry at a time.
A **canary** is a tripwire at a point that only a permission-override attempt
reaches. Ordinary use never gets there, so a trip is not noise. Three properties
define one, and each is enforced by a test.
**Silent.** Tripping one changes nothing the caller can observe: `tripCanary()`
always returns `false` and `tripCanaryDeny()` always `true`, so a call site
reads as the refusal it replaces, and the REST handler re-throws the ORIGINAL
error. This is not politeness - a canary that announces itself is a map of which
paths are watched, and a probe would avoid them.
**Bounded.** A canary sits where an attacker can loop, so one database row per
attempt would be a denial of service they get for free. The first trip of a
(canary, actor) pair is recorded at once, the rest of the window are COUNTED,
and one summary carrying the total is written when the window closes; a pair
that never gives up stops writing after sixty summaries. The tracked-pair map is
capped and evicts the least RECENTLY seen, so a long-running attacker is not
pushed out by a passing one. A thousand attempts in a minute cost **one row**,
and a suppressed trip costs one map lookup.
**Attributed.** Every event carries the account, the **username**, the **IP
address** and the attempt count. The username is stored at write time on purpose
- it is what the account was called when it tried, so a rename does not rewrite
history. The address uses the same spoofing-safe rule as the login throttle, or
an attacker could write a colleague's address into the security log by sending a
header.
Seventeen canaries sit at the permission checks that refuse the attempts behind
BoardBleed, ParentBleed, ChecklistBleed, PathBleed and CommentBleed, and nine
more cover **NoSQL injection** (an execution operator in a client selector, or
`{"$ne": null}` where a typed value belongs), **SQL injection** (the database's
own guard now marks its refusal so the attempt reaches the admin instead of a
log file), **sanitization that removed something dangerous** rather than merely
tidying, a **forged forwarded-for header**, and a **login lockout**. All
server-side: no browser, nothing to install.
Admin Panel / Problems / Security gains **Username**, **IP address** and
**Attempts** columns, both new ones searchable - the thing an admin does with
one security event is pivot on it.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/a260a53e">The database marks the operations WeKan never issues, so an operator sees them</a>. Thanks to xet7.</summary>
FerretDB is reached over a local socket by one application, whose driver is a
Meteor 3 one. That makes a class of operations interesting by their mere
presence: server-side JavaScript (`$where`, `eval`, `$function`), an aggregation
writing into a collection (`$out`, `$merge`), dropping a database, a
server-administration command. The driver does not send them, so a request that
does is either a bug or somebody who reached the socket and is looking around.
`internal/util/canary` refuses them with the ordinary *"operation not supported
by this build"* - the same answer an unimplemented command gets - and appends
`canary:<id>`, which WeKan reads off the error and records with the account and
the address. The package **writes nothing**: no file, no table, no counter, so
hammering it costs one string comparison per request. The **SQL guard** marks
its refusals the same way; it already refused a statement carrying what only
injection produces, but a line in the database's own log is not somewhere
anybody looks.
On MongoDB there is no FerretDB to mark anything and these operations simply
never appear, so the feature degrades to nothing rather than misbehaving.
</details>
**Admin Panel / Problems / Filesystem integrity** - whether the stored files
are still the files WeKan stored.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/2cc0d42dd">A daily paced check of every stored file's name, date and four hashes</a>. Thanks to xet7.</summary>
Attachments and avatars are files under `WRITABLE_PATH`, and the database holds
one document per file. **Nothing checked that the two still agree.** A file can
be replaced, truncated, back-dated or deleted by anything that reaches the
filesystem - a bad restore, a sync tool, a container rebuild, a shell on the
volume - and WeKan would keep serving whatever is there now.
A baseline per file now lives in the existing WeKan database (no new files under
`WRITABLE_PATH`): path, size, modification time and **md5, sha256 and sha512**.
Three, because md5 is what other tools print - so an admin can compare with a
backup using what they already have - and because two digests over the same
bytes cannot disagree: when they do, the bytes were not read the same way twice,
which is a failing disk rather than a substitution, and is its own critical
finding. **ed25519** is the fourth check and is not a hash but a signature,
answering what the digests cannot: *who says these are the right hashes?*
Anybody who can rewrite a file can rewrite a row of hashes, so each entry is
signed and verified on every scan.
The scan runs **once a day**, never at or above **60% CPU**, with a **pause
between every file** (50 ms, plus 20 ms per megabyte) and a **fifteen-minute
budget** after which it stops and continues tomorrow - and reads each file
**once** for all three digests. A run that stopped early does not report what it
never reached as missing.
The finding is a change **with no record saying why**. A change WeKan made is
reported once and re-baselined; a change nothing accounts for keeps showing
until somebody looks. Crashes and downtime are in the same stream, from a
heartbeat the next start reads: a first run and a clean stop record nothing, and
a long gap with no clean-shutdown mark says so, with how long the server was
down.
</details>
and updates the following dependencies:
- **@aws-sdk/lib-storage 3.1085.0 → 3.1104.0** — the S3 multipart uploader the
optional S3 attachment storage uses.
- **markdown-it 15.0.0** — the markdown renderer behind card descriptions and
comments. A major version; its breaking changes are in plugin APIs WeKan does
not use.
- **@playwright/test 1.62.0 → 1.62.1** — the browser test runner, in
`tests/playwright` only; it ships in no WeKan bundle.
- **actions/checkout 4 → 7**, **actions/download-artifact 4 → 8**,
**actions/upload-artifact 4 → 7** — the GitHub Actions steps every release
workflow starts and ends with. Build-time only.
Thanks to dependabot.
and fixes the following bugs:
**Comment reactions** - who a reaction says it belongs to.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/156121c4bc0428a5a1edf5db9fbda6cac916d3ea">React as yourself, not as somebody else</a>. Thanks to xet7.</summary>
The same shape as CommentBleed, one collection over. A `CardCommentReactions`
document holds
`{ cardCommentId, reactions: [ { reactionCodepoint, userIds } ] }` and the whole
array is ONE field, whose allow rule was board membership for
insert, update and remove alike. So any member could `$set` `reactions` to
anything: add a colleague's userId to a reaction they never made, or remove one
they did. `toggleReaction()` only ever touches the caller's own id, so no
legitimate client sends anything else - the rule simply never said so.
Integrity rather than confidentiality, since reactions are visible to the whole
board already, but it puts words in another person's mouth. A deny rule now
refuses an update that changes any OTHER user's presence in any reaction. The
decision compares MEMBERSHIP rather than array order, because the client
rebuilds the array on every toggle and a reordered array with the same
membership is the same set of reactions. The modifier forms that cannot be
checked that way - `$push`, `$pull`, `$addToSet`, `$unset`, a dotted
`reactions.0.userIds` - are refused outright. Read-only and no-comment members
still may not react at all, as before.
</details>
**Admin Panel / Problems / Database problems** - two of its own reports, acted
on.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/30e8e38f6">Fix the two bugs the Database problems page was reporting</a>. Thanks to xet7.</summary>
The page was doing its job and nobody had acted on it. `moveSwimlane` threw
*"update is not available on the server. Please use updateAsync()"* four times
in
a week, because the default-swimlane self-heal called the synchronous
`Swimlanes.upsert()` that Meteor 3 removed on the server; it starts the async
one
without waiting now, since the getter around it cannot await. `moveList` threw
*"ValidationError: Failed validation, Cannot read properties of undefined
(reading 'title')"* when a list had no title: the insert failed schema
validation and collection2's error formatter then crashed on the undefined
field, so the admin saw neither the list nor the real problem. It now says
"This list has no title, so it cannot be moved to another board" before the
insert, and the two `console.log` lines that printed the title to a log nobody
reads are gone.
Both errors also gained a classifier rule, so neither reads as *unknown /
unclassified* again: they say plainly that this is WeKan's bug rather than the
database's or the admin's, and where to report it.
</details>
and has the following developer-facing changes:
**The test suite** - what it claims to guard, and what it actually does.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/a4a9c03b1fc75d61760a35c87decbaf7a3081973">Security tests say which vulnerability they guard, and the list is checked</a>. Thanks to xet7.</summary>
"Are the security tests enough to prevent what is in the Hall of Fame" was
unanswerable. WeKan publishes 58 named vulnerabilities; some suites named the
one they belong to, most did not, and the Hall of Fame lives in a different
repository that CI never checks out - so answering it meant reading 58 pages
against 300 suites by hand, which means it was never answered. A regression test
that quietly stops existing is worth nothing, which is the failure mode
`tests/testsAreRegistered.test.cjs` already caught once, when two \*bleed suites
had drifted out of the mocha index.
The list lives in the repository that has the tests now, and
`tests/securityRegressionCoverage.test.cjs` keeps the two in step. Every
published vulnerability is either GUARDED - named by a suite that still exists -
or RECORDED, a gap with a written reason, which is this CHANGELOG's `TODO Later`
pattern applied to tests. The gap count is pinned, so a vulnerability cannot be
published with neither a test nor a note; a gap that turns out to be guarded
after all fails too, so coverage is never understated; and a file that merely
REGISTERS suites is not accepted as coverage, or the guard would pass itself.
Getting there meant naming vulnerabilities in the suites that already guarded
them. `tests/securityMeifukun.test.cjs` guards eight reports and named one: its
sections are RedirectBleed, SourceBleed, LiveBleed, CasBleed, OidcBleed,
MetricsBleed, ImpersonateBleed and InviteBleed - seven vulnerabilities that
looked untested and were not. `tests/noIdentityReplacement.test.cjs` guards
IdentityBleed and PatternBleed. ExportBleed, CrashBleed, MimeBleed and the four
LockoutBleed suites now say so too, and the cross-board suite also checks
BoardBleed's move deny on Lists and Swimlanes, not only Cards.
The count that comes out of it: **29 of 58 published vulnerabilities have a
named regression test, and 29 are recorded gaps** - mostly older fixes from
before WeKan tested its security fixes at all. They are not known to be
unprotected; they are known to be unchecked, which is a different and more
honest statement, and each one now says what it would take to close it.
</details>
**The release and setup scripts** - what the build menu offers, and what it
still carries.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/6def3a14d">Setup: "git pull" and "git push" that finish the job, replacing "Update git"</a>. Thanks to xet7.</summary>
The build menu's `Update git` did a `git pull` and left it there, so a
contributor who used it still had to know the other half by heart. It is two
entries now - one that pulls and one that pushes - and each does the whole
thing, submodules included, rather than the first step of it.
</details>
<details>
<summary><a href="https://github.com/wekan/wekan/commit/8c2565e99">releases/: delete two superseded scripts, and two exemptions that outlived them</a>. Thanks to xet7.</summary>
Two scripts in `releases/` had been replaced by the release workflow and were
kept only because guards had been written to exempt them. Both the scripts and
their exemptions are gone, so the guards now describe what is really there -
an exemption that outlives its reason is how a check quietly stops checking.
</details>
Thanks to above GitHub users for their contributions and translators for their
translations.
# v10.73 2026-08-08 WeKan ® release
**In short:** a **GitHub CodeQL** finding fixed after v10.72 was tagged -
+1 -1
View File
@@ -12,7 +12,7 @@ LABEL org.opencontainers.image.source="https://github.com/wekan/wekan"
# TARGETARCH and TARGETVARIANT are automatically provided by Docker Buildx
ARG TARGETARCH
ARG TARGETVARIANT
ARG VERSION=10.73
ARG VERSION=10.76
ARG DEBIAN_FRONTEND=noninteractive
ENV BUILD_DEPS="apt-utils gnupg wget bzip2 g++ curl libarchive-tools build-essential git ca-certificates python3 unzip"
+1 -1
View File
@@ -1,5 +1,5 @@
appId: wekan-public/apps/77b94f60-dec9-0136-304e-16ff53095928
appVersion: "v10.73.0"
appVersion: "v10.76.0"
files:
userUploads:
- README.md
+29 -41
View File
@@ -100,12 +100,14 @@ echo.
echo -- Setup -- ^(0 = Back^)
echo 1^) Install dependencies
echo 2^) Build WeKan
echo 3^) Update git ^(fetch + rebase onto origin, fix CHANGELOG hashes, status^)
echo 3^) git pull ^(fetch, fast-forward or rebase, repoint moved CHANGELOG links^)
echo 4^) git push ^(check the CHANGELOG links, push, pull-and-retry once if rejected^)
set "choice="
set /p "choice=Choose: "
if "%choice%"=="1" goto install
if "%choice%"=="2" goto build
if "%choice%"=="3" goto updategit
if "%choice%"=="3" goto gitpull
if "%choice%"=="4" goto gitpush
if "%choice%"=="0" goto menu
goto menu_setup
@@ -674,6 +676,11 @@ if /I "%K%"=="push-copy-en-gb-translation" (set "CMD=bash releases/translations/
if /I "%K%"=="report-english-regressions" (set "CMD=node releases/translations/report-english-regressions.mjs" ^& goto cli_go)
if /I "%K%"=="verify-human-preference" (set "CMD=node releases/translations/verify-human-preference.mjs" ^& goto cli_go)
if /I "%K%"=="merge-translations" (set "CMD=node releases/translations/merge-translations.mjs" ^& goto cli_go)
REM `git pull` and `git push` are shell functions in build.sh, not scripts in
REM releases/, so this answers to the same two names with its own labels - the
REM ones the interactive menu already uses.
if /I "%K%"=="git-pull" (goto gitpull)
if /I "%K%"=="git-push" (goto gitpush)
if /I "%K%"=="commit" (set "CMD=bash releases/commit.sh" ^& goto cli_go)
if /I "%K%"=="git-add-revert" (set "SHCMD=git restore --staged" ^& goto cli_go)
if /I "%K%"=="delete-branch-local-and-remote" (set "CMD=bash releases/delete-branch-local-and-remote.sh" ^& goto cli_go)
@@ -801,51 +808,32 @@ echo Done.
goto end
REM ===========================================================================
REM Make the working copy current in one step: fetch + rebase the current branch
REM onto its upstream, repoint any CHANGELOG commit links the rebase made stale
REM (the same shared releases\fix-changelog-hashes.sh release-all.sh uses), and
REM show the status. Parity with build.sh's Setup -> "Update git ...".
:updategit
echo Updating git: fetch + rebase onto origin, fix CHANGELOG hashes, show status.
git rev-parse --git-dir >nul 2>&1
if errorlevel 1 ( echo Not a git repository. & goto end )
set "BRANCH="
for /f "delims=" %%b in ('git rev-parse --abbrev-ref HEAD 2^>nul') do set "BRANCH=%%b"
echo Branch: %BRANCH%
echo (A dirty tree is auto-stashed for the rebase and re-applied after.)
echo --- git fetch --all --prune ---
git fetch --all --prune
git rev-parse --verify --quiet "origin/%BRANCH%" >nul 2>&1
if errorlevel 1 (
echo No upstream origin/%BRANCH% - skipping rebase.
) else (
echo --- git pull --rebase --autostash origin %BRANCH% ---
git pull --rebase --autostash origin "%BRANCH%"
if errorlevel 1 (
echo.
echo Rebase stopped ^(conflicts^). Resolve them, then:
echo git rebase --continue ^(or: git rebase --abort^)
echo and run this option again to finish the hash fix + status.
goto end
)
)
echo --- Fixing CHANGELOG commit links ^(releases\fix-changelog-hashes.sh^) ---
echo ^(whole file, including released sections: bash releases/fix-changelog-hashes.sh --all-sections^)
:gitpull
REM Both git actions are build.sh's git_pull / git_push, run through bash rather
REM than written a second time here. What they do - fast-forward or rebase,
REM repoint the CHANGELOG links a rebase moved, abort a conflicting rebase so the
REM repo is left exactly as it was, pull-and-retry once when a push is rejected -
REM is shell logic, and the batch copy of the old "Update git" that used to live
REM here is why this file drifted: it reimplemented the same steps, so a fix to
REM one never reached the other.
where bash >nul 2>&1
if errorlevel 1 (
echo bash not found - the CHANGELOG hash fix needs Git Bash, bundled with Git for Windows.
echo Skipping it. Run it yourself in Git Bash: bash releases/fix-changelog-hashes.sh
) else (
bash releases/fix-changelog-hashes.sh
echo ERROR: bash was not found. It comes with Git for Windows ^(Git Bash^) and with WSL.
goto end
)
echo --- git status ---
git status
echo.
echo NOTE: if CHANGELOG.md was changed above, review 'git diff CHANGELOG.md' and
echo commit it - this script never commits for you.
bash ./build.sh git-pull
goto end
REM ===========================================================================
:gitpush
where bash >nul 2>&1
if errorlevel 1 (
echo ERROR: bash was not found. It comes with Git for Windows ^(Git Bash^) and with WSL.
goto end
)
bash ./build.sh git-push
goto end
:dev_local
call :ensure_dirs
call :set_dev_env
+157 -51
View File
@@ -1637,63 +1637,161 @@ function ask_dev_url(){
esac
}
# ── Update git: make the working copy current in one step ────────────────────
# ── Keeping the checkout in step with origin ─────────────────────────────────
# Fetch + rebase the current branch onto its upstream, then repoint any CHANGELOG
# commit links the rebase made stale (same shared script release-all.sh uses), and
# show the resulting status. One menu action instead of the fetch/pull/rebase/
# hash-fix/status dance by hand.
function update_git(){
git rev-parse --git-dir >/dev/null 2>&1 || { echo "Not a git repository."; return 0; }
local branch upstream here
here="$(cd "$(dirname "$0")" && pwd)"
branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null)"
echo "== Update git — branch: ${branch:-?} =="
# ── git pull / git push, doing the whole job ─────────────────────────────────
#
# These replace the old "Update git", which did a fetch+rebase, ran the CHANGELOG
# hash repair, printed `git status` and left the rest to the reader. The rest is
# where the mistakes were: a rebase rewrites local commits, so the CHANGELOG links
# written before it point at commits that no longer exist; the repair fixes the
# file but commits nothing, so the next push carries a dirty tree; and a push
# rejected as non-fast-forward leaves a repo that needs a pull the caller has to
# know to run.
#
# So: one action per direction, each ending in a state that is either correct or
# unchanged. Conflicts are the one thing a script must not paper over - a rebase
# that stops half-applied IS the wrong state - so on conflict the rebase is
# aborted, the working tree is exactly what it was, and the message says what to
# do by hand.
#
# Nothing here force-pushes, ever.
# Do not surprise anyone: a dirty tree is stashed by --autostash below, but say
# so first and let the user opt out.
if [ -n "$(git status --porcelain 2>/dev/null)" ]; then
echo "Working tree has uncommitted changes:"
git status --short
printf "They will be auto-stashed for the rebase and re-applied after. Continue? [y/N] "
read -r ans
case "$ans" in y|Y|yes|YES) : ;; *) echo "Aborted (nothing changed)."; return 0 ;; esac
fi
echo "--- git fetch --all --prune ---"
git fetch --all --prune || { echo "fetch failed - check network/remote and retry."; return 0; }
upstream="origin/$branch"
if git rev-parse --verify --quiet "$upstream" >/dev/null 2>&1; then
echo "--- git pull --rebase --autostash origin $branch ---"
if ! git pull --rebase --autostash origin "$branch"; then
echo
echo "Rebase stopped (conflicts or autostash conflict). Resolve them, then:"
echo " git rebase --continue # or: git rebase --abort"
echo "and run this option again to finish the hash fix + status."
return 0
fi
else
echo "No upstream '$upstream' - skipping rebase (nothing to pull for this branch)."
fi
# The rebase may have rewritten commits the CHANGELOG links to; repoint the
# stale links in the unreleased section (shared with releases/release-all.sh).
# A link is only repointed when NO ref in this clone reaches its commit, so a
# link into an old release tag - which GitHub serves fine - is left alone.
echo "--- Fixing CHANGELOG commit links (releases/fix-changelog-hashes.sh) ---"
bash "$here/releases/fix-changelog-hashes.sh" || true
echo " (whole file, including released sections:"
echo " bash releases/fix-changelog-hashes.sh --all-sections [--dry-run])"
echo "--- git status ---"
git status
# The CHANGELOG links after history moved. The repair only repoints a link whose
# commit is reachable from NO ref in this clone (a link into an old release tag
# is fine and is left alone), so running it when nothing moved is a no-op.
# Returns 0 when the file was changed and committed, 1 when there was nothing to do.
function git_fix_changelog_links(){
local script="$WEKAN_DIR/releases/fix-changelog-hashes.sh"
[ -f "$script" ] || return 1
bash "$script" || true
if [ -n "$(git status --porcelain -- CHANGELOG.md 2>/dev/null)" ]; then
echo
echo "NOTE: CHANGELOG.md was updated by the hash fix above."
echo " Review 'git diff CHANGELOG.md' and commit it (this script never commits for you)."
git add CHANGELOG.md
git commit -q -m "CHANGELOG: repoint commit links after history moved.
A rebase rewrote the commits these entries link to, so the links named commits
that no longer exist and would have 404ed on GitHub. Repointed by
releases/fix-changelog-hashes.sh, which only touches a link no ref in this clone
can reach.
Thanks to xet7 !"
echo "==> CHANGELOG commit links repointed and committed."
return 0
fi
return 1
}
# git pull: fast-forward when that is all it takes, rebase when the branch has
# diverged, and never end half-way through either.
function git_pull(){
git rev-parse --git-dir >/dev/null 2>&1 || { echo "Not a git repository."; return 1; }
local branch upstream before ahead behind
branch="$(git rev-parse --abbrev-ref HEAD)"
upstream="origin/$branch"
echo "== git pull - branch $branch =="
# The exact commit to return to if anything goes wrong.
before="$(git rev-parse HEAD)"
if [ -n "$(git status --porcelain)" ]; then
echo "==> Working tree is not clean; the changes are stashed for the pull and"
echo " re-applied afterwards (git's own --autostash)."
fi
echo "--- git fetch origin $branch ---"
git fetch origin "$branch" || { echo "ERROR: fetch failed - network or remote. Nothing changed."; return 1; }
git rev-parse --verify --quiet "$upstream" >/dev/null || {
echo "No $upstream yet - nothing to pull. Push first to create it."; return 0; }
behind="$(git rev-list --count HEAD.."$upstream")"
ahead="$(git rev-list --count "$upstream"..HEAD)"
echo " $ahead commit(s) here that origin does not have, $behind the other way."
if [ "$behind" -eq 0 ]; then
echo "==> Already up to date with $upstream."
elif [ "$ahead" -eq 0 ]; then
# Nothing of ours to replay: a fast-forward moves the branch pointer and
# rewrites no commit, so no link can go stale.
echo "--- fast-forward (no local commits to replay) ---"
git merge --ff-only "$upstream" || { echo "ERROR: fast-forward failed. Nothing changed."; return 1; }
else
echo "--- rebase: replaying $ahead local commit(s) onto $upstream ---"
echo " This gives them NEW hashes, which is why the CHANGELOG links are"
echo " repaired straight after."
if ! git -c rebase.autoStash=true rebase "$upstream"; then
git rebase --abort 2>/dev/null
git stash list >/dev/null 2>&1
echo
echo "ERROR: the rebase hit a conflict, so it was ABORTED - this repo is"
echo " exactly as it was before ($(git rev-parse --short "$before"))."
echo " Resolve it by hand:"
echo " git rebase $upstream # then fix the conflicts"
echo " git rebase --continue # or: git rebase --abort"
echo " and run this option again afterwards."
return 1
fi
fi
git_fix_changelog_links || echo "==> CHANGELOG commit links all resolve; nothing to repoint."
echo "--- git status ---"
git status --short --branch
return 0
}
# git push: make sure what is about to be published is correct first, and turn
# the one rejection that has an obvious answer into that answer.
function git_push(){
git rev-parse --git-dir >/dev/null 2>&1 || { echo "Not a git repository."; return 1; }
local branch ahead
branch="$(git rev-parse --abbrev-ref HEAD)"
echo "== git push - branch $branch =="
if [ -n "$(git status --porcelain)" ]; then
echo "==> NOTE: there are uncommitted changes; a push publishes commits only."
git status --short
fi
# Before publishing, not after: a stale link that reaches GitHub 404s for
# everyone who reads the release notes.
git_fix_changelog_links || echo "==> CHANGELOG commit links all resolve."
git fetch origin "$branch" >/dev/null 2>&1 || true
if git rev-parse --verify --quiet "origin/$branch" >/dev/null; then
ahead="$(git rev-list --count "origin/$branch"..HEAD)"
[ "$ahead" -eq 0 ] && { echo "==> Nothing to push; origin/$branch is already at this commit."; return 0; }
echo " $ahead commit(s) to push."
fi
echo "--- git push origin $branch ---"
if git push origin "$branch"; then
echo "==> Pushed."
return 0
fi
# The common rejection: origin moved while this was being written. That has
# one right answer - pull, then push again - so do it once, rather than
# printing the advice and leaving the caller to it. Once only: a second
# rejection is something else, and retrying a loop is not a fix.
echo
echo "==> Push was rejected. Pulling first, then trying once more."
git_pull || { echo "ERROR: the pull did not finish, so nothing was pushed."; return 1; }
echo "--- git push origin $branch (retry) ---"
if git push origin "$branch"; then
echo "==> Pushed."
return 0
fi
echo
echo "ERROR: still rejected. This is NOT a case to force - force-pushing a"
echo " branch somebody has pulled rewrites their history too. Read what"
echo " git said above; if origin has commits this clone should keep,"
echo " run this option's pull first and look at 'git log --oneline'."
return 1
}
echo
PS3='Please enter your choice: '
@@ -1813,6 +1911,8 @@ RELEASE_SCRIPTS=( "Release|Release ALL platforms: push CHANGELOG, trigger releas
"Translations|Report English strings that regressed|releases/translations/report-english-regressions.mjs|||"
"Translations|Prove a pull keeps human translations (no network)|releases/translations/verify-human-preference.mjs|||"
"Translations|Merge a finished pull by hand|releases/translations/merge-translations.mjs|||"
"Git and repo|git pull - fetch, fast-forward or rebase, repoint moved CHANGELOG links|!git_pull|||git-pull"
"Git and repo|git push - verify the CHANGELOG links, push, pull-and-retry once if origin moved|!git_push|||git-push"
"Git and repo|Commit with the editor open for a multi-line message|releases/commit.sh|||"
"Git and repo|Add everything, then revert it again|!git restore --staged|Path to unstage||git-add-revert"
"Git and repo|Delete a branch, locally and on the remote|releases/delete-branch-local-and-remote.sh|Branch name||"
@@ -2021,7 +2121,8 @@ while [ -z "$opt" ]; do
choose "Setup" \
"Install dependencies|Install WeKan dependencies" \
"Build WeKan|Build WeKan" \
"Update git|Update git: fetch + rebase onto origin, fix CHANGELOG hashes, show status" ;;
"git pull|git pull: fetch, fast-forward or rebase onto origin, repoint the CHANGELOG commit links the rebase moved, and leave the repo unchanged if anything conflicts" \
"git push|git push: check the CHANGELOG commit links resolve before publishing them, push this branch to origin, and pull-then-retry once if origin moved meanwhile" ;;
"Dev server")
choose "Dev server" \
"localhost:3000|Run Meteor for dev on http://localhost:3000" \
@@ -2163,8 +2264,13 @@ for _once in 1; do
break
;;
"Update git: fetch + rebase onto origin, fix CHANGELOG hashes, show status")
update_git
"git pull: fetch, fast-forward or rebase onto origin, repoint the CHANGELOG commit links the rebase moved, and leave the repo unchanged if anything conflicts")
git_pull
break
;;
"git push: check the CHANGELOG commit links resolve before publishing them, push this branch to origin, and pull-then-retry once if origin moved meanwhile")
git_push
break
;;
@@ -30,6 +30,8 @@ template(name="adminReports")
+eventStreamReport(stream="cpu")
else if showDatabase.get
+eventStreamReport(stream="database")
else if showIntegrity.get
+eventStreamReport(stream="integrity")
else if tablePageData
+tablePage(tablePageData)
+31 -2
View File
@@ -110,6 +110,7 @@ Template.adminReports.onCreated(function () {
this.showTests = new ReactiveVar(false);
this.showCpu = new ReactiveVar(false);
this.showDatabase = new ReactiveVar(false);
this.showIntegrity = new ReactiveVar(false);
this.error = new ReactiveVar('');
this.loading = new ReactiveVar(false);
@@ -315,6 +316,10 @@ const PROBLEMS_MENU = [
// What the database itself said, classified: which database type, what it
// means and what to do (server/lib/databaseProblems.js).
{ id: 'report-database', icon: 'fa-database', labelKey: 'databaseReportTitle' },
// What the FILESYSTEM said: a stored file that is no longer the file WeKan
// stored, and whether this server stopped cleanly
// (docs/Security/Remediation/WeKan.md §13).
{ id: 'report-integrity', icon: 'fa-fingerprint', labelKey: 'integrityReportTitle' },
];
Template.adminReports.helpers({
@@ -497,6 +502,7 @@ function openReportPane(tmpl, targetID) {
tmpl.showTests.set(false);
tmpl.showCpu.set(false);
tmpl.showDatabase.set(false);
tmpl.showIntegrity.set(false);
if (tmpl.subscription) {
tmpl.subscription.stop();
}
@@ -533,6 +539,9 @@ function openReportPane(tmpl, targetID) {
} else if ('report-database' === targetID) {
tmpl.showDatabase.set(true);
tmpl.loading.set(false);
} else if ('report-integrity' === targetID) {
tmpl.showIntegrity.set(true);
tmpl.loading.set(false);
} else if ('report-broken' === targetID) {
// A report like the others now: same controls, same paging, same loader.
tmpl.brokenPage.set(1);
@@ -835,8 +844,28 @@ const EVENT_STREAM_COLUMNS = [
{ labelKey: 'event-severity', value: r => r.severity, data: r => r.severity },
{ labelKey: 'event-action', value: r => r.action || r.type },
{ labelKey: 'event-source', value: r => r.source },
// The user who triggered the event (e.g. who uploaded a sanitized file).
{ labelKey: 'username', value: r => userName(r.userId), userId: r => r.userId },
// WHO tried it. The stored `username` wins over looking the account up: it is
// what the account was CALLED when the event happened, so a later rename does
// not rewrite history and a deleted account does not erase it. The lookup
// stays as the fallback for the older events that predate the stored field.
{
labelKey: 'username',
value: r => r.username || userName(r.userId),
userId: r => r.userId,
},
// FROM WHERE. Resolved with the same spoofing-safe rule as the login throttle -
// X-Forwarded-For only as far as HTTP_FORWARDED_COUNT says to trust it - so this
// column cannot be written by sending a header.
{ labelKey: 'event-ip', nowrap: true, value: r => r.ip || '' },
// HOW MANY attempts this row stands for. A canary counts repeats inside its
// window rather than writing one row each, so "1" is an ordinary event and a
// larger number is a burst that was deliberately not written out in full
// (docs/Security/Remediation/WeKan.md §12).
{
labelKey: 'event-attempts',
nowrap: true,
value: r => (r.count && r.count > 1 ? String(r.count) : ''),
},
// The `database` stream's detail is WeKan's reading of the error - what it means
// and what to do - and it is worth little without the sentence the database
// itself produced, which is the one thing an admin can search for or paste into
+1
View File
@@ -77,6 +77,7 @@ The bare `/admin/settings` still resolves; it *redirects* to
| `/admin/problems/cards` | Cards Report | `report-cards` |
| `/admin/problems/recovery` | Recovery | `report-recovery` |
| `/admin/problems/database` | Database problems | `report-database` |
| `/admin/problems/integrity` | Filesystem integrity | `report-integrity` |
Two panes are called Security, and they are different things: the *settings*
pane and the *report*. `security` is the settings one, beside `notifications`
@@ -10,7 +10,7 @@ This is without container (without Docker or Snap).
Right click and download files 1-4:
1. [wekan-10.73-win64.zip](https://github.com/wekan/wekan/releases/download/v10.73/wekan-10.73-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
1. [wekan-10.76-win64.zip](https://github.com/wekan/wekan/releases/download/v10.76/wekan-10.76-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
2. [node.exe](https://nodejs.org/dist/latest-v24.x/win-x64/node.exe)
@@ -22,7 +22,7 @@ Right click and download files 1-4:
6. Double click `mongodb-windows-x86_64-7.0.34-signed.msi` . In installer, uncheck downloading MongoDB compass.
7. Unzip `wekan-10.73-win64.zip` , inside it is directory `bundle`, to it copy other files:
7. Unzip `wekan-10.76-win64.zip` , inside it is directory `bundle`, to it copy other files:
```
bundle (directory)
+80
View File
@@ -70,6 +70,86 @@ problem *observable* to WeKan; the recording is WeKan's job. Concretely:
WeKan maps these to `eventlog` documents via the category catalog
(`models/lib/securityCategories.js`) using `source:'sqlite.*'`.
---
## 3b. Canary tokens — operations the client never issues
The database is reached over a **local socket by one application**, whose driver
is a Meteor 3 one. That makes a class of operations interesting **by their mere
presence**: server-side JavaScript evaluation, an aggregation writing its result
into a collection, dropping a database, a server-administration command. The
driver does not send them. A request that does is either a bug or somebody who
has reached the socket and is looking around — and both are worth telling the
operator about.
`internal/util/canary` (paired with `tests` in the same package) is the FerretDB
half of [WeKan.md §12](WeKan.md), and it keeps the same three properties:
- **SILENT.** A tripped canary returns the ordinary *"operation not supported by
this build"* refusal — the same answer an unimplemented command gets. The id is
appended as `canary:<id>`, which the client parses and the operator reads;
nothing in it says that anything was detected or recorded, so a probe cannot
tell a watched operation from an unimplemented one and route around the watched
ones. A Go test asserts the message contains none of *detect*, *record*, *log*,
*alert*.
- **BOUNDED.** The package writes **nothing** — no file, no table, no counter. It
is a map lookup and an error value, so a caller hammering it in a loop costs
this process one string comparison per request and this package **no memory at
all**. This is what §2's "FerretDB does not write to any database or file"
means for canaries, and a Go test asserts the package is stateless.
- **ATTRIBUTED.** The marker names *which* canary, so the operator's report says
"tried to run server-side JavaScript" rather than "an error".
The canaries:
| Id | Operations | Why the client never sends it |
| --- | --- | --- |
| `db.javascript` | `eval`, `$where`, `$function`, `$accumulator`, `mapReduce` | the driver has no feature that evaluates JavaScript in the database |
| `db.result-to-collection` | `$out`, `$merge` | aggregation results are read, never persisted by the database |
| `db.drop-database` | `dropDatabase`, `dropAllDatabases` | the application drops collections it owns; dropping the database is an operator action taken with the database's own tools |
| `db.server-admin` | `shutdown`, `setParameter`, `getParameter`, `profile`, `logRotate` | these manage the server, not the data |
`Check(op)` returns `nil` for everything else, and a Go test pins the ordinary
vocabulary — `find`, `insert`, `update`, `aggregate`, `$match`, `$group`,
`$lookup`, … — as **not** tripping. A canary that fires on normal traffic is
worse than no canary: it buries the real ones.
### How it reaches the operator
Exactly as §2 describes for every other FerretDB event — the database reports,
the client records:
```
FerretDB WeKan
Check(op) → canary:<id> in the error ──► recordDatabaseProblem()
│ databaseCanaryId() reads the marker
▼
tripCanary('database.canary') ← rate-limited,
│ aggregated,
▼ attributed
eventlog (stream:'security')
│
▼
Admin Panel → Problems → Security
```
Two things that are deliberate on the WeKan side
(`server/lib/databaseProblems.js`):
1. The id is **not trusted as a category**. It is matched against a known list,
and anything else is recorded generically. An error string is
attacker-influenced, and a marker parsed out of one must never be able to
choose which security category it lands in — or to inject anything, which is
why the extractor accepts only `[a-z][a-z0-9.-]{0,60}`.
2. A canary goes to the **security** stream and does **not** fall through to the
`database` stream. Filing it under "the database said something" would put an
intrusion attempt in the list of things to triage as configuration problems.
On **MongoDB** there is no FerretDB to mark anything, and these operations simply
never appear — the WeKan side is inert, and the feature degrades to nothing
rather than misbehaving. That is the same property as §2: storage and reporting
are WeKan's job, so nothing here depends on which database is underneath.
## 4. Security remediation points → logger
| Point | Remediation (present/added) | Logged as |
+327 -1
View File
@@ -351,7 +351,333 @@ test". It uses the same `events` schema and the same disk-space discipline, in t
---
## 11. Out of scope / follow-ups
## 12. Canary tokens — who tried it, and from where
§7 records what a guard **did**: a request was blocked, a filename sanitized, an
upload refused. That answers "is WeKan defending itself". It does not answer the
question an admin actually asks after seeing one — **who did that, and from
where** — and it does not distinguish a browser that got confused from somebody
working through the [hall of fame](https://wekan.fi/hall-of-fame/) one entry at
a time.
A **canary token** is a tripwire placed at a point that **only a permission-override
attempt reaches**. Ordinary use never gets there: the code path runs when a
request asks for something the rules forbid. So a trip is not noise, and it is
worth an admin's attention with the actor attached.
Three properties define one. Each is enforced by `tests/canaryTokens.test.cjs`
and `tests/canaryCoverage.test.cjs`.
### 12.1 SILENT — the attacker learns nothing
Tripping a canary changes **nothing the caller can observe**. The refusal keeps
its wording, its status, its timing and its shape. This is not politeness: a
canary that announces itself is a map of which paths are watched, and a probe
would simply avoid them.
So the trip function is written as a drop-in for the refusal it replaces:
```js
if (somethingForbidden) return false; // before
if (somethingForbidden) return tripCanary('card.vote-field', { userId }); // after
```
`tripCanary()` **always returns `false`**, and `tripCanaryDeny()` — for a Meteor
`deny` rule, which refuses by returning **true** — always returns `true`. The
return value is decided **before** any reporting is attempted, and the reporting
is wrapped, so a canary can never throw into, delay, or otherwise disturb the
request it is watching. A REST handler that must throw re-throws **the original
error object**, never a new one.
### 12.2 BOUNDED — probing must not cost more than it does now
A canary sits where an attacker can loop. Writing one database row per attempt
would be a denial of service they get for free: CPU on every insert, and a
collection that grows until the disk is full.
`models/lib/canaryTokens.js` (pure, no Meteor, no clock of its own — the runtime
passes the time in, which is how the tests drive it) decides per trip:
- the **first** trip of a `(canary, actor)` pair is recorded immediately, so a
probe shows up promptly;
- further trips **inside the window** (default 60 s) write nothing and are
**counted**;
- the first trip **after** the window writes **one summary** carrying the total,
which is what the `count` column shows;
- a pair that has already written `maxEventsPerPair` summaries (default 60)
keeps counting but **stops writing** until it goes quiet — so an endless slow
probe cannot write one row a minute forever;
- the map of tracked pairs is **capped** (default 5000) and evicts the **least
recently seen** entry, so a long-running attacker is not pushed out by a
passing one, and a botnet varying its address cannot grow it without bound;
- idle pairs are **swept** on a timer, so a long-lived server does not hold a row
per address seen since boot.
A thousand attempts in a minute therefore cost **one row**, and the decision to
suppress costs **one map lookup** — which is what makes a canary safe to put on a
path an attacker chooses to hammer. The username lookup is cached (60 s, capped
at 1000 entries), so probing cannot make WeKan do a database read per attempt
either, and neither the lookup nor the insert is awaited on the caller's path.
### 12.3 ATTRIBUTED — who, and from where
Every event carries:
| Field | Where it comes from |
| --- | --- |
| `userId` | the caller's own knowledge, else the DDP invocation |
| `username` | looked up once and cached; **stored on the event**, not resolved later |
| `ip` | the request, resolved with `resolveClientKey` — the SAME spoofing-safe rule as the login throttle |
| `count` | how many attempts this one row stands for (§12.2) |
The username is **denormalised at write time on purpose**: it is what the account
was *called when it tried*, so a later rename does not rewrite history and a
deleted account does not erase it.
The address honours `X-Forwarded-For` **only as far as `HTTP_FORWARDED_COUNT`
says to trust it**, exactly as `server/lib/loginAttemptThrottle.js` does.
Otherwise an attacker would write somebody else's address into the security log
by sending a header — turning the report into a way to frame a colleague.
`detail` says what was **attempted**, in the words of the feature ("tried to move
a card into a board they cannot write to"), and **never the payload**: it is
attacker-controlled text, and §4's truncation and control-character stripping is
the second line of defence behind that rule.
### 12.4 Where the canaries are
Each is `canaryId → the guard that trips it → the published vulnerability whose
ATTEMPT it watches`. The table is pinned by `tests/canaryCoverage.test.cjs`,
which also fails on a canary in the catalog that nothing trips.
| Canary | Tripped in | Watches the attempt behind |
| --- | --- | --- |
| `card.cross-board-move` | `server/permissions/cards.js` | BoardBleed |
| `card.invisible-parent` | `server/permissions/cards.js` | ParentBleed |
| `card.vote-field`, `card.poker-field` | `server/permissions/cards.js` | direct field writes that must go through a method |
| `list.cross-board-move` | `server/permissions/lists.js` | BoardBleed |
| `swimlane.cross-board-move` | `server/permissions/swimlanes.js` | BoardBleed |
| `checklist.cross-board-move` | `server/permissions/checklists.js` | ChecklistBleed |
| `checklist-item.cross-board-move` | `server/permissions/checklistItems.js` | ChecklistBleed |
| `avatar.version-path`, `avatar.restricted-field`, `avatar.not-owner` | `server/permissions/avatars.js` | PathBleed |
| `attachment.version-path`, `attachment.restricted-field` | `server/permissions/attachments.js` | PathBleed |
| `reaction.foreign` | `server/permissions/cardCommentReactions.js` | writing another user's reaction |
| `comment.foreign-delete` | `server/models/cardComments.js` | CommentBleed |
| `export.path-outside-storage` | `models/exporter.js` | PathBleed (a path poisoned some other way) |
| `database.canary` | `server/lib/databaseProblems.js` | [FerretDB.md](FerretDB.md) — an operation WeKan never issues |
**No Playwright, no browser.** Every canary above fires inside a server-side
permission check, so it works on any WeKan server with nothing installed. A
published vulnerability whose attempt leaves no server-side trace — a fixed XSS,
say, where the payload is refused in the browser — has no canary, and that is a
statement of what is detectable rather than an omission.
### 12.5 What the admin sees
Admin Panel → **Problems → Security** (§8), unchanged except for three columns:
**Username**, **IP address** and **Attempts**. Both new columns are **searchable**,
because the thing an admin does with one security event is pivot on it: every
other event from this address, every other event from this account.
A row reads:
```
2026-08-09 14:02:11 · authz · PathBleed · high · detected · canary:avatar.version-path
· mallory · 203.0.113.7 · 214
· tried to write the on-disk path of an avatar; 214 attempts in this window
```
---
### 12.6 Injection, sanitization, and the other common attacks
§12.4 watches **permission overrides**. Three more families are watched the same
way, and each has the same problem in common: the defence already existed and was
**silent**, so nobody ever learned that it had fired.
**NoSQL injection** (`models/lib/injectionDetect.js`, pure and unit-tested). Two
shapes, and they are different problems:
- an **execution operator** in a client-supplied selector — `$where`,
`$function`, `$accumulator`, `$out`, `$merge`. A selector is data; these turn
it into code the database runs. → `injection.nosql-selector`
- an **operator object where a scalar was expected** — `{"$ne": null}`,
`{"$gt": ""}`, `{"$regex": ".*"}` in a field that should hold the string a
user typed. This is how "match every row" is spelled in a document database
and it needs no JavaScript at all. → `injection.nosql-operator`
The detector is deliberately narrow about the second: **every** key must be an
operator. A plain object with ordinary keys is somebody sending the wrong type,
which is a validation error, and calling it an attack would fill the report with
noise. A `$` inside a *string* is text — a card titled `$100 refund` puts nobody
in the security report.
**SQL injection.** WeKan builds no SQL, so it cannot detect this itself. The
database does: FerretDB's `internal/util/sqlguard` refuses a statement carrying
what only injection produces, and now **marks the refusal** so the attempt
reaches the admin instead of dying in a log file
([FerretDB.md §3b](FerretDB.md)). → `injection.sql-statement`
**Sanitization.** Sanitizing is routine — a filename gets trimmed, a pasted
`<b>` gets stripped from a comment — and routine is not worth an admin's
attention. What is worth it is sanitization that removed something that would
have **done** something:
| Canary | Fires when |
| --- | --- |
| `sanitize.dangerous-filename` | an uploaded name carried an exploit pattern, invisible characters or URL encoding |
| `sanitize.path-traversal` | a file path tried to leave its directory |
| `sanitize.dangerous-content` | active markup was removed from an uploaded file (a script or `javascript:` URI inside an SVG) |
| `sanitize.dangerous-text` | active markup was removed from submitted text (a comment carrying `<script>`, an `onerror=`, a `javascript:` URI) |
`removedActiveMarkup()` decides the last two by comparing what the sanitizer took
**out**: text that never had any is not interesting, and text that still has it
was not sanitized at all — which would be a different bug.
**Other common attacks.**
| Canary | Fires when |
| --- | --- |
| `spoof.forwarded-header` | `/metrics` denied a request that carried `X-Forwarded-For` — nothing legitimate sends one to an endpoint that does not trust it |
| `brute.login-lockout` | a REST login was refused while the address was locked out, i.e. it had already failed the configured number of attempts in a row |
Both replaced a **direct `securityLog.record()`**. That matters for more than
tidiness: those two paths are **unauthenticated**, so a bare `record()` was one
database insert per request an attacker chose to send. Going through the canary
gives them the §12.2 rate limit and the §12.3 attribution, and the responses —
the 401 and the 429 — are untouched.
## 13. Filesystem storage integrity, crashes and downtime
§12 watches what somebody **tries to do through WeKan**. This section is about
what happened to WeKan's own files and process **when nobody was asking it
anything**.
WeKan's attachments and avatars are files under `WRITABLE_PATH`, and the database
holds one document per file. **Nothing checked that the two still agree.** A file
can be replaced, truncated, back-dated or deleted by anything that reaches the
filesystem — a bad restore, a sync tool, a container rebuild, a shell on the
volume — and WeKan would keep serving whatever is there now, silently.
### 13.1 The baseline, and the four hashes
One document per file in the existing WeKan database (`fileIntegrity`; **no new
files under `WRITABLE_PATH`**, per §3): the path, the size, the modification
time, and **md5, sha256 and sha512**.
Three digests, not one, because they answer different questions. **md5** is fast
and is what most other tools print, so an admin can compare against a backup with
the tool they already have — it is never alone here, because its collision
resistance is gone. **sha256** is the working digest. **sha512** is a second,
different-width one, and it earns its place on the day the two *disagree*: two
digests over the same bytes cannot disagree, so when they do, the bytes were not
read the same way twice — a failing disk, a partial write, a truncated copy —
which is a different problem from a substitution and is reported as its own,
**critical**, finding.
**ed25519 is the fourth, and it is not a hash — it is a signature**, which is the
only thing that answers the question the three digests cannot: *who says these
are the right hashes?* Anybody who can rewrite a file can rewrite a row of
hashes. So each baseline entry is signed over a canonical line
(`manifestLine()` — fixed field order, no JSON, whose key order and escaping are
not guaranteed stable across versions), and the signature is verified on every
scan. A record that does not match its signature is a **critical** finding, and
it is never explained away by an ordinary edit: WeKan does not rewrite a baseline
without re-signing it.
The key:
| Source | What it protects against |
| --- | --- |
| `WEKAN_INTEGRITY_PRIVATE_KEY` (PKCS#8 PEM), supplied by the operator and **never stored by WeKan** | an attacker who reaches the **database** as well as the disk |
| otherwise, generated on first run and kept in the database | filesystem-only tampering — a restore, a sync tool, a container rebuild, a shell on the volume |
The second is the common case and is honest about not being more than that. A
**malformed** supplied key is reported and the scan runs **without** signing
rather than silently falling back to a generated one, which would look like it
was working while checking nothing the operator meant.
### 13.2 The scan: once a day, when nothing else needs the machine
Reading every stored byte is the last thing that should compete with users, so
the scan is paced by policy rather than by hope (`models/lib/fileIntegrity.js`,
pure and time-injected):
- **once a day** (`intervalMs`), checked hourly — not on every restart;
- **not while the machine is busy**: at or above **60% CPU** it does not start,
and it stops if the load rises mid-run. It is never urgent;
- **a pause between every file** — 50 ms, plus 20 ms per megabyte, so a
directory of large files does not become a sustained read;
- **a time budget** (15 minutes). A scan that stops and continues tomorrow is
better than one that holds a machine down;
- **one read per file for all three digests.** Three passes would be three times
the disk for the same bytes.
A run that stopped early does **not** report the files it never reached as
missing — it has not looked everywhere, and saying otherwise would be a lie.
### 13.3 The finding: changed, with no record saying why
Files change when people use WeKan; reporting that would be noise. So each change
is classified against WeKan's own record of the file:
| Situation | Result |
| --- | --- |
| unchanged | nothing recorded |
| changed, and WeKan has a record of the operation | `info` — reported once, then **re-baselined** so it does not repeat daily |
| **changed, with nothing to account for it** | **the warning** — at least `medium`, and deliberately **not** re-baselined, so it keeps showing until somebody looks |
| the record's signature does not verify | `critical`, never explained |
| recorded and now missing | `high` |
| present and never recorded | `low` — a leftover or a backup copy is not evidence of anything |
A modification time that moved **backwards** is called out separately: nothing
does that by accident.
The event carries the **file's name and the finding, never its contents and never
the digests** — an admin needs to know which file, not to read it here.
### 13.4 Crashes, downtime and errors nobody caught
Admin Panel → Problems answered "what did WeKan refuse" and "what did the
database say", and not the plainest question: **did this server stop, and did it
stop cleanly?**
A crash leaves nothing behind — the process is gone, so it cannot write a message
about being gone. A **heartbeat** is the only thing that works: WeKan writes the
time every minute, and the NEXT start reads it.
| What the next start finds | Recorded as |
| --- | --- |
| no previous run | nothing — a first run is not a problem |
| last heartbeat with a **clean-shutdown mark** | nothing — a deliberate restart is not a problem |
| a **short** gap, no mark | `low` — the shutdown hook may simply not have run |
| a **long** gap, no mark | **`high`** — it stopped without shutting down cleanly, and here is how long it was down |
Recording every deliberate restart is how a Problems page becomes a page nobody
opens, which is why the first two record nothing at all.
Two more land in the same stream, because they are the same question one level
down: an **uncaught exception** and an **unhandled promise rejection**. This app
turns the second into a process exit, so it is a crash *with a cause attached* —
and the cause is worth recording before the process goes. The listeners are
**added, never replaced**: removing whatever else is listening would change how
the app handles its own failures.
### 13.5 Where it appears
A new event stream, `integrity`, in the same `eventlog` collection and the same
Admin Panel → **Problems → Filesystem integrity** table as the others (§8), with
the Username / IP address / Attempts columns §12.3 added. It counts towards the
red **Problems** button like every other stream.
The `fileIntegrity` baseline itself is **never published and never client-writable**:
it is a map of every file on the server, and the key document is a private key.
Neither belongs on a client, admin or not — Admin Panel reads the **findings**,
which are ordinary event rows.
---
## 14. Out of scope / follow-ups
- Rotating/retention: a cron trims `eventlog` to the newest N per stream (`remove` older docs).
- Shipping logs to an external SIEM (kept local by design).
+5 -2
View File
@@ -2339,5 +2339,8 @@
"event-severity": "Severity",
"event-action": "Action",
"event-source": "Source",
"event-detail": "Detail"
}
"event-detail": "Detail",
"event-ip": "IP address",
"event-attempts": "Attempts",
"integrityReportTitle": "Filesystem integrity"
}
+33 -9
View File
@@ -4,6 +4,7 @@ import { check, Match } from 'meteor/check';
import { Random } from 'meteor/random';
import { ReactiveCache } from '/imports/reactiveCache';
const { notHelperBoardTitle } = require('/models/lib/helperBoards');
const { boardVisibilitySelectors } = require('/models/lib/boardVisibilitySelectors');
import escapeForRegex from 'escape-string-regexp';
import CustomFields from './customFields';
import {
@@ -1862,7 +1863,29 @@ Boards.helpers({
// Upsert keyed on the deterministic _id: concurrent self-heals collide on
// the _id unique index, so only one insert wins instead of racing the
// check-then-insert and each inserting a new swimlane.
Swimlanes.upsert({ _id: defaultId }, { $setOnInsert: this.defaultSwimlaneFields() });
//
// It must be the ASYNC upsert. Meteor 3 removed the synchronous one on the
// server, and calling it threw
// Error: update is not available on the server. Please use updateAsync()
// out of every `moveSwimlane` that reached this self-heal - the failure an
// admin kept seeing in Admin Panel / Problems / Database problems. This
// getter is SYNCHRONOUS and cannot await, so the upsert is started and not
// waited for: the self-heal still happens, and this call returns whatever
// is in the cache, which the caller already had to handle (it was undefined
// before the self-heal existed too). A caller that needs the swimlane back
// in the same tick uses ensureDefaultSwimlaneIdAsync().
try {
const p = Swimlanes.upsertAsync(
{ _id: defaultId },
{ $setOnInsert: this.defaultSwimlaneFields() },
);
if (p && typeof p.catch === 'function') p.catch(() => {});
} catch (e) {
if (process.env.DEBUG === 'true') {
// eslint-disable-next-line no-console
console.warn('default swimlane self-heal failed:', e && e.message);
}
}
return ReactiveCache.getSwimlane({ _id: defaultId });
},
@@ -2452,14 +2475,15 @@ Boards.userBoards = (
if (selector.title === undefined) {
selector.title = notHelperBoardTitle();
}
selector.$or = [
...(includePublic ? [{ permission: 'public' }] : []),
{ members: { $elemMatch: { userId, isActive: true } } },
{ orgs: { $elemMatch: { orgId: { $in: user.orgIds() }, isActive: true } } },
{ teams: { $elemMatch: { teamId: { $in: user.teamIds() }, isActive: true } } },
// #5850: domain-based board sharing — board shared with the user's email domain.
{ domains: { $elemMatch: { domain: { $in: user.emailDomains() }, isActive: true } } },
];
// GHSA-gwc4-fw7p-gw58: the same builder the `board` publication uses, so
// "which boards may this user see" has one answer and cannot drift.
selector.$or = boardVisibilitySelectors({
userId,
orgIds: user.orgIds(),
teamIds: user.teamIds(),
emailDomains: user.emailDomains(),
includePublic,
});
return selector;
};
+56 -26
View File
@@ -17,6 +17,23 @@ function sanitizeText(text) {
previous = sanitized;
sanitized = sanitized.replace(/<[^>]*>/g, '');
} while (sanitized !== previous);
// A comment losing a `<b>` is an everyday thing and says nothing. A comment
// losing a SCRIPT TAG, an event handler or a javascript: URI is a stored-XSS
// attempt that this line defused, and an admin should be told who wrote it
// (docs/Security/Remediation/WeKan.md §12.6). The comment is stored sanitized
// either way; the author is told nothing.
if (Meteor.isServer && sanitized !== text) {
try {
const { removedActiveMarkup, CANARY_IDS } = require('/models/lib/injectionDetect');
if (removedActiveMarkup(text, sanitized)) {
require('/server/lib/canary').tripCanary(CANARY_IDS.text, {
detail: 'active markup removed from a comment',
});
}
} catch (e) { /* a canary must never break a comment */ }
}
return sanitized;
}
@@ -201,34 +218,47 @@ CardComments.helpers({
CardComments.hookOptions.after.update = { fetchPrevious: false };
if (Meteor.isServer) {
// Server-side enforcement of comment edit/delete permissions (issue #5906).
// Server-side enforcement of comment edit/delete permissions (issue #5906).
//
// The DDP `allow` rule in server/permissions/cardComments.js is the first gate,
// but the per-board `restrictCommentEditing` setting is enforced here so the
// rule cannot be bypassed and the decision lives next to the data. Exported so
// the REST handlers can enforce the SAME rule (GHSA-pqr4-rxgp-hv2m) rather than
// relying on collection hooks, which cannot see an HTTP caller's identity.
export async function assertCanMutateComment(userId, doc) {
// Server-internal operations (board copy, cleanup, migrations, etc.) run
// without an authenticated user; do not block those here. User-initiated
// DDP calls always carry a userId and are still gated by the allow rule.
//
// The DDP `allow` rule in server/permissions/cardComments.js is the first
// gate, but the per-board `restrictCommentEditing` setting is enforced here
// so the rule cannot be bypassed and the decision lives next to the data.
const assertCanMutateComment = async (userId, doc) => {
// Server-internal operations (board copy, cleanup, migrations, etc.) run
// without an authenticated user; do not block those here. User-initiated
// DDP calls always carry a userId and are still gated by the allow rule.
if (!userId) {
return;
}
const isAuthor = userId === doc.userId;
if (isAuthor) {
return; // Authors may always edit/delete their own comments.
}
const board = await Boards.findOneAsync(doc.boardId);
const isBoardAdmin = !!board && !!userId && board.hasAdmin(userId);
const restrictCommentEditing = !!board && !!board.restrictCommentEditing;
if (!canEditComment({ isAuthor, isBoardAdmin, restrictCommentEditing })) {
throw new Meteor.Error(
'error-comment-edit-not-allowed',
"You are not allowed to edit or delete another user's comment on this board.",
);
}
};
// GHSA-pqr4-rxgp-hv2m: an HTTP handler is NOT a server-internal operation,
// but it reached the collection with no Meteor userId in the invocation
// context, so it landed on this trusted path — and every board member could
// delete anyone's comment through the REST API, restrictCommentEditing or
// not. The REST handlers now call this function themselves, with req.userId,
// BEFORE touching the collection (server/models/cardComments.js). The trusted
// path stays for the genuine internal callers it was written for.
if (!userId) {
return;
}
const isAuthor = userId === doc.userId;
if (isAuthor) {
return; // Authors may always edit/delete their own comments.
}
const board = await Boards.findOneAsync(doc.boardId);
const isBoardAdmin = !!board && !!userId && board.hasAdmin(userId);
const restrictCommentEditing = !!board && !!board.restrictCommentEditing;
if (!canEditComment({ isAuthor, isBoardAdmin, restrictCommentEditing })) {
const error = new Meteor.Error(
'error-comment-edit-not-allowed',
"You are not allowed to edit or delete another user's comment on this board.",
);
// So the REST layer answers 403 rather than 500 (httpStatusForError).
error.statusCode = 403;
throw error;
}
}
if (Meteor.isServer) {
CardComments.before.update(async (userId, doc) => {
await assertCanMutateComment(userId, doc);
});
+22 -1
View File
@@ -31,6 +31,19 @@ EventLog.attachSchema(
source: { type: String, optional: true }, // guard/module/test name (wekan… or sqlite…/ferretdb…)
cwe: { type: String, optional: true },
userId: { type: String, optional: true },
// WHO and FROM WHERE. A security event that says only "something was
// blocked" cannot be acted on: the admin needs the account and the address
// to decide whether to lock it, and to recognise the same actor across
// several events. Denormalised at write time on purpose - the username is
// what the account was CALLED when it tried, which a later rename must not
// rewrite, and a deleted account must not erase.
username: { type: String, optional: true },
ip: { type: String, optional: true },
// How many attempts this one row stands for. A canary sits on a path an
// attacker controls in a loop, so repeats inside a window are counted rather
// than each written (models/lib/canaryTokens.js): 1 for an ordinary event, N
// for a summary flush.
count: { type: Number, optional: true },
detail: { type: String, optional: true },
// The 'database' stream's own four fields (server/lib/databaseProblems.js).
// They MUST be declared here: collection2 cleans every insert against this
@@ -62,7 +75,10 @@ EventLogAcks.attachSchema(
// (MongoDB, or FerretDB over SQLite/PostgreSQL/MySQL/MariaDB/SAP HANA), what the
// message means, and what an admin should do about it. See
// models/lib/databaseErrors.js and server/lib/databaseProblems.js.
export const EVENT_STREAMS = ['security', 'speed', 'tests', 'cpu', 'database'];
// 'integrity' is what the FILESYSTEM said: a stored file that is not the file
// WeKan stored (server/lib/fileIntegrityScan.js), and whether this server
// stopped cleanly last time (server/lib/uptimeWatch.js).
export const EVENT_STREAMS = ['security', 'speed', 'tests', 'cpu', 'database', 'integrity'];
if (Meteor.isServer) {
// The Security/Speed/Tests report pages filter by `stream` and sort by `at`
@@ -97,6 +113,11 @@ if (Meteor.isServer) {
selector.$or = [
{ category: rx }, { bleed: rx }, { source: rx }, { detail: rx },
{ db: rx }, { kind: rx }, { type: rx }, { message: rx },
// WHO and FROM WHERE are the two things an admin looking at a security
// event actually wants to pivot on: every other event from this address,
// every other event from this account. Searching the table for either
// has to find the rows that DISPLAY it, so both columns are searched.
{ username: rx }, { ip: rx },
];
}
return selector;
+59 -4
View File
@@ -61,6 +61,29 @@ export function writeWithBackpressure(res, str) {
});
}
// GHSA-4mxf-m8pq-xc9p: an avatar's `versions.original.path` comes out of the
// DATABASE, and the export below reads it off disk and embeds the bytes as
// base64. The avatar allow rule now refuses to let a client write that field
// (server/permissions/avatars.js), and this is the second lock: a path is only
// read when it resolves to somewhere inside WeKan's own storage, so a path
// poisoned any other way - an old document written before the fix, a restored
// backup, a bad migration - still cannot turn an export into a file read.
//
// The roots are computed the same way models/avatars.js and models/attachments.js
// compute their storagePath, via the shared helper both of them are kept in sync
// with.
export function avatarStorageRoots() {
const { computeStoragePaths } = require('./lib/attachmentStoragePath');
const paths = computeStoragePaths(process.env.WRITABLE_PATH);
return [paths.avatars, paths.attachments];
}
// True when `storedPath` may be read. Exported so the guard itself is testable.
export function isReadableStoredFilePath(storedPath, roots) {
const { isPathInsideAny } = require('./lib/storagePathContainment');
return isPathInsideAny(storedPath, roots || avatarStorageRoots());
}
// exporter maybe is broken since Gridfs introduced, add fs and path
export class Exporter {
constructor(boardId, attachmentId, options = {}) {
@@ -117,12 +140,33 @@ export class Exporter {
buffer.fill(0);
// callback has the form function (err, res) {}
// GHSA-4mxf-m8pq-xc9p: this is the ONE place the export turns a stored
// path into bytes, for attachments and avatars alike, so the containment
// check belongs here as well as at the call sites. A path that does not
// resolve inside WeKan's storage is not a file this export may read.
const storedPath = doc?.versions?.original?.path;
if (!isReadableStoredFilePath(storedPath)) {
if (process.env.DEBUG === 'true') {
console.warn('Refused to export a file stored outside the storage root:', storedPath);
}
// A canary, not a guess: every path WeKan writes is inside the storage
// root, so one that is not was put there by something that should not
// have. The export continues without the file, exactly as before.
try {
const { tripCanary } = require('/server/lib/canary');
tripCanary('export.path-outside-storage', { detail: 'during board export' });
} catch (e) { /* never break an export to report on it */ }
callback(null, null);
return;
}
const tmpFile = path.join(
os.tmpdir(),
`tmpexport${process.pid}${Math.random()}`,
);
const tmpWriteable = fs.createWriteStream(tmpFile);
const readStream = fs.createReadStream(doc.versions.original.path);
const readStream = fs.createReadStream(storedPath);
readStream.on('data', function (chunk) {
buffer = Buffer.concat([buffer, chunk]);
});
@@ -295,7 +339,10 @@ export class Exporter {
const m = localUrl.match(/\/(?:cdn\/storage\/avatars|cfs\/files\/avatars)\/([^/?#]+)/);
if (m && m[1]) {
const avatar = await ReactiveCache.getAvatar(m[1]);
if (avatar && avatar.versions && avatar.versions.original && avatar.versions.original.path) {
const avatarPath = avatar?.versions?.original?.path;
// GHSA-4mxf-m8pq-xc9p: only read it when it is really inside WeKan's
// own storage. A path pointing anywhere else is not an avatar.
if (avatarPath && isReadableStoredFilePath(avatarPath)) {
const file = await getBase64DataAsync(avatar);
if (file) {
user.profile = user.profile || {};
@@ -434,7 +481,13 @@ export class Exporter {
if (!this._excludeAttachments) {
await w(',"file":"');
const filePath = att.versions && att.versions.original && att.versions.original.path;
if (filePath && fs.existsSync(filePath)) {
// GHSA-4mxf-m8pq-xc9p class: the ATTACHMENT half of the same read. The
// attachment allow rule refuses a client-supplied path, so this is not
// the reported hole - but a path is only as trustworthy as every way it
// could have been written, and this streaming export is the one
// remaining place that read a stored path with nothing but an
// existsSync. Same containment check as the avatars above.
if (filePath && isReadableStoredFilePath(filePath) && fs.existsSync(filePath)) {
await new Promise((resolve, reject) => {
const rs = fs.createReadStream(filePath);
let leftover = Buffer.alloc(0);
@@ -512,7 +565,9 @@ export class Exporter {
if (m && m[1]) {
const avatar = await avatarsRaw.findOne({ _id: m[1] });
const p = avatar && avatar.versions && avatar.versions.original && avatar.versions.original.path;
if (p) {
// GHSA-4mxf-m8pq-xc9p: same containment check as the non-streaming
// export above - never read a stored path from outside the storage.
if (p && isReadableStoredFilePath(p)) {
try {
const buf = fs.readFileSync(p);
user.profile = user.profile || {};
+50
View File
@@ -0,0 +1,50 @@
import { Mongo } from 'meteor/mongo';
import { Meteor } from 'meteor/meteor';
// ============================================================================
// The filesystem storage BASELINE (design: docs/Security/Remediation/WeKan.md §13).
//
// One document per stored file: what it was called, how big it was, when it was
// last modified, its md5/sha256/sha512, and an ed25519 signature over all of
// that. server/lib/fileIntegrityScan.js writes it and re-checks it once a day.
//
// In the EXISTING WeKan database, like every other part of this subsystem - no
// new files under WRITABLE_PATH, nothing extra to back up or to forget to back
// up, and it works the same on FerretDB and MongoDB.
//
// Deliberately NOT schema-attached: `digests` is a small open map, and the point
// of this collection is to be a faithful record of what was observed rather than
// something a schema might quietly clean fields out of - which is exactly the
// bug that once emptied the database-problems rows (models/eventLog.js).
// ============================================================================
const FileIntegrity = new Mongo.Collection('fileIntegrity');
// The signing key, and the scan's own bookkeeping (when it last ran). Separate
// from the baseline so a full re-baseline never touches the key.
export const IntegrityKeys = new Mongo.Collection('fileIntegrityKeys');
if (Meteor.isServer) {
const { ensureIndex } = require('/server/lib/mongoStartup');
Meteor.startup(async () => {
// The scan reads by _id (the path) and sweeps by `at`.
await ensureIndex(FileIntegrity, { at: -1 });
});
// NEVER published. The baseline is a map of every file on the server and the
// key document is a private key; neither has any business on a client, not
// even an admin's. Admin Panel reads the FINDINGS, which are ordinary event
// log rows, not this.
FileIntegrity.deny({
insert: () => true,
update: () => true,
remove: () => true,
});
IntegrityKeys.deny({
insert: () => true,
update: () => true,
remove: () => true,
});
}
export default FileIntegrity;
+2
View File
@@ -101,6 +101,7 @@ const ADMIN_PAGES = {
cards: 'report-cards',
recovery: 'report-recovery',
database: 'report-database',
integrity: 'report-integrity',
},
},
attachments: {
@@ -173,6 +174,7 @@ const ADMIN_PANE_TITLES = {
cards: { titleKey: 'cardsReportTitle' },
recovery: { titleKey: 'recoveryReportTitle' },
database: { titleKey: 'databaseReportTitle' },
integrity: { titleKey: 'integrityReportTitle' },
},
attachments: {
backup: { titleKey: 'backup' },
+64
View File
@@ -0,0 +1,64 @@
// Pure, dependency-free construction of the Mongo `$or` that answers "which
// boards may this user see". No Meteor imports, so it is unit tested directly
// with plain Node (tests/boardVisibilitySelectors.test.cjs).
//
// GHSA-gwc4-fw7p-gw58: this was written out TWICE, and the two copies disagreed.
// Boards.userBoards() - the All Boards list - matched org/team/domain shares
// with `$elemMatch: { ..., isActive: true }`, but the `board` publication (the
// one that sends the board and all its cards) matched them with a dotted
// `'orgs.orgId': { $in: [...] }`, which ignores isActive entirely. isActive is
// the REVOKE switch: setting a share to `isActive: false` made the board vanish
// from All Boards, as it should, while a user who still knew the boardId could
// subscribe to it and receive the whole private board. A revoke that the primary
// data publication does not honour is not a revoke.
//
// So there is one builder now, and both callers use it. A share entry counts
// only while it is active, everywhere.
/**
* @param {object} args
* @param {string} [args.userId] the subscriber; omitted/falsy means anonymous
* @param {string[]} [args.orgIds] organisations the user belongs to
* @param {string[]} [args.teamIds] teams the user belongs to
* @param {string[]} [args.emailDomains] the user's verified email domains (#5850)
* @param {boolean} [args.includePublic=true] include `{ permission: 'public' }`,
* which is not a relationship to the user at all but "anybody may open
* this" - wanted in a board LIST, not in a search over all boards.
* @return {object[]} the `$or` clauses, in a stable order
*/
function boardVisibilitySelectors({
userId,
orgIds = [],
teamIds = [],
emailDomains = [],
includePublic = true,
} = {}) {
const selectors = [];
if (includePublic) {
selectors.push({ permission: 'public' });
}
if (!userId) {
return selectors;
}
const clean = (values) =>
Array.isArray(values) ? values.filter((v) => typeof v === 'string' && v) : [];
selectors.push({ members: { $elemMatch: { userId, isActive: true } } });
selectors.push({
orgs: { $elemMatch: { orgId: { $in: clean(orgIds) }, isActive: true } },
});
selectors.push({
teams: { $elemMatch: { teamId: { $in: clean(teamIds) }, isActive: true } },
});
// #5850: domain-based board sharing — board shared with the user's email domain.
selectors.push({
domains: { $elemMatch: { domain: { $in: clean(emailDomains) }, isActive: true } },
});
return selectors;
}
module.exports = { boardVisibilitySelectors };
+346
View File
@@ -0,0 +1,346 @@
'use strict';
// ============================================================================
// Canary tokens — the pure half
// (design: docs/Security/Remediation/WeKan.md §12)
// ----------------------------------------------------------------------------
// A CANARY is a tripwire placed at a point where somebody could TRY to override
// permissions. Ordinary users never reach it: the code path only runs when a
// request asks for something the rules forbid. So a canary trip is not noise -
// it is somebody probing, and it is worth an admin's attention with WHO and
// FROM WHERE attached.
//
// Three properties, and they are the whole point:
//
// 1. SILENT. Tripping a canary changes nothing the caller can observe. The
// trip function returns `false` so a guard can `return tripCanary(...)` in
// place of `return false`, and the attacker gets the same refusal, with the
// same timing and the same message, as before the canary existed. Nothing
// is ever returned, thrown, delayed or logged back to them.
// 2. BOUNDED. A canary sits on a path an attacker controls and can hit in a
// loop. Writing one database row per attempt is a denial-of-service the
// attacker gets for free - CPU on every insert, and a table that grows
// until the disk is full. So the FIRST trip in a window is recorded and the
// rest are COUNTED, then flushed as a single summary event when the window
// closes. The counter map itself is capped, so memory is bounded too.
// 3. ATTRIBUTED. Every trip carries the actor - userId, username and client IP
// address - so Admin Panel → Problems → Security answers "who tried this,
// and from where", not just "something happened".
//
// This file is pure and dependency-free (no Meteor, no database, no clock of its
// own), so it is unit tested with plain Node: tests/canaryTokens.test.cjs. The
// runtime half - resolving the actor, applying this limiter, writing the event -
// is server/lib/canary.js.
// ============================================================================
// ---------------------------------------------------------------- the catalog
//
// One entry per tripwire. `key` is the existing securityCategories key, so a
// canary's category, *Bleed name, severity and CWE come from the SAME catalog
// the other guards use and cannot drift from it (models/lib/securityCategories.js).
//
// `what` is the sentence an admin reads. It says what was ATTEMPTED, in the
// terms of the feature, because "authz.board blocked" is not something anyone
// can act on.
const CANARIES = {
'card.cross-board-move': {
key: 'authz.board',
what: 'tried to move a card into a board they cannot write to',
},
'card.invisible-parent': {
key: 'authz.board',
what: 'tried to set a card parent on a board they cannot see',
},
'list.cross-board-move': {
key: 'authz.board',
what: 'tried to move a list into a board they cannot write to',
},
'swimlane.cross-board-move': {
key: 'authz.board',
what: 'tried to move a swimlane into a board they cannot write to',
},
'checklist.cross-board-move': {
key: 'authz.checklist',
what: 'tried to move a checklist into a board they cannot write to',
},
'checklist-item.cross-board-move': {
key: 'authz.checklist',
what: 'tried to move a checklist item into a board they cannot write to',
},
'attachment.version-path': {
key: 'authz.file-path',
what: 'tried to write the on-disk path of an attachment',
},
'avatar.version-path': {
key: 'authz.file-path',
what: 'tried to write the on-disk path of an avatar',
},
'attachment.restricted-field': {
key: 'authz.file-path',
what: 'tried to write a restricted attachment field',
},
'avatar.restricted-field': {
key: 'authz.file-path',
what: 'tried to write a restricted avatar field',
},
'avatar.not-owner': {
key: 'authz.file-path',
what: "tried to change another user's avatar",
},
'comment.foreign-delete': {
key: 'authz.comment',
what: "tried to delete another user's comment over the REST API",
},
'reaction.foreign': {
key: 'authz.comment',
what: "tried to add or remove another user's comment reaction",
},
'export.path-outside-storage': {
key: 'authz.file-path',
what: 'a stored file path pointed outside the storage root and was not read',
},
'card.vote-field': {
key: 'authz.board',
what: 'tried to write a vote field directly instead of through the method',
},
'card.poker-field': {
key: 'authz.board',
what: 'tried to write a poker field directly instead of through the method',
},
'database.canary': {
key: 'authz.database',
what: 'the database refused an operation that WeKan never issues',
},
// --- Injection ------------------------------------------------------------
// WeKan builds no SQL, so a SQL canary can only come from the database's own
// last-look guard (docs/Security/Remediation/FerretDB.md §3b). NoSQL is the
// one WeKan can see for itself: a selector that carries an execution
// operator, or an operator object where the value a user typed belongs.
'injection.nosql-selector': {
key: 'injection.nosql',
what: 'sent a query carrying a database-side execution operator',
},
'injection.nosql-operator': {
key: 'injection.nosql',
what: 'sent a query operator where a plain value was expected',
},
'injection.sql-statement': {
key: 'injection.sql',
what: 'the database refused a statement that looked like injected SQL',
},
// --- Sanitization ---------------------------------------------------------
// Sanitizing is routine - a filename gets trimmed, a document gets its
// formatting normalised - and routine is not worth an admin's attention. What
// IS worth it is sanitization that removed something DANGEROUS: markup that
// would have executed, a path that would have escaped its directory, a type
// that lied about itself. Only those trip.
'sanitize.dangerous-filename': {
key: 'file.name',
what: 'uploaded a filename carrying an exploit pattern',
},
'sanitize.path-traversal': {
key: 'file.name',
what: 'used a file path that tried to escape its directory',
},
'sanitize.dangerous-content': {
key: 'file.content',
what: 'uploaded a file whose content had to have active markup removed',
},
'sanitize.dangerous-text': {
key: 'xss.input',
what: 'submitted text that had to have active markup removed',
},
// --- Other common attacks -------------------------------------------------
'spoof.forwarded-header': {
key: 'spoofing.xff',
what: 'sent a forwarded-for header that is not trusted here',
},
'brute.login-lockout': {
key: 'brute.login',
what: 'was locked out after repeated failed logins',
},
};
// The id used when a caller passes something not in the catalog. A canary with
// an unknown id must still be RECORDED - losing the event would be the worst
// possible failure mode for a tripwire - it is simply recorded generically.
const UNKNOWN_CANARY = {
key: 'authz.canary',
what: 'tripped a canary',
};
/**
* Look up a canary. Never throws and never returns undefined.
* @param {string} id
* @return {{key: string, what: string, id: string, known: boolean}}
*/
function canaryFor(id) {
const entry = (typeof id === 'string' && CANARIES[id]) || null;
return {
id: typeof id === 'string' && id ? id : 'unknown',
key: (entry || UNKNOWN_CANARY).key,
what: (entry || UNKNOWN_CANARY).what,
known: !!entry,
};
}
/** Every catalogued canary id, for the tests and the docs. */
function canaryIds() {
return Object.keys(CANARIES).sort();
}
// ------------------------------------------------------------- the rate limit
const DEFAULTS = {
// How long one (canary, actor) pair is aggregated for. The first trip in a
// window is recorded immediately - an admin should see a probe promptly - and
// everything else in the window is counted.
windowMs: 60 * 1000,
// How many (canary, actor) pairs are tracked at once. An attacker can vary the
// actor (a fresh IP per request over a botnet), so this map is the thing that
// would grow without a cap. At the cap the OLDEST entry is dropped: losing the
// count of an old, already-reported probe is much cheaper than unbounded
// memory, and the first trip of every pair was recorded regardless.
maxTracked: 5000,
// A hard ceiling on how many events one pair can produce, however long the
// attack runs: after this many summaries the pair keeps counting but stops
// writing until it goes quiet for a window. Prevents a slow-but-endless probe
// from writing one row a minute forever.
maxEventsPerPair: 60,
};
/**
* Decides, for each trip, whether to WRITE an event now and what count it
* carries. Pure: the clock is passed in, so the tests drive time directly.
*
* The contract:
* - the first trip of a (canary, actor) pair writes immediately, count 1;
* - further trips inside the window write nothing and are counted;
* - the first trip AFTER the window closes writes a summary carrying the
* suppressed count, and opens a new window;
* - a pair that has already written `maxEventsPerPair` summaries stops
* writing until it goes quiet for a whole window;
* - the tracking map never exceeds `maxTracked` entries.
*/
class CanaryRateLimiter {
constructor(options = {}) {
this.windowMs = options.windowMs || DEFAULTS.windowMs;
this.maxTracked = options.maxTracked || DEFAULTS.maxTracked;
this.maxEventsPerPair = options.maxEventsPerPair || DEFAULTS.maxEventsPerPair;
// Insertion-ordered, which is what makes the oldest entry cheap to evict.
this._pairs = new Map();
}
/**
* @param {string} pairKey identifies (canary, actor)
* @param {number} now epoch ms
* @return {{write: boolean, count: number, suppressed: number}}
* `write` - record an event now. `count` - how many trips it stands for.
* `suppressed` - how many of those were not individually recorded.
*/
consider(pairKey, now) {
const key = typeof pairKey === 'string' && pairKey ? pairKey : 'unknown';
const at = typeof now === 'number' && isFinite(now) ? now : 0;
const existing = this._pairs.get(key);
if (!existing) {
this._evictIfFull();
this._pairs.set(key, { windowStart: at, seen: 1, written: 1, lastSeen: at });
return { write: true, count: 1, suppressed: 0 };
}
// Re-insert so the map stays ordered by RECENCY, not by first sighting;
// otherwise a long-running attacker would be evicted before a one-off probe.
this._pairs.delete(key);
this._pairs.set(key, existing);
existing.lastSeen = at;
const windowOpen = at - existing.windowStart < this.windowMs;
if (windowOpen) {
existing.seen += 1;
return { write: false, count: existing.seen, suppressed: existing.seen - 1 };
}
// The window closed. Flush what it accumulated, then start a new one.
const suppressed = Math.max(0, existing.seen - 1);
existing.windowStart = at;
const total = existing.seen + 1;
existing.seen = 1;
if (existing.written >= this.maxEventsPerPair) {
// Still counting, no longer writing: an endless probe must not be able to
// write forever, and by now the admin has 60 events saying so.
return { write: false, count: total, suppressed };
}
existing.written += 1;
return { write: true, count: total, suppressed };
}
/** Drop pairs whose window closed long ago; called by the runtime's sweeper. */
sweep(now, idleMs) {
const cutoff = (typeof now === 'number' ? now : 0) - (idleMs || this.windowMs * 10);
let dropped = 0;
for (const [key, entry] of this._pairs) {
if (entry.lastSeen < cutoff) {
this._pairs.delete(key);
dropped += 1;
}
}
return dropped;
}
size() {
return this._pairs.size;
}
_evictIfFull() {
while (this._pairs.size >= this.maxTracked) {
const oldest = this._pairs.keys().next();
if (oldest.done) return;
this._pairs.delete(oldest.value);
}
}
}
/**
* The key one canary + one actor are aggregated under. An unauthenticated actor
* is keyed by IP alone; a logged-in one by userId, so switching IP mid-attack
* does not reset their counter.
* @param {string} canaryId
* @param {{userId?: string, ip?: string}} actor
* @return {string}
*/
function canaryPairKey(canaryId, actor = {}) {
const id = typeof canaryId === 'string' && canaryId ? canaryId : 'unknown';
const userId = actor && typeof actor.userId === 'string' && actor.userId ? actor.userId : '';
const ip = actor && typeof actor.ip === 'string' && actor.ip ? actor.ip : 'unknown-ip';
return userId ? `${id}u:${userId}` : `${id}i:${ip}`;
}
/**
* The `detail` line an event carries. Deliberately says what was attempted and
* how many times - never the payload, which is attacker-controlled and would
* turn the security log into an injection sink (the logger truncates and strips
* control characters as a second line of defence).
*/
function canaryDetail(canary, count, extra) {
const parts = [canary.what];
if (typeof extra === 'string' && extra) parts.push(extra);
if (count > 1) parts.push(`${count} attempts in this window`);
return parts.join('; ');
}
module.exports = {
CANARIES,
UNKNOWN_CANARY,
DEFAULTS,
CanaryRateLimiter,
canaryFor,
canaryIds,
canaryPairKey,
canaryDetail,
};
+35
View File
@@ -20,6 +20,41 @@ const DATABASES = ['mongodb', 'sqlite', 'postgresql', 'mysql', 'mariadb', 'hana'
// doing it is obviously right. Everything else is advice, because a database
// that is out of disk is not something an application should "handle".
const RULES = [
// ── WeKan's own bugs, arriving as database errors ─────────────────────────
//
// These two filled Admin Panel / Problems / Database problems with rows
// marked "unknown / unclassified", whose advice was "add a rule to
// models/lib/databaseErrors.js". They are not the database's fault and there
// is nothing an admin can configure: they are WeKan calling the database
// wrongly, so the rule says so and names the version that fixes it rather
// than sending the admin to read a stack trace.
{
id: 'meteor3-sync-api',
match: /(update|insert|remove|upsert|find\w*) is not available on the server\. Please use \w+Async\(\) instead/i,
databases: ['mongodb', 'sqlite', 'postgresql', 'mysql', 'mariadb', 'hana'],
severity: 'warning',
kind: 'bug',
means: 'WeKan called a synchronous collection method on the server, which Meteor 3 removed. ' +
'The operation did not happen.',
whatToDo: 'A WeKan bug, not a database or configuration problem. Upgrade WeKan; if it ' +
'persists on the newest version, report it at https://github.com/wekan/wekan/issues ' +
'with the method name from the message.',
act: null,
},
{
id: 'schema-validation',
match: /ValidationError: Failed validation|Cannot read properties of undefined \(reading 'title'\)/i,
databases: ['mongodb', 'sqlite', 'postgresql', 'mysql', 'mariadb', 'hana'],
severity: 'warning',
kind: 'bug',
means: 'A write was refused because the document did not match its schema - usually a ' +
'required field that was empty. The document was not saved.',
whatToDo: 'Check whether the item named in the message is missing a title or another ' +
'required field, and fill it in. If nothing obvious is missing, it is a WeKan bug: ' +
'report it at https://github.com/wekan/wekan/issues with the method name.',
act: null,
},
// ── injection and malformed SQL: FerretDB's bug, never the admin's ────────
{
id: 'sql-guard-refused',
+298
View File
@@ -0,0 +1,298 @@
'use strict';
// ============================================================================
// Filesystem storage integrity — the pure half
// (design: docs/Security/Remediation/WeKan.md §13)
// ----------------------------------------------------------------------------
// WeKan's attachments and avatars live as files under WRITABLE_PATH, and the
// database holds one document per file. Nothing checks that the two still agree.
// A file can be replaced, truncated, back-dated or deleted by anything that
// reaches the filesystem - a bad restore, a sync tool, a container rebuild, or
// somebody who got in - and WeKan would keep serving whatever is there now.
//
// So a BASELINE is recorded per file (name, size, modification time, and three
// digests), signed, and re-checked. What makes a finding worth an admin's
// attention is not that a file changed - files change when people edit
// boards - but that it changed WITH NO RECORD SAYING WHY.
//
// This file is pure: no filesystem, no database, no clock, no crypto. It is
// given what was recorded and what was observed and says what that means, so it
// is unit tested with plain Node (tests/fileIntegrity.test.cjs). The scanning,
// the digests and the signature are server/lib/fileIntegrityScan.js.
// ============================================================================
// The digests kept per file. Three, not one, because they answer different
// questions:
//
// md5 - fast, and what most other tools print, so an admin can compare
// against a backup with the tool they already use. Broken for
// collision RESISTANCE, which is why it is never alone here.
// sha256 - the working digest.
// sha512 - a second, different-width digest. Two independent digests
// disagreeing about the same bytes means something is wrong with
// the READING (a failing disk, a truncated copy), not merely with
// the file - and that is a different problem from a substitution.
const DIGESTS = ['md5', 'sha256', 'sha512'];
// What a finding can be. Ordered from "the file is gone" to "somebody re-signed
// the record", which is roughly increasing seriousness.
const FINDINGS = {
MISSING: 'missing', // recorded, and not on disk any more
UNTRACKED: 'untracked', // on disk, and never recorded
SIZE: 'size-changed',
MTIME: 'mtime-changed',
CONTENT: 'content-changed', // every digest changed: different bytes
DIGEST_DISAGREE: 'digests-disagree', // some digests changed and some did not
SIGNATURE: 'signature-invalid', // the RECORD was tampered with
};
const SEVERITY = {
[FINDINGS.MISSING]: 'high',
[FINDINGS.UNTRACKED]: 'low',
[FINDINGS.SIZE]: 'medium',
[FINDINGS.MTIME]: 'low',
[FINDINGS.CONTENT]: 'high',
[FINDINGS.DIGEST_DISAGREE]: 'critical',
[FINDINGS.SIGNATURE]: 'critical',
};
/**
* The exact bytes that get signed for one baseline entry.
*
* Canonical and explicit: a fixed field order, a separator that cannot appear
* in any field, and no JSON (whose key order and escaping are not guaranteed
* stable across versions). If this string is not reproducible byte for byte,
* every signature verification fails a year from now for no reason.
*
* @param {object} entry
* @return {string}
*/
function manifestLine(entry) {
const e = entry || {};
const digests = DIGESTS.map(name => `${name}=${(e.digests && e.digests[name]) || ''}`);
return [
`path=${e.path || ''}`,
`size=${Number.isFinite(e.size) ? e.size : ''}`,
// Milliseconds since the epoch, as an integer: a formatted date is a
// locale and a timezone away from being unstable.
`mtime=${Number.isFinite(e.mtimeMs) ? Math.floor(e.mtimeMs) : ''}`,
...digests,
].join('\n');
}
/**
* Compare one recorded baseline against what was just observed.
*
* @param {object|null} baseline what was recorded (null = never recorded)
* @param {object|null} observed what is on disk now (null = not there)
* @param {object} [options]
* @param {boolean} [options.signatureValid=true] result of verifying the
* baseline's own signature; false means the RECORD was edited
* @return {Array<{finding: string, severity: string, detail: string}>}
*/
function compareEntry(baseline, observed, options = {}) {
const findings = [];
if (options.signatureValid === false) {
findings.push({
finding: FINDINGS.SIGNATURE,
severity: SEVERITY[FINDINGS.SIGNATURE],
detail: 'the recorded baseline does not match its signature',
});
}
if (!baseline && !observed) return findings;
if (baseline && !observed) {
findings.push({
finding: FINDINGS.MISSING,
severity: SEVERITY[FINDINGS.MISSING],
detail: 'a recorded file is no longer on disk',
});
return findings;
}
if (!baseline && observed) {
findings.push({
finding: FINDINGS.UNTRACKED,
severity: SEVERITY[FINDINGS.UNTRACKED],
detail: 'a file is in the storage directory that WeKan never stored',
});
return findings;
}
// Digests first: they are what actually decides whether the bytes changed.
const changed = DIGESTS.filter(name => {
const was = baseline.digests && baseline.digests[name];
const now = observed.digests && observed.digests[name];
// A digest that was never recorded, or was not computed now, says nothing.
return was && now && was !== now;
});
const compared = DIGESTS.filter(name => {
const was = baseline.digests && baseline.digests[name];
const now = observed.digests && observed.digests[name];
return was && now;
});
if (changed.length && changed.length === compared.length) {
findings.push({
finding: FINDINGS.CONTENT,
severity: SEVERITY[FINDINGS.CONTENT],
detail: `the file's contents changed (${changed.join(', ')})`,
});
} else if (changed.length) {
// Two digests over the same bytes cannot disagree. If they do, the bytes
// were not read the same way twice - a failing disk, a partial write, a
// truncated copy - or somebody updated one digest and not the others.
findings.push({
finding: FINDINGS.DIGEST_DISAGREE,
severity: SEVERITY[FINDINGS.DIGEST_DISAGREE],
detail: `digests disagree: ${changed.join(', ')} changed, ` +
`${compared.filter(n => !changed.includes(n)).join(', ')} did not`,
});
}
if (Number.isFinite(baseline.size) && Number.isFinite(observed.size) &&
baseline.size !== observed.size) {
findings.push({
finding: FINDINGS.SIZE,
severity: SEVERITY[FINDINGS.SIZE],
detail: `size ${baseline.size} → ${observed.size} bytes`,
});
}
// A modification time that moved while the contents did not is worth a low
// note and nothing more: a copy, a restore or a backup tool does it. A time
// that moved BACKWARDS is different - nothing does that by accident - so it
// is called out even though it is the same finding.
if (Number.isFinite(baseline.mtimeMs) && Number.isFinite(observed.mtimeMs) &&
Math.floor(baseline.mtimeMs) !== Math.floor(observed.mtimeMs)) {
const backwards = observed.mtimeMs < baseline.mtimeMs;
findings.push({
finding: FINDINGS.MTIME,
severity: backwards ? 'medium' : SEVERITY[FINDINGS.MTIME],
detail: backwards
? 'the modification time moved BACKWARDS'
: 'the modification time changed',
});
}
return findings;
}
/**
* Was this change EXPLAINED?
*
* A file changing is ordinary; WeKan changes files when people use it. What is
* not ordinary is a change with nothing in the record to account for it, and
* that is the finding an admin is actually being warned about.
*
* @param {Array} findings from compareEntry
* @param {boolean} hasRecentRecord did WeKan record an operation on this file
* around the time it changed?
* @return {{explained: boolean, severity: string, summary: string}}
*/
function classifyChange(findings, hasRecentRecord) {
if (!Array.isArray(findings) || findings.length === 0) {
return { explained: true, severity: 'info', summary: 'unchanged' };
}
const worst = findings.reduce((acc, f) => {
const order = ['info', 'low', 'medium', 'high', 'critical'];
return order.indexOf(f.severity) > order.indexOf(acc) ? f.severity : acc;
}, 'info');
// A tampered RECORD is never explained by an ordinary operation: WeKan does
// not rewrite a baseline without re-signing it.
const signature = findings.some(f => f.finding === FINDINGS.SIGNATURE);
if (hasRecentRecord && !signature) {
return {
explained: true,
severity: 'info',
summary: `${findings.map(f => f.finding).join(', ')} — WeKan has a record of this`,
};
}
return {
explained: false,
// An unexplained change is at least a medium: the point of the scan is that
// nobody knows why it happened.
severity: worst === 'low' || worst === 'info' ? 'medium' : worst,
summary: `${findings.map(f => f.finding).join(', ')} — with no record saying why`,
};
}
// ---------------------------------------------------------------- the pacing
//
// The scan reads every stored byte, which on a busy instance is the last thing
// that should compete with users. So it is paced by policy rather than by hope:
// skip entirely while the machine is busy, sleep between files, and stop after
// a budget rather than running to completion at any cost.
const PACING = {
// Above this, do not start and do not continue. The scan is never urgent.
maxCpuPercent: 60,
// Between files. Small, but it is what turns a tight read loop into a
// background trickle.
pauseMsPerFile: 50,
// ...and proportional to the file, so a directory of large files does not
// become a sustained read.
pauseMsPerMegabyte: 20,
// Stop after this long and continue tomorrow. A scan that never finishes is
// better than a scan that holds a machine down.
maxRunMs: 15 * 60 * 1000,
// How long between runs. Once a day.
intervalMs: 24 * 60 * 60 * 1000,
};
/**
* What to do before reading the next file.
* @param {object} state
* @param {number} state.cpuPercent current CPU load, 0-100
* @param {number} state.elapsedMs how long this run has been going
* @param {number} state.fileSize the next file's size in bytes
* @param {object} [pacing] overrides for the constants above
* @return {{action: 'stop'|'pause'|'go', pauseMs: number, reason: string}}
*/
function nextStep(state = {}, pacing = PACING) {
const cpu = Number(state.cpuPercent);
const elapsed = Number(state.elapsedMs) || 0;
const size = Number(state.fileSize) || 0;
if (elapsed >= pacing.maxRunMs) {
return { action: 'stop', pauseMs: 0, reason: 'this run has used its time; continuing next time' };
}
if (Number.isFinite(cpu) && cpu >= pacing.maxCpuPercent) {
return { action: 'stop', pauseMs: 0, reason: `CPU at ${Math.round(cpu)}%; not competing with users` };
}
const megabytes = size / (1024 * 1024);
const pauseMs = Math.round(pacing.pauseMsPerFile + megabytes * pacing.pauseMsPerMegabyte);
return { action: 'pause', pauseMs, reason: '' };
}
/** Is it time for another run? */
function isDue(lastRunAtMs, nowMs, pacing = PACING) {
const last = Number(lastRunAtMs);
const now = Number(nowMs);
// `Number(null)` is 0, which is finite - so an unreadable clock would have
// read as "the epoch" and started a scan. A clock this side of 1970 is not a
// clock, and a scan is never urgent enough to run on a guess.
if (!Number.isFinite(now) || now <= 0) return false;
if (!Number.isFinite(last) || last <= 0) return true;
return now - last >= pacing.intervalMs;
}
module.exports = {
DIGESTS,
FINDINGS,
SEVERITY,
PACING,
manifestLine,
compareEntry,
classifyChange,
nextStep,
isDue,
};
+5 -12
View File
@@ -31,18 +31,11 @@ export {
const { sanitizeFilename } = require('./filenameSanitizer');
const { hasEnoughDiskSpace } = require('./diskSpace');
function normalizeForCompare(inputPath) {
const normalized = path.resolve(inputPath);
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
}
function isPathInside(basePath, targetPath) {
const normalizedBase = normalizeForCompare(basePath);
const normalizedTarget = normalizeForCompare(targetPath);
const relative = path.relative(normalizedBase, normalizedTarget);
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
}
// GHSA-4mxf-m8pq-xc9p: this containment check used to be written out here and
// nowhere else, so the board exporter - which reads the very same stored paths -
// had none. It lives in models/lib/storagePathContainment.js now, where the
// exporter uses it too and where plain Node can test it.
const { isPathInsideBase: isPathInside } = require('./storagePathContainment');
function tryRealPath(inputPath) {
try {
+82
View File
@@ -0,0 +1,82 @@
// Pure, dependency-free guards for the `versions` metadata of a stored file
// (ostrio:files: `versions.<name>.path` / `.storage`), so both the attachment
// and the avatar permission rules can use the SAME rule and cannot drift apart.
//
// GHSA-4mxf-m8pq-xc9p: attachments blocked client-supplied `versions.*.path`,
// avatars did not - their allow rule was `update: isOwner` with no field
// restriction at all. An avatar owner could therefore point
// `versions.original.path` at any file the WeKan process can read (/etc/passwd,
// anything under WRITABLE_PATH) and then export a board they are a member of:
// the exporter reads that path from disk and embeds the bytes as base64 in
// `profile.avatarFile`. That is an arbitrary file read for any authenticated
// user, so the rule now lives in ONE place and is applied to both collections.
//
// `path` and `storage` are server-managed: they say WHERE on disk the bytes
// are. A client never has a reason to set or change them, on any collection.
const SERVER_MANAGED_VERSION_FIELDS = ['path', 'storage'];
/**
* True when a document a client is trying to INSERT carries server-managed
* version metadata (`versions.<any>.path` or `.storage`).
* @param {object} fileObj the document as the client sent it
* @return {boolean}
*/
function hasUnsafeClientVersionFields(fileObj) {
const versions = fileObj && fileObj.versions;
if (!versions || typeof versions !== 'object') {
return false;
}
return Object.values(versions).some((version) => {
if (!version || typeof version !== 'object') {
return false;
}
return SERVER_MANAGED_VERSION_FIELDS.some((field) =>
Object.prototype.hasOwnProperty.call(version, field),
);
});
}
/**
* True when an UPDATE touches the `versions` subtree at all. Deliberately
* broader than hasUnsafeClientVersionFields: an update arrives as a list of
* modified field names, and `versions.original` (the whole sub-object, path
* included) is as dangerous as `versions.original.path` spelled out.
* @param {string[]} fields the modified field names Meteor passes to allow()
* @return {boolean}
*/
function touchesVersionFields(fields) {
if (!Array.isArray(fields)) {
return false;
}
return fields.some(
(field) =>
typeof field === 'string' &&
(field === 'versions' || field.startsWith('versions.')),
);
}
/**
* True when every modified field name is in `allowedFields` (comparing the
* part before the first dot, so `meta.boardId` is allowed by `meta`).
* @param {string[]} fields the modified field names
* @param {string[]} allowedFields the whitelist
* @return {boolean}
*/
function onlyTouchesAllowedFields(fields, allowedFields) {
if (!Array.isArray(fields)) {
return false;
}
return fields.every((field) => {
if (typeof field !== 'string') return false;
return allowedFields.includes(field.split('.')[0]);
});
}
module.exports = {
SERVER_MANAGED_VERSION_FIELDS,
hasUnsafeClientVersionFields,
touchesVersionFields,
onlyTouchesAllowedFields,
};
+222
View File
@@ -0,0 +1,222 @@
'use strict';
// ============================================================================
// Injection detection — the pure half
// (design: docs/Security/Remediation/WeKan.md §12.6)
// ----------------------------------------------------------------------------
// Two shapes of NoSQL injection reach a Meteor app, and they are different
// problems:
//
// 1. A client-supplied SELECTOR carrying an operator that makes the DATABASE do
// something: `$where`, `$function`, `$accumulator` (server-side JavaScript),
// `$out`/`$merge` (write the result somewhere). A selector is data; these
// turn it into code.
//
// 2. An OPERATOR OBJECT where a scalar was expected. This is the classic one:
// a field that should hold the string a user typed arrives as
// `{"$ne": null}` or `{"$gt": ""}` or `{"$regex": ".*"}`, and a comparison
// that was meant to match one value now matches every value. It is how
// "log in as anybody" is spelled in a document database, and it needs no
// JavaScript at all.
//
// Both are detected here, purely - no Meteor, no database - so they are unit
// tested with plain Node (tests/injectionDetect.test.cjs) and so the same
// functions can be used on any input path.
//
// SQL injection is NOT detected here: WeKan never builds SQL. The database does,
// and it has its own last-look guard (FerretDB internal/util/sqlguard), which
// refuses the statement and marks the refusal so WeKan can record it -
// docs/Security/Remediation/FerretDB.md §3b.
// ============================================================================
// Operators that make the database EXECUTE something rather than compare.
// Presence of any of these in a client-supplied selector is not a query.
const EXECUTION_OPERATORS = [
'$where', // server-side JavaScript
'$function', // aggregation JavaScript
'$accumulator', // aggregation JavaScript
'$out', // write the result into a collection
'$merge', // ...or merge it into one
];
// Operators that are legitimate in a query but NOT in a place that expects the
// scalar a user typed. `{"$ne": null}` in a username field is an attack; the
// same operator built by WeKan's own code is ordinary.
const COMPARISON_OPERATORS = [
'$ne', '$gt', '$gte', '$lt', '$lte', '$in', '$nin',
'$regex', '$not', '$exists', '$type', '$expr', '$elemMatch',
];
const MAX_DEPTH = 12;
/**
* Every execution operator found anywhere in a value, deduplicated and sorted.
* Walks objects and arrays; bounded in depth so a deeply nested payload cannot
* be used to burn CPU here.
* @param {*} value
* @return {string[]}
*/
function executionOperatorsIn(value) {
const found = new Set();
const walk = (node, depth) => {
if (depth > MAX_DEPTH || !node || typeof node !== 'object') return;
if (Array.isArray(node)) {
node.forEach(item => walk(item, depth + 1));
return;
}
Object.keys(node).forEach(key => {
if (EXECUTION_OPERATORS.includes(key)) found.add(key);
walk(node[key], depth + 1);
});
};
walk(value, 0);
return [...found].sort();
}
/**
* True when `value` is an OPERATOR OBJECT - a plain object whose keys are Mongo
* operators - in a position that expected a scalar.
*
* Deliberately strict about what counts: EVERY key must be an operator. A plain
* object with ordinary keys is somebody sending the wrong type, which is a
* validation error and not an attack, and calling it one would fill the security
* report with noise.
*
* @param {*} value
* @return {boolean}
*/
function isOperatorObject(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return false;
const keys = Object.keys(value);
if (keys.length === 0) return false;
const known = COMPARISON_OPERATORS.concat(EXECUTION_OPERATORS);
return keys.every(key => known.includes(key));
}
/**
* The operators inside such an object, for the report. Empty when it is not one.
* @param {*} value
* @return {string[]}
*/
function operatorsOf(value) {
return isOperatorObject(value) ? Object.keys(value).sort() : [];
}
/**
* Classify one client-supplied value that should have been a scalar.
*
* @param {*} value the value as it arrived
* @return {{injection: boolean, kind: string, operators: string[]}}
* `kind` is 'execution' (the database would run something), 'operator' (a
* comparison where a value was expected) or '' (ordinary).
*/
function classifyScalarParam(value) {
const execution = executionOperatorsIn(value);
if (execution.length) {
return { injection: true, kind: 'execution', operators: execution };
}
if (isOperatorObject(value)) {
return { injection: true, kind: 'operator', operators: operatorsOf(value) };
}
return { injection: false, kind: '', operators: [] };
}
/**
* Classify a whole client-supplied SELECTOR. Only execution operators make a
* selector an attack - comparison operators are what a selector is made of.
* @param {*} selector
* @return {{injection: boolean, kind: string, operators: string[]}}
*/
function classifySelector(selector) {
const execution = executionOperatorsIn(selector);
return execution.length
? { injection: true, kind: 'execution', operators: execution }
: { injection: false, kind: '', operators: [] };
}
/**
* The one-clause summary a canary carries. Names the OPERATORS, which are a
* short fixed vocabulary, and never any value beside them - those are
* attacker-controlled.
* @param {{kind: string, operators: string[]}} classification
* @param {string} [where] the parameter or publication name
* @return {string}
*/
function injectionDetail(classification, where) {
const what =
classification.kind === 'execution'
? 'database-side execution operator'
: 'query operator where a value was expected';
const ops = classification.operators.join(', ');
return where ? `${what} (${ops}) in ${where}` : `${what} (${ops})`;
}
// Markup that would have DONE something if it had survived. Sanitizing text is
// routine - a pasted `<b>` gets stripped from a comment every day - and routine
// is not worth an admin's attention. A script tag, an event handler, a
// javascript: URI or an embedding element is not routine: it is a stored-XSS
// attempt that the sanitizer defused, and that is worth knowing about with the
// author attached.
const ACTIVE_MARKUP = [
/<\s*script\b/i,
/<\s*iframe\b/i,
/<\s*object\b/i,
/<\s*embed\b/i,
/<\s*svg\b/i,
/\son[a-z]+\s*=/i, // onerror=, onload=, onclick=, …
/javascript\s*:/i,
/data\s*:\s*text\/html/i,
];
/**
* True when sanitizing `original` into `sanitized` removed ACTIVE markup, as
* opposed to tidying formatting away.
*
* Compares what the sanitizer took OUT: text that never had any is not
* interesting, and text that still has it was not sanitized at all (which would
* be a different bug).
*
* @param {string} original
* @param {string} sanitized
* @return {boolean}
*/
function removedActiveMarkup(original, sanitized) {
if (typeof original !== 'string' || typeof sanitized !== 'string') return false;
if (original === sanitized) return false;
return ACTIVE_MARKUP.some(re => re.test(original) && !re.test(sanitized));
}
// Which canary id a caller passes for each finding. Kept here, beside the
// detector, so a call site does not have to guess and so the ids cannot drift
// from the catalog (models/lib/canaryTokens.js).
const CANARY_IDS = {
// A client-supplied SELECTOR carrying a database-side execution operator.
selector: 'injection.nosql-selector',
// An operator object where the value a user typed belongs.
operator: 'injection.nosql-operator',
// Text whose active markup the sanitizer had to remove.
text: 'sanitize.dangerous-text',
};
module.exports = {
EXECUTION_OPERATORS,
COMPARISON_OPERATORS,
ACTIVE_MARKUP,
CANARY_IDS,
removedActiveMarkup,
executionOperatorsIn,
isOperatorObject,
operatorsOf,
classifyScalarParam,
classifySelector,
injectionDetail,
};
+122
View File
@@ -0,0 +1,122 @@
// Pure, dependency-free check that a comment-reaction update only changes the
// ACTING USER's own reactions. No Meteor imports, so it is unit tested directly
// with plain Node (tests/reactionOwnership.test.cjs).
//
// Found while auditing for more of GHSA-pqr4-rxgp-hv2m (the REST comment delete
// that checked board membership and nothing about WHOSE comment it was). A
// CardCommentReactions document is
//
// { cardCommentId, reactions: [ { reactionCodepoint, userIds: [...] } ] }
//
// and its allow rule was `allowIsBoardMemberCommentOnly` for insert, update AND
// remove - board membership, and nothing about whose reaction is being changed.
// The whole `reactions` array is one field, so a member could $set it to
// anything: add their colleague's userId to a reaction they never made, or
// remove one they did. `toggleReaction()` only ever touches Meteor.userId(), so
// no legitimate client needs anything else - the rule simply never said so.
//
// Not a confidentiality bug: reactions are visible to everyone on the board
// already. It is an integrity one, and it puts words in another person's mouth.
/**
* Normalise a reactions array into `codepoint -> Set(userIds)`, ignoring
* anything that is not shaped like a reaction.
* @param {Array} reactions
* @return {Map<string, Set<string>>}
*/
function indexReactions(reactions) {
const index = new Map();
if (!Array.isArray(reactions)) return index;
reactions.forEach(reaction => {
if (!reaction || typeof reaction !== 'object') return;
const codepoint = reaction.reactionCodepoint;
if (typeof codepoint !== 'string') return;
const userIds = Array.isArray(reaction.userIds)
? reaction.userIds.filter(id => typeof id === 'string')
: [];
// A codepoint repeated in the array is still one reaction.
const existing = index.get(codepoint) || new Set();
userIds.forEach(id => existing.add(id));
index.set(codepoint, existing);
});
return index;
}
/**
* True when going from `oldReactions` to `newReactions` changes nothing about
* any user other than `userId`.
*
* Deliberately compares MEMBERSHIP rather than array order or shape: the client
* rebuilds the array on every toggle, so a reordered array with the same
* membership is the same set of reactions and must not be refused.
*
* @param {Array} oldReactions the reactions as stored
* @param {Array} newReactions the reactions being written
* @param {string} userId the acting user
* @return {boolean}
*/
function changesOnlyOwnReactions(oldReactions, newReactions, userId) {
if (typeof userId !== 'string' || !userId) return false;
const before = indexReactions(oldReactions);
const after = indexReactions(newReactions);
const codepoints = new Set([...before.keys(), ...after.keys()]);
for (const codepoint of codepoints) {
const wasThere = before.get(codepoint) || new Set();
const isThere = after.get(codepoint) || new Set();
// Everyone except the acting user must appear in exactly the same
// reactions before and after.
const othersBefore = [...wasThere].filter(id => id !== userId).sort();
const othersAfter = [...isThere].filter(id => id !== userId).sort();
if (othersBefore.length !== othersAfter.length) return false;
for (let i = 0; i < othersBefore.length; i++) {
if (othersBefore[i] !== othersAfter[i]) return false;
}
}
return true;
}
/**
* The deny-rule form: true means "refuse this write". A modifier that does not
* set `reactions` is not a reaction change and is left to the allow rule.
*
* @param {string} userId the acting user
* @param {object} doc the stored document (needs `reactions`)
* @param {object} modifier the update modifier
* @return {boolean}
*/
function denyForeignReactionChange(userId, doc, modifier) {
const set = modifier && modifier.$set;
if (!set || !Object.prototype.hasOwnProperty.call(set, 'reactions')) {
// Any OTHER way of writing the array - $push, $pull, $addToSet, a dotted
// `reactions.0.userIds` - cannot be checked field by field here, and no
// legitimate client sends one: toggleReaction() always $sets the whole
// array. Refuse those outright rather than letting them through unchecked.
const touchesReactions = modifier
? Object.keys(modifier).some(op => {
const value = modifier[op];
return (
value &&
typeof value === 'object' &&
Object.keys(value).some(field => field === 'reactions' || field.startsWith('reactions.'))
);
})
: false;
return touchesReactions;
}
return !changesOnlyOwnReactions(doc && doc.reactions, set.reactions, userId);
}
module.exports = {
indexReactions,
changesOnlyOwnReactions,
denyForeignReactionChange,
};
+16
View File
@@ -32,6 +32,22 @@ function sanitizeUploadedFileExploits(fileObj) {
});
}
// Active markup had to be REMOVED from an uploaded file - a script, an event
// handler, a javascript: URI inside an SVG. Nothing a drawing program produces
// needs any of it, so this is a stored-XSS attempt that was defused rather than
// a file that needed tidying, and it trips a canary with the uploader attached
// (docs/Security/Remediation/WeKan.md §12.6). The sanitized file is stored
// exactly as before; the uploader is told nothing.
if (changed) {
try {
const { tripCanary } = require('/server/lib/canary');
tripCanary('sanitize.dangerous-content', {
userId: (fileObj.userId || (fileObj.meta && fileObj.meta.userId)) || undefined,
detail: 'active markup removed from an uploaded ' + (fileObj.type || 'file'),
});
} catch (e) { /* a canary must never break an upload */ }
}
return changed;
}
+16
View File
@@ -33,6 +33,22 @@ const CATALOG = {
'file.disk': { category: 'file', bleed: 'FloppyBleed', severity: 'low', cwe: 'CWE-400' },
'file.avatar-url': { category: 'ssrf', bleed: 'RedirectBleed', severity: 'high', cwe: 'CWE-918' },
'file.policy': { category: 'file', bleed: 'PolicyBleed', severity: 'info', cwe: '' },
// Canary tokens (docs/Security/Remediation/WeKan.md §12). A canary sits where
// somebody could TRY to override permissions, so every one of these is an
// ATTEMPT that the rules already refused - the event is the attribution, not
// the defence. They resolve through this same catalog so a canary's category
// and *Bleed name cannot drift from the guard it sits next to.
'authz.canary': { category: 'authz', bleed: 'CanaryBleed', severity: 'medium', cwe: 'CWE-863' },
'authz.checklist': { category: 'authz', bleed: 'ChecklistBleed', severity: 'high', cwe: 'CWE-863' },
'authz.comment': { category: 'authz', bleed: 'CommentBleed', severity: 'medium', cwe: 'CWE-639' },
'authz.file-path': { category: 'authz', bleed: 'PathBleed', severity: 'high', cwe: 'CWE-22' },
'authz.parent': { category: 'authz', bleed: 'ParentBleed', severity: 'high', cwe: 'CWE-862' },
'authz.share': { category: 'authz', bleed: 'RevokeBleed', severity: 'high', cwe: 'CWE-863' },
'authz.readonly': { category: 'authz', bleed: 'ReadOnlyBleed', severity: 'medium', cwe: 'CWE-863' },
'authz.database': { category: 'authz', bleed: 'DatabaseBleed', severity: 'high', cwe: 'CWE-863' },
'injection.nosql': { category: 'injection', bleed: 'SelectorBleed', severity: 'high', cwe: 'CWE-943' },
'injection.sql': { category: 'injection', bleed: 'EscapeBleed', severity: 'critical', cwe: 'CWE-89' },
};
const DEFAULT = { category: 'unknown', bleed: 'Generic', severity: 'info', cwe: '' };
+84
View File
@@ -0,0 +1,84 @@
// Pure, dependency-free containment checks for a file path that came out of the
// DATABASE rather than off the disk. No Meteor imports, so it is unit tested
// directly with plain Node (tests/storagePathContainment.test.cjs).
//
// GHSA-4mxf-m8pq-xc9p: the board exporter read `versions.original.path` from an
// avatar document and streamed that file into the export as base64, trusting
// whatever Mongo held. The avatar allow rule let the owner write that field, so
// the export embedded the bytes of any file the WeKan process could read.
// Blocking the write is the first half of the fix; this is the second, and it is
// the half that also holds if a path is poisoned some other way - a document
// written before the fix, a restored backup, a bad migration.
//
// The DOWNLOAD path in models/lib/fileStoreStrategy.js has always checked
// containment this way; that function now lives here, so the download and the
// export ask the same question instead of each having their own answer. Drift
// between two copies of a rule is exactly what this advisory was.
const path = require('path');
/** Resolve, and lowercase on Windows, whose paths are case-insensitive. */
function normalizeForCompare(inputPath) {
const normalized = path.resolve(inputPath);
return process.platform === 'win32' ? normalized.toLowerCase() : normalized;
}
/**
* True when `targetPath` is `basePath` or lies under it.
*
* Resolving first is the whole point: '..' segments are what a plain
* `startsWith` gets wrong, so `/data/files/avatars/../../etc/passwd` is NOT
* inside `/data/files/avatars`. Comparing the RELATIVE path also means
* `/data/files/avatars-evil` is not inside `/data/files/avatars`, which a
* `startsWith` on the raw strings would accept.
*
* @param {string} basePath the directory
* @param {string} targetPath the path to test
* @return {boolean}
*/
function isPathInsideBase(basePath, targetPath) {
if (typeof basePath !== 'string' || !basePath) return false;
if (typeof targetPath !== 'string' || !targetPath) return false;
const normalizedBase = normalizeForCompare(basePath);
const normalizedTarget = normalizeForCompare(targetPath);
const relative = path.relative(normalizedBase, normalizedTarget);
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
}
/**
* The same question asked the other way round, for reading a FILE: is this
* candidate somewhere under that root? The root ITSELF is not an answer - a
* storage root is a directory, never the file being read - which is the one
* difference from isPathInsideBase().
*
* @param {string} candidate the path to check (as stored)
* @param {string} root the directory it must be under
* @return {boolean}
*/
function isPathInside(candidate, root) {
if (typeof candidate !== 'string' || !candidate) return false;
if (typeof root !== 'string' || !root) return false;
if (normalizeForCompare(candidate) === normalizeForCompare(root)) return false;
return isPathInsideBase(root, candidate);
}
/**
* Same, against several roots at once (attachments and avatars, say).
* @param {string} candidate
* @param {string[]} roots
* @return {boolean}
*/
function isPathInsideAny(candidate, roots) {
if (!Array.isArray(roots)) return false;
return roots.some((root) => isPathInside(candidate, root));
}
module.exports = {
normalizeForCompare,
isPathInsideBase,
isPathInside,
isPathInsideAny,
};
+17 -2
View File
@@ -328,8 +328,23 @@ Lists.helpers({
await card.move(boardId, this._id, boardList._id);
}
} else {
console.log('list.title:', this.title);
console.log('boardList:', boardList);
// A list with no usable title cannot be inserted: `title` is required by
// the schema, so the insert fails validation - and collection2's own error
// formatter then reads a property of the undefined field and throws
// ValidationError: Failed validation
// Cannot read properties of undefined (reading 'title')
// which is what an admin actually saw in Admin Panel / Problems /
// Database problems: an opaque crash naming neither the list nor the
// real problem. Say what is wrong instead, and say it before the insert.
// (The two console.log lines that used to be here printed it to a log
// nobody reads and did not stop the crash.)
if (typeof this.title !== 'string' || this.title.trim().length === 0) {
throw new Meteor.Error(
'list-has-no-title',
'This list has no title, so it cannot be moved to another board. ' +
'Give it a title first.',
);
}
listId = await Lists.insertAsync({
title: this.title,
boardId,
+7 -3
View File
@@ -243,9 +243,13 @@ Meteor.startup(() => {
// that is a likely forged-whitelisted-IP attempt (MetricsBleed) — record it.
if (req.headers['x-forwarded-for']) {
try {
require('/server/lib/securityLog').record({
key: 'spoofing.xff', action: 'blocked', source: 'metrics',
detail: 'denied /metrics with X-Forwarded-For present (socket ' + (req.socket && req.socket.remoteAddress) + ')',
// A canary, with the REQUEST in hand: nothing legitimate sends a
// forwarded-for header to an endpoint that does not trust one, so
// this is somebody trying to look like a whitelisted address. The
// 401 below is unchanged (docs/Security/Remediation/WeKan.md §12).
require('/server/lib/canary').tripCanary('spoof.forwarded-header', {
req,
detail: 'denied /metrics with X-Forwarded-For present',
});
} catch (e) { /* logging must never break the endpoint */ }
}
+42 -32
View File
@@ -1,17 +1,17 @@
{
"name": "wekan",
"version": "v10.73.0",
"version": "v10.76.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wekan",
"version": "v10.73.0",
"version": "v10.76.0",
"hasInstallScript": true,
"license": "MIT",
"dependencies": {
"@aws-sdk/client-s3": "^3.1095.0",
"@aws-sdk/lib-storage": "~3.1085.0",
"@aws-sdk/lib-storage": "~3.1104.0",
"@azure/storage-blob": "^12.33.0",
"@babel/parser": "^8.0.4",
"@babel/runtime": "^8.0.0",
@@ -44,7 +44,7 @@
"jquery-ui": "^1.14.2",
"jszip": "^3.7.1",
"limax": "^4.2.3",
"markdown-it": "^14.1.1",
"markdown-it": "^15.0.0",
"markdown-it-emoji": "^3.0.0",
"markdown-it-math": "^6.0.0",
"meteor-accounts-t9n": "^2.6.0",
@@ -278,13 +278,13 @@
}
},
"node_modules/@aws-sdk/lib-storage": {
"version": "3.1085.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.1085.0.tgz",
"integrity": "sha512-S+yCGIMxQ5Zs2A5ZDdYfXwOxu5kKjBaCQVOxp6+mnwCQYXUNkBD/aKCnW1eniMTavzz3YidhD5eTFpDlcUv2gQ==",
"version": "3.1104.0",
"resolved": "https://registry.npmjs.org/@aws-sdk/lib-storage/-/lib-storage-3.1104.0.tgz",
"integrity": "sha512-pseDO+9Dx8e8JXZPcrtmrwXqZjZpCmnALknKoHx8kFRXjlPtiJkoMtUNTLq2bnUsvGxQ7fST2EfD8zvGfK8hng==",
"license": "Apache-2.0",
"dependencies": {
"@smithy/core": "^3.29.2",
"@smithy/types": "^4.16.0",
"@smithy/core": "^3.31.1",
"@smithy/types": "^4.16.1",
"buffer": "5.6.0",
"events": "3.3.0",
"stream-browserify": "3.0.0",
@@ -294,7 +294,7 @@
"node": ">=20.0.0"
},
"peerDependencies": {
"@aws-sdk/client-s3": "^3.1085.0"
"@aws-sdk/client-s3": "^3.1104.0"
}
},
"node_modules/@aws-sdk/middleware-sdk-s3": {
@@ -4172,9 +4172,19 @@
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-3.0.0.tgz",
"integrity": "sha512-BOp5NMrHqKxmq/OLr+clzzrRxgOKSLkcjmkWuChp7Irqwn4s74WjOBPIgWfA/HMcBnVkZ5XEuf9uUqzlpfCQ6A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "Python-2.0"
},
"node_modules/array-flatten": {
@@ -8032,9 +8042,9 @@
}
},
"node_modules/linkify-it": {
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.2.tgz",
"integrity": "sha512-ONTm2jCMAVZjgQa/Fy1kScXsuOoF5NPTsoFBdE1KVIZ2vAh/r9+Bqo+0jINCBYnavTPQZz38QzFTme79ENoN3Q==",
"version": "6.1.0",
"resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-6.1.0.tgz",
"integrity": "sha512-wJ/TwpSDTLepCrQoYWYIExIKg5Zchex2Nn5yk2mFnB+6PtdkHtyLx742md9csRjjOnGkKIS/RrbY7l8D6gT9Vw==",
"funding": [
{
"type": "github",
@@ -8047,7 +8057,7 @@
],
"license": "MIT",
"dependencies": {
"uc.micro": "^2.0.0"
"uc.micro": "^3.0.0"
}
},
"node_modules/locate-path": {
@@ -8152,9 +8162,9 @@
}
},
"node_modules/markdown-it": {
"version": "14.3.0",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.3.0.tgz",
"integrity": "sha512-RCEsPjR+sr0x+AuYp601tKTkgFG4YEPLCzHST3cQ/fhlJkqAkz1L2/Qbp1j9qw5SBwQHFBoW8+hoN5xssOF0Tw==",
"version": "15.0.0",
"resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-15.0.0.tgz",
"integrity": "sha512-Lf8ajvVNdRpzSNB4VegxNy7gjs8gU35l4b4+ET49LrQC5PKYwLZ72u60LeJ9gv3qiaesuYjJWCyVeQmv/QWKQw==",
"funding": [
{
"type": "github",
@@ -8167,12 +8177,12 @@
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1",
"entities": "^4.5.0",
"linkify-it": "^5.0.2",
"mdurl": "^2.0.0",
"argparse": "^3.0.0",
"entities": "^8.0.0",
"linkify-it": "^6.0.0",
"mdurl": "^2.1.0",
"punycode.js": "^2.3.1",
"uc.micro": "^2.1.0"
"uc.micro": "^3.0.0"
},
"bin": {
"markdown-it": "bin/markdown-it.mjs"
@@ -8195,12 +8205,12 @@
}
},
"node_modules/markdown-it/node_modules/entities": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz",
"integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=0.12"
"node": ">=20.19.0"
},
"funding": {
"url": "https://github.com/fb55/entities?sponsor=1"
@@ -10936,9 +10946,9 @@
}
},
"node_modules/uc.micro": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz",
"integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==",
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-3.0.0.tgz",
"integrity": "sha512-U3PppEkleoTnIfi8BozMx3yju3qc/L6SwqWo2Sw+54PX+PX0q9I+r1Um5HCmqD7n9VDX5/v3vQH/AjA6deDdtw==",
"license": "MIT"
},
"node_modules/undate": {
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "wekan",
"version": "v10.73.0",
"version": "v10.76.0",
"description": "Open-Source kanban",
"private": true,
"repository": {
@@ -54,7 +54,7 @@
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1095.0",
"@aws-sdk/lib-storage": "~3.1085.0",
"@aws-sdk/lib-storage": "~3.1104.0",
"@azure/storage-blob": "^12.33.0",
"@babel/parser": "^8.0.4",
"@babel/runtime": "^8.0.0",
@@ -87,7 +87,7 @@
"jquery-ui": "^1.14.2",
"jszip": "^3.7.1",
"limax": "^4.2.3",
"markdown-it": "^14.1.1",
"markdown-it": "^15.0.0",
"markdown-it-emoji": "^3.0.0",
"markdown-it-math": "^6.0.0",
"meteor-accounts-t9n": "^2.6.0",
+3 -3
View File
@@ -3,7 +3,7 @@
<head>
<meta charset="utf8" />
<title>WeKan REST API v10.73</title>
<title>WeKan REST API v10.76</title>
<!-- needed for adaptive design -->
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
@@ -423,7 +423,7 @@ data-styled.g139[id="sc-iIvZUO"]{content:"kAmnlO,"}/*!sc*/
55.627 l 55.6165,55.627 -231.245496,231.24803 c -127.185,127.1864
-231.5279,231.248 -231.873,231.248 -0.3451,0 -104.688,
-104.0616 -231.873,-231.248 z
" fill="currentColor"></path></g></svg></div></div><div class="sc-jtyQFi ldXCvc api-content"><div class="sc-eCQgVK jleRXN"><div class="sc-iCECmn lhRBUS"><div class="sc-hKVpXn kktglN api-info"><h1 class="sc-fuztkK sc-ctiVdb eRMVDe cBgvIi">Wekan REST API<!-- --> <span>(<!-- -->v10.73<!-- -->)</span></h1><p>Download OpenAPI specification<!-- -->:</p><div class="sc-iJSMbW sc-cBEgGa fiNpIH ewCFMV"></div><div data-role="redoc-summary" html="" class="sc-iJSMbW sc-cBEgGa fiNpIH ewCFMV"></div><div data-role="redoc-description" html="&lt;p&gt;The REST API allows you to control and extend Wekan with ease.&lt;/p&gt;
" fill="currentColor"></path></g></svg></div></div><div class="sc-jtyQFi ldXCvc api-content"><div class="sc-eCQgVK jleRXN"><div class="sc-iCECmn lhRBUS"><div class="sc-hKVpXn kktglN api-info"><h1 class="sc-fuztkK sc-ctiVdb eRMVDe cBgvIi">Wekan REST API<!-- --> <span>(<!-- -->v10.76<!-- -->)</span></h1><p>Download OpenAPI specification<!-- -->:</p><div class="sc-iJSMbW sc-cBEgGa fiNpIH ewCFMV"></div><div data-role="redoc-summary" html="" class="sc-iJSMbW sc-cBEgGa fiNpIH ewCFMV"></div><div data-role="redoc-description" html="&lt;p&gt;The REST API allows you to control and extend Wekan with ease.&lt;/p&gt;
&lt;p&gt;If you are an end-user and not a dev or a tester, &lt;a href=&quot;https://github.com/wekan/wekan/issues/new&quot;&gt;create an issue&lt;/a&gt; to request new APIs.&lt;/p&gt;
&lt;blockquote&gt;
&lt;p&gt;All API calls in the documentation are made using &lt;code&gt;curl&lt;/code&gt;. However, you are free to use Java / Python / PHP / Golang / Ruby / Swift / Objective-C / Rust / Scala / C# or any other programming languages.&lt;/p&gt;
@@ -2005,7 +2005,7 @@ comment-only / worker members are rejected).</p>
" class="sc-iJSMbW sc-cBEgGa sc-ciCrSJ fiNpIH dNfUH dDDioG"><p>Attachment not found</p>
</div></button></div></div></div><div class="sc-jSppWd sc-gKkgUA fpMlmc bdQQyo"><div class="sc-fXwuWv fYxpnv"><button class="sc-jWMFtl jzaJhV"><span type="delete" class="sc-eEFuoE iINwyG http-verb delete">delete</span><span class="sc-FpjRO dsIRaZ">/api/attachment/delete/{attachmentId}</span><svg class="sc-dYjPD bQKUih" style="margin-right:-25px" version="1.1" viewBox="0 0 24 24" x="0" xmlns="http://www.w3.org/2000/svg" y="0" aria-hidden="true"><polygon points="17.3 8.3 12 13.6 6.7 8.3 5.3 9.7 12 16.4 18.7 9.7 "></polygon></svg></button><div aria-hidden="true" class="sc-fmtEmb tUxhh"><div class="sc-ljIcGq bAnPPh"><div html="" class="sc-iJSMbW sc-cBEgGa fiNpIH bAoMjv"></div><div tabindex="0" role="button"><div class="sc-jlJOIR PEJyb"><span></span>/api/attachment/delete/{attachmentId}</div></div></div></div></div></div></div></div></div><div class="sc-ekVkVN bjzTxL"></div></div></div>
<script>
const __redoc_state = {"menu":{"activeItemIdx":-1},"spec":{"data":{"openapi":"3.0.0","info":{"title":"Wekan REST API","version":"v10.73","description":"The REST API allows you to control and extend Wekan with ease.\n\nIf you are an end-user and not a dev or a tester, [create an issue](https://github.com/wekan/wekan/issues/new) to request new APIs.\n\n> All API calls in the documentation are made using `curl`. However, you are free to use Java / Python / PHP / Golang / Ruby / Swift / Objective-C / Rust / Scala / C# or any other programming languages.\n\n# Production Security Concerns\nWhen calling a production Wekan server, ensure it is running via HTTPS and has a valid SSL Certificate. The login method requires you to post your username and password in plaintext, which is why we highly suggest only calling the REST login api over HTTPS. Also, few things to note:\n\n* Only call via HTTPS\n* Implement a timed authorization token expiration strategy\n* Ensure the calling user only has permissions for what they are calling and no more\n"},"paths":{"/users/login":{"post":{"operationId":"login","summary":"Login with REST API","tags":["Login"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}},"application/x-www-form-urlencoded":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}}},"description":"Login credentials","required":true},"responses":{"200":{"description":"Successful authentication","content":{"*/*":{"schema":{"type":"object","required":["id","token","tokenExpires"],"properties":{"id":{"type":"string","description":"User ID"},"token":{"type":"string","description":"Authentication token\n"},"tokenExpires":{"type":"string","format":"date-time","description":"Token expiration date\n"}}}}}},"400":{"description":"Error in authentication\n","content":{"*/*":{"schema":{"type":"object","properties":{"error":{"type":"string"},"reason":{"type":"string"}}}}}},"default":{"description":"Error in authentication\n"}}}},"/users/register":{"post":{"operationId":"register","summary":"Register with REST API","description":"Notes:\n - You will need to provide the token for any of the authenticated methods.\n","tags":["Login"],"requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"},"email":{"description":"Your email\n","type":"string"}},"required":["username","password","email"]}}},"required":true},"responses":{"200":{"description":"Successful registration","content":{"*/*":{"schema":{"items":{"properties":{"id":{"type":"string"},"token":{"type":"string"},"tokenExpires":{"type":"string"}}}}}}},"400":{"description":"Error in registration\n","content":{"*/*":{"schema":{"items":{"properties":{"error":{"type":"number"},"reason":{"type":"string"}}}}}}},"default":{"description":"Error in registration\n"}}}},"/api/admin/attachment-settings":{"get":{"operationId":"get_attachment_settings","summary":"Get the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. Returns the Admin Panel >\n Attachments settings document, including `limitSettings.attachmentsUploadBlocked`\n and `limitSettings.avatarsUploadBlocked` plus the size limits. Cloud-storage\n secret keys (S3 secretAccessKey, Azure accountKey/connectionString, GCS\n credentials) are masked and never returned; a boolean `<field>Set` marker\n shows whether a value exists.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}},"put":{"operationId":"update_attachment_settings","summary":"Update the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. The request body is a\n partial settings object (for example\n `{\"limitSettings\": {\"avatarsUploadBlocked\": true}}`). It is applied through\n the same update path as the `updateAttachmentStorageSettings` Meteor method,\n so validation and `$set` semantics are identical: cloud-storage secrets left\n blank are preserved. The masked, updated settings document is returned.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}}},"/api/admin/domains":{"get":{"operationId":"get_admin_domains","summary":"List email domains with their user counts (GlobalAdmin)","description":"Only the Line truncated
const __redoc_state = {"menu":{"activeItemIdx":-1},"spec":{"data":{"openapi":"3.0.0","info":{"title":"Wekan REST API","version":"v10.76","description":"The REST API allows you to control and extend Wekan with ease.\n\nIf you are an end-user and not a dev or a tester, [create an issue](https://github.com/wekan/wekan/issues/new) to request new APIs.\n\n> All API calls in the documentation are made using `curl`. However, you are free to use Java / Python / PHP / Golang / Ruby / Swift / Objective-C / Rust / Scala / C# or any other programming languages.\n\n# Production Security Concerns\nWhen calling a production Wekan server, ensure it is running via HTTPS and has a valid SSL Certificate. The login method requires you to post your username and password in plaintext, which is why we highly suggest only calling the REST login api over HTTPS. Also, few things to note:\n\n* Only call via HTTPS\n* Implement a timed authorization token expiration strategy\n* Ensure the calling user only has permissions for what they are calling and no more\n"},"paths":{"/users/login":{"post":{"operationId":"login","summary":"Login with REST API","tags":["Login"],"requestBody":{"content":{"application/json":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}},"application/x-www-form-urlencoded":{"schema":{"type":"object","required":["username","password"],"properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"}}}}},"description":"Login credentials","required":true},"responses":{"200":{"description":"Successful authentication","content":{"*/*":{"schema":{"type":"object","required":["id","token","tokenExpires"],"properties":{"id":{"type":"string","description":"User ID"},"token":{"type":"string","description":"Authentication token\n"},"tokenExpires":{"type":"string","format":"date-time","description":"Token expiration date\n"}}}}}},"400":{"description":"Error in authentication\n","content":{"*/*":{"schema":{"type":"object","properties":{"error":{"type":"string"},"reason":{"type":"string"}}}}}},"default":{"description":"Error in authentication\n"}}}},"/users/register":{"post":{"operationId":"register","summary":"Register with REST API","description":"Notes:\n - You will need to provide the token for any of the authenticated methods.\n","tags":["Login"],"requestBody":{"content":{"application/x-www-form-urlencoded":{"schema":{"type":"object","properties":{"username":{"description":"Your username\n","type":"string"},"password":{"description":"Your password\n","type":"string","format":"password"},"email":{"description":"Your email\n","type":"string"}},"required":["username","password","email"]}}},"required":true},"responses":{"200":{"description":"Successful registration","content":{"*/*":{"schema":{"items":{"properties":{"id":{"type":"string"},"token":{"type":"string"},"tokenExpires":{"type":"string"}}}}}}},"400":{"description":"Error in registration\n","content":{"*/*":{"schema":{"items":{"properties":{"error":{"type":"number"},"reason":{"type":"string"}}}}}}},"default":{"description":"Error in registration\n"}}}},"/api/admin/attachment-settings":{"get":{"operationId":"get_attachment_settings","summary":"Get the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. Returns the Admin Panel >\n Attachments settings document, including `limitSettings.attachmentsUploadBlocked`\n and `limitSettings.avatarsUploadBlocked` plus the size limits. Cloud-storage\n secret keys (S3 secretAccessKey, Azure accountKey/connectionString, GCS\n credentials) are masked and never returned; a boolean `<field>Set` marker\n shows whether a value exists.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}},"put":{"operationId":"update_attachment_settings","summary":"Update the attachment storage / upload-block settings (GlobalAdmin)","description":"Only the global admin can call this. The request body is a\n partial settings object (for example\n `{\"limitSettings\": {\"avatarsUploadBlocked\": true}}`). It is applied through\n the same update path as the `updateAttachmentStorageSettings` Meteor method,\n so validation and `$set` semantics are identical: cloud-storage secrets left\n blank are preserved. The masked, updated settings document is returned.\n","tags":["AttachmentStorageSettings","Settings"],"security":[{"UserSecurity":[]}],"responses":{"200":{"description":"200 response","content":{"application/json":{"schema":{"type":"Object"}}}}}}},"/api/admin/domains":{"get":{"operationId":"get_admin_domains","summary":"List email domains with their user counts (GlobalAdmin)","description":"Only the Line truncated
var container = document.getElementById('redoc');
Redoc.hydrate(__redoc_state, container);
+1 -1
View File
@@ -1,7 +1,7 @@
swagger: '2.0'
info:
title: Wekan REST API
version: v10.73
version: v10.76
description: |
The REST API allows you to control and extend Wekan with ease.
-50
View File
@@ -1,50 +0,0 @@
#!/bin/bash
# Build WeKan arm64 bundle - Meteor 3 style (no fibers needed).
#
# This script rebuilds only the native Node.js modules for arm64.
# Run on an arm64 machine, or use the GitHub Actions ubuntu-24.04-arm runner.
#
# Usage:
# ./releases/build-bundle-arm64.sh 8.43
if [ $# -ne 1 ]; then
echo "Syntax with Wekan version number:"
echo " ./releases/build-bundle-arm64.sh 8.43"
exit 1
fi
VERSION=$1
# Install build dependencies
sudo apt-get update
sudo apt-get install -y build-essential g++ make python3 curl wget zip unzip
# Remove old files
rm -rf bundle
rm -f wekan-${VERSION}-arm64.zip wekan-${VERSION}-amd64.zip
# Download the amd64 bundle as the base
wget --no-check-certificate \
https://github.com/wekan/wekan/releases/download/v${VERSION}/wekan-${VERSION}-amd64.zip
# Extract bundle
unzip wekan-${VERSION}-amd64.zip
# Rebuild native Node.js modules for arm64 (no fibers or bcrypt patching needed with Meteor 3)
cd bundle/programs/server
npm rebuild
cd ../../..
# Clean up temporary files
cd bundle
find . -type d -name '*-garbage*' -exec rm -rf {} + 2>/dev/null || true
find . -name '*phantom*' -exec rm -rf {} + 2>/dev/null || true
find . -name '.*.swp' -delete 2>/dev/null || true
find . -name '*.swp' -delete 2>/dev/null || true
cd ..
# Create arm64 bundle
zip -r wekan-${VERSION}-arm64.zip bundle
echo "Done: wekan-${VERSION}-arm64.zip"
@@ -1,362 +0,0 @@
cd ~/repos/wekan
echo "Arabic:"
tx pull -f -l ar
echo "Arabic (Egypt), simply Masri (مَصرى, [ˈmɑsˤɾi], Egyptian, Masr refers to Cairo):"
tx pull -f -l ar_EG
echo "Arabic (Moroccan):"
tx pull -f -l ary
echo "Arabic (Algeria):"
tx pull -f -l ar_DZ
echo "Azerbaijani:"
tx pull -f -l az
echo "Azerbaijani (Azerbaijan):"
tx pull -f -l az_AZ
echo "Azerbaijani (Latin):"
tx pull -f -l az@latin
echo "Armenian:"
tx pull -f -l hy
echo "Basque:"
tx pull -f -l eu
echo "Bulgarian:"
tx pull -f -l bg_BG
echo "Breton:"
tx pull -f -l br
echo "Catalan:"
tx pull -f -l ca
echo "Chinese (China):"
tx pull -f -l zh_CN
echo "Chinese (Hong Kong):"
tx pull -f -l zh_HK
echo "Chinese Simplified (zh-Hans):"
tx pull -f -l zh_Hans
echo "Chinese (Taiwan):"
tx pull -f -l zh_TW
echo "Croatian:"
tx pull -f -l hr
echo "Czech:"
tx pull -f -l cs
echo "Czech (Czech Republic):"
tx pull -f -l cs_CZ
echo "Danish:"
tx pull -f -l da
echo "Dutch:"
tx pull -f -l nl
echo "Dutch (Netherlands):"
tx pull -f -l nl_NL
echo "Esperanto:"
tx pull -f -l eo
echo "Estonian (Estonia):"
tx pull -f -l et_EE
echo "English (Brazil):"
tx pull -f -l en_BR
echo "English (Germany):"
tx pull -f -l en_DE
echo "English (Italy):"
tx pull -f -l en_IT
echo "English (Malaysia) (en_MY):"
tx pull -f -l en_MY
echo "English (United Kingdom):"
tx pull -f -l en_GB
echo "Finnish:"
tx pull -f -l fi
echo "French:"
tx pull -f -l fr
echo "French (Switzerland):"
tx pull -f -l fr_CH
echo "Galician:"
tx pull -f -l gl
echo "Galician (Spain):"
tx pull -f -l gl_ES
echo "Georgian:"
tx pull -f -l ka
echo "German:"
tx pull -f -l de
echo "German (Austria):"
tx pull -f -l de_AT
echo "German (Switzerland) => Deutsch (Schweiz):"
tx pull -f -l de_CH
#echo "Greek:"
#tx pull -f -l el
echo "Greek (Greece):"
tx pull -f -l el_GR
echo "Gurajati (India):"
tx pull -f -l gu_IN
echo "Hebrew:"
tx pull -f -l he
echo "Hebrew (Israel):"
tx pull -f -l he_IL
echo "Hungarian:"
tx pull -f -l hu_HU
echo "Hindi:"
tx pull -f -l hi
echo "Hindi (India)":
tx pull -f -l hi_IN
echo "Khmer (km)":
tx pull -f -l km
echo "Lithuanian:"
tx pull -f -l lt
echo "Persian:"
tx pull -f -l fa
echo "Persian (Iran) فارسی/پارسی (ایران‎):"
tx pull -f -l fa_IR
echo "Indonesian (Indonesia):"
tx pull -f -l id_ID
echo "Igbo:"
tx pull -f -l ig
echo "Italian:"
tx pull -f -l it
echo "Japanese:"
tx pull -f -l ja
echo "Japanese (Hiragana):"
tx pull -f -l ja-Hira
echo "Khmer:"
tx pull -f -l km
echo "Korean:"
tx pull -f -l ko
echo "Korean (Korea):"
tx pull -f -l ko_KR
echo "Latvian (Latvia):"
tx pull -f -l lv_LV
echo "Macedonian:"
tx pull -f -l mk
echo "Malay (ms):"
tx pull -f -l ms
echo "Malay (Malaysia) (ms_MY):"
tx pull -f -l ms_MY
echo "Mongolian (Mongolia):"
tx pull -f -l mn_MN
#echo "Norwegian:"
#tx pull -f -l no
# This is same as no below
echo "Norwegian Bokmål:"
tx pull -f -l nb
echo "Occitan (post 1500):"
tx pull -f -l oc
echo "Persian:"
tx pull -f -l fa
echo "Panjabi (Punjabi):"
tx pull -f -l pa
echo "Polish:"
tx pull -f -l pl
echo "Polish (Poland):"
tx pull -f -l pl_PL
echo "Portuguese:"
tx pull -f -l pt
echo "Portuguese (Brazil):"
tx pull -f -l pt_BR
echo "Romanian:"
tx pull -f -l ro
echo "Romanian (Romania):"
tx pull -f -l ro_RO
echo "Russian:"
tx pull -f -l ru
echo "Russian (Ukraine):"
tx pull -f -l ru_UA
echo "Spanish:"
tx pull -f -l es
echo "Spanish (Argentina):"
tx pull -f -l es_AR
echo "Spanish (Asturia):"
tx pull -f -l ast_ES
echo "Spanish (Chile):"
tx pull -f -l es_CL
echo "Spanish (Latin America)":
tx pull -f -l es_419
echo "Spanish (Mexico)":
tx pull -f -l es_MX
echo "Spanish (Paraguay):"
tx pull -f -l es_PY
echo "Spanish (Peru):"
tx pull -f -l es_PE
echo "Serbian:"
tx pull -f -l sr
echo "Slovak:"
tx pull -f -l sk
echo "Slovenian:"
tx pull -f -l sl_SI
echo "Standard Moroccan Tamazight"
tx pull -f -l zgh
echo "Swahili:"
tx pull -f -l sw
echo "Swedish:"
tx pull -f -l sv
echo "Tamil:"
tx pull -f -l ta
echo "Thai:"
tx pull -f -l th
echo "Turkish:"
tx pull -f -l tr
echo "Ukrainian:"
tx pull -f -l uk
echo "Ukrainian (Ukraine):"
tx pull -f -l uk_UA
echo "Uyghur (ug):"
tx pull -f -l ug
echo "Uzbek (uz):"
tx pull -f -l uz
echo "Uzbek (Arabic) (uz@Arab):"
tx pull -f -l uz@Arab
echo "Uzbek (Latin) (uz@Latn):"
tx pull -f -l uz@Latn
echo "Uzbek (Uzbekistan) (uz_UZ):"
tx pull -f -l uz_UZ
echo "Venda (ve):"
tx pull -f -l ve
echo "Venetian (vec):"
tx pull -f -l vec
echo "Vepsian (vep):"
tx pull -f -l vep
echo "Vietnamese:"
tx pull -f -l vi
echo "Vietnamese (Viet Nam) (vi_VN):"
tx pull -f -l vi_VN
echo "Vlaams (vls):"
tx pull -f -l vls
echo "Volapük (vo):"
tx pull -f -l vo
echo "Walloon (wa):"
tx pull -f -l wa
echo "Welsh (cy):"
tx pull -f -l cy
echo "Welsh (United Kingdom) (cy_GB):"
tx pull -f -l cy_GB
echo "Western Frisian (fy):"
tx pull -f -l fy
echo "Western Frisian (Netherlands) (fy_NL):"
tx pull -f -l fy_NL
echo "Wolof (wo):"
tx pull -f -l wo
echo "Wáray-Wáray (war):"
tx pull -f -l war
echo "Xhosa (xh):"
tx pull -f -l xh
echo "Yeshivish English (en@ysv):"
tx pull -f -l en@ysv
echo "Yiddish (yi):"
tx pull -f -l yi
echo "Yoruba (yo):"
tx pull -f -l yo
echo "Zulu (zu):"
tx pull -f -l zu
echo "Zulu (South Africa) (zu_ZA):"
tx pull -f -l zu_ZA
+2 -2
View File
@@ -22,10 +22,10 @@ const pkgdef :Spk.PackageDefinition = (
appTitle = (defaultText = "Wekan"),
# The name of the app as it is displayed to the user.
appVersion = 1073,
appVersion = 1076,
# Increment this for every release.
appMarketingVersion = (defaultText = "10.73~2026-08-08"),
appMarketingVersion = (defaultText = "10.76~2026-08-09"),
# Human-readable presentation of the app version.
minUpgradableAppVersion = 0,
+10
View File
@@ -74,6 +74,16 @@ WebApp.handlers.post('/users/login', async function (req, res) {
// out, before doing any user lookup or password work.
const gate = restLoginThrottle.check(clientKey, now);
if (gate.blocked) {
// A canary: being locked out means this address already failed the
// configured number of logins in a row, which nobody typing their own
// password does. The 429 below is unchanged - the attacker learns only
// what the throttle already told them (docs/Security/Remediation/WeKan.md §12).
try {
require('/server/lib/canary').tripCanary('brute.login-lockout', {
req,
detail: 'REST login refused while locked out',
});
} catch (e) { /* a canary must never break the login path */ }
const retryAfterSeconds = Math.ceil(gate.retryAfterMs / 1000);
const error = new Meteor.Error(
'too-many-requests',
+5
View File
@@ -204,6 +204,11 @@ import '/server/lib/tenantResolver';
import '/server/lib/ssrfGuard';
import '/server/lib/ddpSessionSendGuard';
import '/server/lib/databaseProblems';
// What the FILESYSTEM said, and whether this server stopped cleanly last time
// (docs/Security/Remediation/WeKan.md §13). Both write to the 'integrity' event
// stream, shown in Admin Panel / Problems / Filesystem integrity.
import '/server/lib/fileIntegrityScan';
import '/server/lib/uptimeWatch';
import '/server/lib/utils';
// ----------------------------------------------------------------------------
+230
View File
@@ -0,0 +1,230 @@
// ============================================================================
// Canary tokens — the runtime half
// (design: docs/Security/Remediation/WeKan.md §12; pure half:
// models/lib/canaryTokens.js)
// ----------------------------------------------------------------------------
// `tripCanary()` is callable from ANY point in WeKan where somebody could try to
// override permissions - an allow/deny rule, a REST handler, a publication, a
// method. It resolves who is calling and from where, rate-limits, records one
// security event, and RETURNS FALSE.
//
// Returning false is the interface. Every one of these call sites already had to
// produce a refusal, so the canary is written as a drop-in for it:
//
// if (somethingForbidden) return false; // before
// if (somethingForbidden) return tripCanary('card.vote-field', { ... });
//
// and a deny rule, which refuses by returning TRUE, uses `tripCanaryDeny()`.
// Nothing about the response, its timing, its wording or its status changes, so
// the person probing cannot tell a canaried path from an ordinary refusal - if
// they could, they would simply avoid the canaried ones.
//
// NOTHING here may throw into the caller. A tripwire that breaks the request it
// is watching would be a denial of service triggered by trying to report one.
// Every step is wrapped, and the return value is decided before any of the
// reporting work is attempted.
// ============================================================================
import { Meteor } from 'meteor/meteor';
import securityLog from '/server/lib/securityLog';
const {
CanaryRateLimiter,
canaryFor,
canaryPairKey,
canaryDetail,
} = require('/models/lib/canaryTokens');
const { resolveClientKey } = require('/server/lib/loginAttemptThrottle');
// One limiter for the process. Bounded by construction (models/lib/canaryTokens.js):
// a capped map, a counting window, and a ceiling on events per pair.
const limiter = new CanaryRateLimiter();
// Idle pairs are swept so a long-lived server does not hold a row per address
// seen since boot. Ten minutes of silence and the pair is forgotten; its trips
// were all recorded already.
const SWEEP_EVERY_MS = 10 * 60 * 1000;
const IDLE_MS = 10 * 60 * 1000;
if (Meteor.isServer) {
Meteor.startup(() => {
Meteor.setInterval(() => {
try {
limiter.sweep(Date.now(), IDLE_MS);
} catch (e) {
/* a sweeper that throws must not take the timer down */
}
}, SWEEP_EVERY_MS);
});
}
// The username is looked up once per userId and cached briefly: a canary on a
// hot path must not add a database read per attempt, which would hand the
// attacker a cheap way to make WeKan work harder the faster they probe.
const NAME_TTL_MS = 60 * 1000;
const NAME_CACHE_MAX = 1000;
const nameCache = new Map();
function cachedUsername(userId) {
const hit = nameCache.get(userId);
if (hit && Date.now() - hit.at < NAME_TTL_MS) return hit.username;
return undefined;
}
function rememberUsername(userId, username) {
if (nameCache.size >= NAME_CACHE_MAX) {
const oldest = nameCache.keys().next();
if (!oldest.done) nameCache.delete(oldest.value);
}
nameCache.set(userId, { username, at: Date.now() });
}
/**
* Who is doing this, and from where.
*
* Sources, in order: what the caller passed (a REST handler knows its own
* `req`), then the current DDP invocation. The address is resolved with the
* SAME spoofing-safe rule as the login throttle - X-Forwarded-For is honoured
* only as far as HTTP_FORWARDED_COUNT says to trust it - so an attacker cannot
* write someone else's address into the security log by sending a header.
*/
function resolveActor(given = {}) {
const actor = { userId: null, username: '', ip: '' };
try {
if (given.userId) actor.userId = String(given.userId);
if (given.username) actor.username = String(given.username);
if (given.ip) actor.ip = String(given.ip);
if (given.req) {
const req = given.req;
if (!actor.userId && req.userId) actor.userId = String(req.userId);
if (!actor.ip) {
actor.ip = resolveClientKey({
headers: req.headers,
socketAddress:
(req.socket && req.socket.remoteAddress) ||
(req.connection && req.connection.remoteAddress),
forwardedCount: process.env.HTTP_FORWARDED_COUNT,
});
}
}
if (!actor.userId || !actor.ip) {
// A DDP method or allow/deny rule: Meteor exposes the invocation, whose
// `connection` carries the address it already resolved (honouring the
// same HTTP_FORWARDED_COUNT).
const invocation =
typeof DDP !== 'undefined' && DDP._CurrentMethodInvocation
? DDP._CurrentMethodInvocation.get()
: null;
if (invocation) {
if (!actor.userId && invocation.userId) actor.userId = String(invocation.userId);
const connection = invocation.connection;
if (!actor.ip && connection && connection.clientAddress) {
actor.ip = String(connection.clientAddress);
}
}
}
} catch (e) {
/* an actor we could not resolve is still an actor: record what we have */
}
if (!actor.ip) actor.ip = 'unknown';
return actor;
}
/** Fill in the username for a userId, from cache or one lookup. Never throws. */
async function fillUsername(actor) {
if (actor.username || !actor.userId) return actor;
try {
const cached = cachedUsername(actor.userId);
if (cached !== undefined) {
actor.username = cached;
return actor;
}
const user = await Meteor.users.findOneAsync(actor.userId, {
fields: { username: 1 },
});
actor.username = (user && user.username) || '';
rememberUsername(actor.userId, actor.username);
} catch (e) {
/* the account may be gone; the userId is still attribution enough */
}
return actor;
}
/**
* Trip a canary. ALWAYS returns false, immediately - the reporting happens
* afterwards and off the caller's path.
*
* @param {string} canaryId an id from the catalog in models/lib/canaryTokens.js
* @param {object} [context]
* @param {string} [context.userId] the actor, when the caller knows it
* @param {string} [context.username] ...and their name, if already at hand
* @param {string} [context.ip] ...and their address
* @param {object} [context.req] or the HTTP request to take both from
* @param {string} [context.detail] one short clause of extra context. NEVER a
* payload: it is attacker-controlled text and
* the log is not a place to store it.
* @return {false} always
*/
export function tripCanary(canaryId, context = {}) {
try {
report(canaryId, context);
} catch (e) {
if (process.env.DEBUG === 'true') {
console.warn('canary reporting failed:', e && e.message);
}
}
return false;
}
/**
* The same tripwire for a DENY rule, which refuses by returning TRUE.
* @return {true} always
*/
export function tripCanaryDeny(canaryId, context = {}) {
tripCanary(canaryId, context);
return true;
}
/**
* Report, without blocking or affecting the caller. Split out so `tripCanary`
* is nothing but "wrap, and return false".
*/
function report(canaryId, context) {
const canary = canaryFor(canaryId);
const actor = resolveActor(context);
const pair = canaryPairKey(canary.id, actor);
// Decide BEFORE doing any work: a suppressed trip costs one map lookup, which
// is what makes a canary safe to put on a path an attacker can hammer.
const decision = limiter.consider(pair, Date.now());
if (!decision.write) return;
// The username lookup and the insert are both async and neither is awaited:
// the caller has already been given its `false`.
const finish = async () => {
await fillUsername(actor);
securityLog.record({
key: canary.key,
action: 'detected',
source: `canary:${canary.id}`,
userId: actor.userId || undefined,
username: actor.username || undefined,
ip: actor.ip,
count: decision.count,
detail: canaryDetail(canary, decision.count, context.detail),
});
};
const p = finish();
if (p && typeof p.catch === 'function') p.catch(() => {});
}
/** For tests and the admin self-check: how many pairs are being tracked. */
export function canaryTrackedPairs() {
return limiter.size();
}
export default { tripCanary, tripCanaryDeny, canaryTrackedPairs };
+79
View File
@@ -40,10 +40,89 @@ function tooRecent(key) {
return false;
}
// ---------------------------------------------------------------------------
// Database canaries (docs/Security/Remediation/FerretDB.md).
//
// FerretDB writes nothing itself: it refuses the operation and puts a stable
// marker in the error text, and WeKan - which owns the database and the Admin
// Panel - decides what to record. That keeps the whole feature working
// identically on MongoDB, where there is no FerretDB to mark anything and the
// same operations simply never appear.
//
// The id inside `canary:<id>` is FerretDB's, and it is NOT trusted as a
// category: it is matched against a known list, and anything else is recorded
// generically. An error string is attacker-influenced, and a marker parsed out
// of one must never be able to choose which security category it lands in.
const FERRETDB_CANARY_MARKER = 'canary:';
const KNOWN_DB_CANARIES = {
'db.javascript': 'tried to run server-side JavaScript',
'db.result-to-collection': 'tried to write a query result into a collection',
'db.drop-database': 'tried to drop a database',
'db.server-admin': 'tried to run a server administration command',
// The database's own last-look SQL guard refused a statement that carried
// what only injection produces. WeKan builds no SQL, so this is either a bug
// in a statement builder or an attack that reached one - both worth an
// operator's attention (docs/Security/Remediation/FerretDB.md §3b).
'db.sql-injection': 'the database refused a statement that looked like injected SQL',
};
// Extract the id, the same way FerretDB's own IDOf does, so the two cannot drift.
export function databaseCanaryId(error) {
const text = (error && (error.message || error.reason)) || (typeof error === 'string' ? error : '');
if (!text) return '';
const at = text.indexOf(FERRETDB_CANARY_MARKER);
if (at < 0) return '';
let rest = text.slice(at + FERRETDB_CANARY_MARKER.length);
const end = rest.search(/[\s)]/);
if (end >= 0) rest = rest.slice(0, end);
// A marker is an identifier, never a sentence: anything else is not one of
// ours and is treated as absent.
return /^[a-z][a-z0-9.-]{0,60}$/.test(rest) ? rest : '';
}
// True when this error WAS a canary and has been recorded as one.
function recordDatabaseCanary(error, options = {}) {
const id = databaseCanaryId(error);
if (!id) return false;
try {
// Through the ordinary canary path, so a database canary is rate-limited,
// aggregated and attributed exactly like every other one - and, like every
// other one, tells the caller nothing.
const { tripCanary } = require('/server/lib/canary');
const known = Object.prototype.hasOwnProperty.call(KNOWN_DB_CANARIES, id);
// SQL injection gets its own canary id: it is a different thing from "an
// operation the client never issues", and an admin reading the report
// should not have to open the detail to tell them apart.
const canaryId = id === 'db.sql-injection' ? 'injection.sql-statement' : 'database.canary';
tripCanary(canaryId, {
detail: known ? KNOWN_DB_CANARIES[id] : `unrecognised database canary (${id.slice(0, 30)})`,
userId: options.userId,
});
} catch (e) {
if (process.env.DEBUG === 'true') {
// eslint-disable-next-line no-console
console.warn('database canary reporting failed:', e && e.message);
}
}
return true;
}
// Record one database error. Never throws: this is called from error paths, and
// an error handler that fails is worse than the error it was handling.
export function recordDatabaseProblem(error, options = {}) {
try {
// A DATABASE CANARY first: the database refuses operations WeKan never
// issues - server-side JavaScript, an aggregation writing into a collection,
// dropping a database, a server-admin command - and marks the refusal
// `canary:<id>` (.tools/FerretDB internal/util/canary). One of those arriving
// means something spoke to the database socket that was not WeKan, so it is
// a SECURITY event with an actor, not a database problem to be triaged. It
// goes to the security stream and does not fall through to this one, which
// would file it under "the database said something".
if (recordDatabaseCanary(error, options)) return null;
const classified = classifyDatabaseError(error, {
configured: configuredDatabase(process.env),
operation: options.operation || '',
+371
View File
@@ -0,0 +1,371 @@
// ============================================================================
// Filesystem storage integrity — the runtime half
// (design: docs/Security/Remediation/WeKan.md §13; pure half:
// models/lib/fileIntegrity.js)
// ----------------------------------------------------------------------------
// Once a day, when the machine is not busy, walk WeKan's storage directories and
// check that every file is still the file WeKan stored: same name, same size,
// same modification time, same md5, sha256 and sha512 - and that the RECORD of
// all that still matches its ed25519 signature.
//
// Anything that does not match is recorded in Admin Panel / Problems /
// Integrity, and the finding an admin is actually being warned about is a change
// WITH NO RECORD SAYING WHY: files change when people use WeKan, and those
// changes are accounted for. A file that changed while WeKan was not looking is
// the interesting one.
//
// About ed25519. Three digests detect a file being CHANGED. They do not detect
// the baseline being changed to match - anybody who can rewrite a file can
// rewrite a row of hashes. So each baseline entry is signed, and the signature is
// verified on every scan. The key:
//
// * WEKAN_INTEGRITY_PRIVATE_KEY (PKCS#8 PEM) - supplied by the operator and
// never stored by WeKan. This is the configuration that means something
// against an attacker who reaches the DATABASE as well as the disk.
// * otherwise a key generated on first run and kept in the database. That
// detects filesystem-only tampering, which is the common case (a restore, a
// sync tool, a container rebuild, a shell on the volume) - and it is honest
// about not being more than that.
//
// Everything here is best-effort and never throws into its caller: an integrity
// scan that breaks the server it is checking would be a poor trade.
// ============================================================================
import { Meteor } from 'meteor/meteor';
import EventLog from '/models/eventLog';
import FileIntegrity, { IntegrityKeys } from '/models/fileIntegrity';
const fs = Npm.require('fs');
const path = Npm.require('path');
const crypto = Npm.require('crypto');
const {
DIGESTS,
PACING,
manifestLine,
compareEntry,
classifyChange,
nextStep,
isDue,
} = require('/models/lib/fileIntegrity');
const { computeStoragePaths } = require('/models/lib/attachmentStoragePath');
// ------------------------------------------------------------------ the key
let cachedKeys = null;
async function integrityKeys() {
if (cachedKeys) return cachedKeys;
// An operator-supplied key is never written down by WeKan.
const supplied = process.env.WEKAN_INTEGRITY_PRIVATE_KEY;
if (supplied) {
try {
const privateKey = crypto.createPrivateKey(supplied);
cachedKeys = { privateKey, publicKey: crypto.createPublicKey(privateKey), source: 'env' };
return cachedKeys;
} catch (e) {
// A malformed key must not silently fall back to a generated one: that
// would look like it is working while checking nothing the operator meant.
record({
severity: 'high',
category: 'integrity',
action: 'failed',
detail: 'WEKAN_INTEGRITY_PRIVATE_KEY is set but could not be read as a private key',
});
return null;
}
}
const stored = await IntegrityKeys.findOneAsync({ _id: 'ed25519' });
if (stored && stored.privateKeyPem) {
const privateKey = crypto.createPrivateKey(stored.privateKeyPem);
cachedKeys = { privateKey, publicKey: crypto.createPublicKey(privateKey), source: 'database' };
return cachedKeys;
}
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519');
await IntegrityKeys.upsertAsync(
{ _id: 'ed25519' },
{
$set: {
privateKeyPem: privateKey.export({ type: 'pkcs8', format: 'pem' }).toString(),
publicKeyPem: publicKey.export({ type: 'spki', format: 'pem' }).toString(),
createdAt: new Date(),
},
},
);
cachedKeys = { privateKey, publicKey, source: 'database' };
return cachedKeys;
}
function sign(keys, line) {
if (!keys) return '';
return crypto.sign(null, Buffer.from(line, 'utf8'), keys.privateKey).toString('base64');
}
function verify(keys, line, signature) {
if (!keys || !signature) return false;
try {
return crypto.verify(null, Buffer.from(line, 'utf8'), keys.publicKey,
Buffer.from(signature, 'base64'));
} catch (e) {
return false;
}
}
// --------------------------------------------------------------- the digests
/**
* md5, sha256 and sha512 in ONE pass over the file. Three passes would be three
* times the disk read for the same bytes, which is the opposite of what a
* background scan should do.
*/
function digestFile(filePath) {
return new Promise(resolve => {
const hashes = {};
DIGESTS.forEach(name => { hashes[name] = crypto.createHash(name); });
const stream = fs.createReadStream(filePath);
stream.on('data', chunk => DIGESTS.forEach(name => hashes[name].update(chunk)));
stream.on('error', () => resolve(null));
stream.on('end', () => {
const out = {};
DIGESTS.forEach(name => { out[name] = hashes[name].digest('hex'); });
resolve(out);
});
});
}
// ---------------------------------------------------------------- reporting
function record(evt) {
try {
const p = EventLog.insertAsync({
stream: 'integrity',
at: new Date(),
severity: evt.severity || 'info',
category: evt.category || 'integrity',
bleed: evt.bleed || 'StorageBleed',
action: evt.action || 'detected',
source: evt.source || 'integrity-scan',
detail: String(evt.detail || '').slice(0, 500),
count: evt.count || 1,
});
if (p && typeof p.catch === 'function') p.catch(() => {});
} catch (e) {
if (process.env.DEBUG === 'true') console.warn('integrity record failed:', e && e.message);
}
}
// ----------------------------------------------------------------- the scan
const sleep = ms => new Promise(resolve => Meteor.setTimeout(resolve, ms));
/** Current CPU load, 0-100, or NaN when it cannot be read. */
function cpuPercent() {
try {
const { currentCpuPercent } = require('/server/lib/cpuMonitor');
if (typeof currentCpuPercent === 'function') return Number(currentCpuPercent());
} catch (e) { /* the monitor is optional */ }
return NaN;
}
function listFiles(root) {
const out = [];
const walk = dir => {
let entries;
try {
entries = fs.readdirSync(dir, { withFileTypes: true });
} catch (e) {
return;
}
entries.forEach(entry => {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) walk(full);
else if (entry.isFile()) out.push(full);
});
};
walk(root);
return out;
}
/**
* Did WeKan itself touch this file around when it changed? An attachment that a
* user replaced has a modified document; one that changed with nothing to show
* for it is the finding.
*/
async function hasRecentRecord(filePath, observedMtimeMs) {
try {
const Attachments = require('/models/attachments').default;
const Avatars = require('/models/avatars').default;
const WINDOW_MS = 10 * 60 * 1000;
const near = new Date(observedMtimeMs - WINDOW_MS);
for (const collection of [Attachments, Avatars]) {
const doc = await collection.collection.findOneAsync({
'versions.original.path': filePath,
});
if (!doc) continue;
const touched = doc.updatedAt || doc.meta?.updatedAt || doc.versions?.original?.updatedAt;
if (touched && new Date(touched) >= near) return true;
// A file WeKan knows about, whose document has no timestamp to compare,
// is treated as explained: the alternative is warning about every file on
// an instance that never set one.
if (!touched) return true;
}
} catch (e) { /* on any doubt, prefer reporting: an unexplained change is the point */ }
return false;
}
/** One pass. Returns a short summary for the caller and the log. */
export async function runIntegrityScan(options = {}) {
const started = Date.now();
const keys = await integrityKeys();
const paths = computeStoragePaths(process.env.WRITABLE_PATH);
const roots = [paths.attachments, paths.avatars].filter(Boolean);
let checked = 0;
let findings = 0;
let unexplained = 0;
let stoppedEarly = '';
const seen = new Set();
for (const root of roots) {
for (const filePath of listFiles(root)) {
let stat;
try {
stat = fs.statSync(filePath);
} catch (e) {
continue;
}
const step = nextStep(
{ cpuPercent: cpuPercent(), elapsedMs: Date.now() - started, fileSize: stat.size },
options.pacing || PACING,
);
if (step.action === 'stop') {
stoppedEarly = step.reason;
break;
}
await sleep(step.pauseMs);
seen.add(filePath);
checked += 1;
const digests = await digestFile(filePath);
if (!digests) continue; // unreadable right now; not a finding on its own
const observed = { path: filePath, size: stat.size, mtimeMs: stat.mtimeMs, digests };
const baseline = await FileIntegrity.findOneAsync({ _id: filePath });
if (!baseline) {
// First sight of a file: record the baseline rather than warning. A new
// instance would otherwise report every file it has.
await storeBaseline(keys, observed);
continue;
}
const signatureValid = verify(keys, manifestLine(baseline), baseline.signature);
const result = compareEntry(baseline, observed, { signatureValid });
if (result.length === 0) continue;
findings += 1;
const explained = await hasRecentRecord(filePath, stat.mtimeMs);
const verdict = classifyChange(result, explained);
if (!verdict.explained) unexplained += 1;
record({
severity: verdict.severity,
action: verdict.explained ? 'detected' : 'blocked',
// The file's NAME, never its contents, and never the digests
// themselves - an admin needs to know which file, not to read it here.
detail: `${path.basename(filePath)}: ${verdict.summary}`,
});
// Re-baseline an EXPLAINED change so it is reported once, not every day.
// An unexplained one is deliberately left, so it keeps showing until
// somebody looks at it.
if (verdict.explained) await storeBaseline(keys, observed);
}
if (stoppedEarly) break;
}
// Files that were recorded and are no longer there. Only when the scan
// completed: a run that stopped early has not looked everywhere, and
// reporting the rest as missing would be a lie.
if (!stoppedEarly) {
const gone = await FileIntegrity.find(
{ _id: { $nin: [...seen] } },
{ fields: { _id: 1 } },
).fetchAsync();
for (const doc of gone) {
const verdict = classifyChange(compareEntry({ path: doc._id }, null), false);
unexplained += 1;
record({
severity: verdict.severity,
action: 'blocked',
detail: `${path.basename(doc._id)}: ${verdict.summary}`,
});
await FileIntegrity.removeAsync({ _id: doc._id });
}
}
const summary = {
checked,
findings,
unexplained,
ms: Date.now() - started,
stoppedEarly,
keySource: keys ? keys.source : 'none',
};
record({
severity: unexplained ? 'high' : 'info',
action: 'detected',
source: 'integrity-scan-summary',
detail:
`checked ${checked} files in ${Math.round(summary.ms / 1000)}s; ` +
`${findings} changed, ${unexplained} with no record saying why` +
(stoppedEarly ? `; stopped early: ${stoppedEarly}` : ''),
});
return summary;
}
async function storeBaseline(keys, observed) {
const entry = {
path: observed.path,
size: observed.size,
mtimeMs: observed.mtimeMs,
digests: observed.digests,
};
await FileIntegrity.upsertAsync(
{ _id: observed.path },
{ $set: { ...entry, signature: sign(keys, manifestLine(entry)), at: new Date() } },
);
}
// ------------------------------------------------------------- the schedule
const CHECK_EVERY_MS = 60 * 60 * 1000; // look at the clock hourly; run daily
if (Meteor.isServer && process.env.WEKAN_INTEGRITY_SCAN !== 'false') {
Meteor.startup(() => {
Meteor.setInterval(async () => {
try {
const last = await IntegrityKeys.findOneAsync({ _id: 'lastRun' });
if (!isDue(last && last.at && new Date(last.at).getTime(), Date.now())) return;
if (Number(cpuPercent()) >= PACING.maxCpuPercent) return; // try again next hour
await IntegrityKeys.upsertAsync({ _id: 'lastRun' }, { $set: { at: new Date() } });
await runIntegrityScan();
} catch (e) {
if (process.env.DEBUG === 'true') console.warn('integrity scan failed:', e && e.message);
}
}, CHECK_EVERY_MS);
});
}
export default { runIntegrityScan };
+27
View File
@@ -19,10 +19,37 @@ function record(evt) {
} catch (e) { /* logging must never break the caller */ }
}
// Sanitizing a filename is routine - names get trimmed, unicode gets normalised,
// an extension gets corrected - and routine is not worth an admin's attention.
// What IS worth it is a name that carried an EXPLOIT: markup, a shell
// metacharacter, a path trying to leave its directory. Those reasons, and only
// those, also trip a canary, so the Security report separates "somebody uploaded
// a file with an awkward name" from "somebody is probing the upload path"
// (docs/Security/Remediation/WeKan.md §12.6).
const DANGEROUS_REASON = /^exploit: |traversal|invisible characters|URL-encoded name/i;
function canaryForReasons(reasons, fileObj) {
try {
const list = Array.isArray(reasons) ? reasons : [];
const dangerous = list.filter(r => typeof r === 'string' && DANGEROUS_REASON.test(r));
if (!dangerous.length) return;
const traversal = dangerous.some(r => /traversal/i.test(r));
const { tripCanary } = require('/server/lib/canary');
tripCanary(traversal ? 'sanitize.path-traversal' : 'sanitize.dangerous-filename', {
userId: (fileObj && (fileObj.userId || (fileObj.meta && fileObj.meta.userId))) || undefined,
// The REASONS, which are a short fixed vocabulary - never the filename,
// which is the attacker's own text.
detail: dangerous.slice(0, 4).join(', '),
});
} catch (e) { /* a canary must never break an upload */ }
}
// Record that a stored filename was sanitized. `reasons` is the array from
// uploadFileName.sanitizationReasons(); `fileObj` supplies the uploader + location.
async function logFilenameSanitized({ fileObj, source, reasons, from, to }) {
try {
canaryForReasons(reasons, fileObj);
const why = Array.isArray(reasons) && reasons.length ? reasons.join('; ') : 'normalized';
let context = '';
try { context = formatFileContext(await resolveFileContext(fileObj)); } catch (e) { /* omit */ }
+12
View File
@@ -43,6 +43,18 @@ export function record(evt = {}) {
detail: sanitizeDetail(m.detail),
};
if (m.userId || m.userid) doc.userId = String(m.userId || m.userid);
// WHO and FROM WHERE (docs/Security/Remediation/WeKan.md §12). Both are
// optional: a guard that fires on an unauthenticated request has no
// username, and one that fires outside a request context has no address.
// Truncated here as well as sanitized, because both are attacker-influenced
// strings - a username can be chosen at registration and an XFF header is
// whatever was sent.
if (m.username) doc.username = String(m.username).slice(0, 100);
if (m.ip) doc.ip = String(m.ip).slice(0, 64);
// How many attempts this row stands for; 1 unless a canary is flushing a
// window's summary.
const count = Number(m.count);
doc.count = Number.isFinite(count) && count > 0 ? Math.floor(count) : 1;
insert(doc);
} catch (e) {
if (process.env.DEBUG === 'true') console.warn('securityLog.record failed:', e && e.message);
@@ -1,4 +1,9 @@
/* eslint-env mocha */
// MimeBleed - https://wekan.fi/hall-of-fame/mimebleed/
// Named for tests/securityRegressionCoverage.test.cjs, which checks the published
// Hall of Fame list against the tests that guard it - a test that does not say
// which vulnerability it belongs to cannot be checked against that list.
import { expect } from 'chai';
import { looksLikeDangerousMarkup } from '/models/fileValidation';
+182
View File
@@ -0,0 +1,182 @@
// ============================================================================
// Unexpected outcomes: crashes, downtime, and errors nobody caught
// (design: docs/Security/Remediation/WeKan.md §13.4)
// ----------------------------------------------------------------------------
// Admin Panel / Problems answers "what did WeKan refuse" and "what did the
// database say". It did not answer the plainest question of all: did this server
// stop, and did it stop cleanly?
//
// A crash leaves nothing behind. The process is gone, so it cannot write a
// message about being gone - which is why a heartbeat is the only thing that
// works: WeKan writes the time every minute, and the NEXT start reads it. A gap
// between the last heartbeat and the new start is downtime, and a last heartbeat
// with no clean-shutdown mark beside it is a crash rather than a restart.
//
// Two more things land here, because they are the same question one level down:
// an uncaught exception and an unhandled promise rejection. Meteor turns the
// second into a process exit in this app, so it is a crash with a cause
// attached - and the cause is worth recording before the process goes.
//
// Best-effort throughout, and never in the way: everything is wrapped, and the
// heartbeat is one small upsert a minute.
// ============================================================================
import { Meteor } from 'meteor/meteor';
import EventLog from '/models/eventLog';
import { IntegrityKeys } from '/models/fileIntegrity';
const { sanitizeDetail } = require('/models/lib/securityLogFormat');
const HEARTBEAT_MS = 60 * 1000;
// A gap longer than two heartbeats is a real gap rather than a slow write.
const DOWNTIME_THRESHOLD_MS = 3 * HEARTBEAT_MS;
function record(evt) {
try {
const p = EventLog.insertAsync({
stream: 'integrity',
at: new Date(),
severity: evt.severity || 'medium',
category: evt.category || 'availability',
bleed: evt.bleed || 'DowntimeBleed',
action: evt.action || 'detected',
source: evt.source || 'uptime',
detail: sanitizeDetail(evt.detail),
count: 1,
});
if (p && typeof p.catch === 'function') p.catch(() => {});
} catch (e) {
if (process.env.DEBUG === 'true') console.warn('uptime record failed:', e && e.message);
}
}
/**
* Read the previous run's last heartbeat and say what it means. Pure enough to
* be worth keeping separate: given two timestamps and a flag, this is the whole
* decision, and tests/fileIntegrity.test.cjs drives it directly.
*
* @param {object} previous the stored heartbeat document, or null on a first run
* @param {number} nowMs
* @return {{kind: 'first-run'|'clean'|'restart'|'crash', downMs: number, detail: string}}
*/
export function classifyPreviousRun(previous, nowMs) {
if (!previous || !previous.at) {
return { kind: 'first-run', downMs: 0, detail: 'no previous run recorded' };
}
const last = new Date(previous.at).getTime();
const downMs = Math.max(0, Number(nowMs) - last);
const minutes = Math.round(downMs / 60000);
if (previous.cleanShutdown) {
return {
kind: 'clean',
downMs,
detail: `stopped cleanly; down for about ${minutes} minute(s)`,
};
}
if (downMs < DOWNTIME_THRESHOLD_MS) {
// Short, and no clean-shutdown mark: a restart quick enough that the
// shutdown hook may simply not have run. Worth a note, not an alarm.
return {
kind: 'restart',
downMs,
detail: `restarted without a clean-shutdown mark; down for about ${minutes} minute(s)`,
};
}
return {
kind: 'crash',
downMs,
detail:
`the previous run STOPPED WITHOUT SHUTTING DOWN CLEANLY, and this server ` +
`was down for about ${minutes} minute(s)`,
};
}
const SEVERITY_OF = {
'first-run': 'info',
clean: 'info',
restart: 'low',
crash: 'high',
};
if (Meteor.isServer) {
Meteor.startup(async () => {
try {
const previous = await IntegrityKeys.findOneAsync({ _id: 'heartbeat' });
const verdict = classifyPreviousRun(previous, Date.now());
// A clean stop and a first run are not problems; recording them would put
// a row in Problems on every deliberate restart, and a page that cries
// wolf is a page nobody opens.
if (verdict.kind === 'crash' || verdict.kind === 'restart') {
record({
severity: SEVERITY_OF[verdict.kind],
category: 'availability',
action: verdict.kind === 'crash' ? 'failed' : 'detected',
source: 'uptime',
detail: verdict.detail,
});
}
await IntegrityKeys.upsertAsync(
{ _id: 'heartbeat' },
{ $set: { at: new Date(), cleanShutdown: false, pid: process.pid } },
);
} catch (e) {
if (process.env.DEBUG === 'true') console.warn('uptime startup check failed:', e && e.message);
}
Meteor.setInterval(() => {
try {
const p = IntegrityKeys.upsertAsync(
{ _id: 'heartbeat' },
{ $set: { at: new Date(), cleanShutdown: false, pid: process.pid } },
);
if (p && typeof p.catch === 'function') p.catch(() => {});
} catch (e) { /* a heartbeat that fails must not take the timer down */ }
}, HEARTBEAT_MS);
// A clean stop marks itself, which is the only way the next start can tell
// the difference between "stopped" and "died".
const markClean = () => {
try {
IntegrityKeys.update(
{ _id: 'heartbeat' },
{ $set: { at: new Date(), cleanShutdown: true } },
);
} catch (e) { /* nothing useful to do while exiting */ }
};
process.once('SIGTERM', markClean);
process.once('SIGINT', markClean);
// The cause, recorded before the process goes. Listeners are ADDED, never
// replaced: removing whatever else is listening would change how the app
// handles its own failures.
process.on('uncaughtException', error => {
record({
severity: 'critical',
category: 'crash',
bleed: 'CrashBleed',
action: 'failed',
source: 'uncaughtException',
detail: (error && error.message) || String(error),
});
});
process.on('unhandledRejection', reason => {
record({
severity: 'critical',
category: 'crash',
bleed: 'CrashBleed',
action: 'failed',
source: 'unhandledRejection',
detail: (reason && reason.message) || String(reason),
});
});
});
}
export default { classifyPreviousRun };
+88
View File
@@ -0,0 +1,88 @@
import { Meteor } from 'meteor/meteor';
import { ReactiveCache } from '/imports/reactiveCache';
const { boardVisibilitySelectors } = require('/models/lib/boardVisibilitySelectors');
// "Which of these boards may this user see?" — asked with the SAME selectors as
// the All Boards list and the `board` publication, so a board is visible here
// exactly when it is visible there: public, an active membership, or an ACTIVE
// org/team/domain share.
//
// GHSA-jvv9-498p-hxrg: a card's `parentId` could point at a card on ANOTHER
// board, and nothing checked that the person setting it, or the people
// subscribed to the child board, were allowed to see that other board. The
// board publication walks the whole ancestor chain to render a subtask's path
// (#3453) and published the full ancestor card documents to every subscriber of
// the child board — so writing one id into one field bridged a private board's
// cards into another board's DDP feed. Both ends of that now ask this question:
// the write refuses an invisible parent, and the publication refuses to send
// ancestors from a board the subscriber cannot see.
/**
* @param {string} userId
* @param {string[]} boardIds
* @return {Promise<Set<string>>} the subset of boardIds the user may see
*/
export async function visibleBoardIds(userId, boardIds) {
const ids = (Array.isArray(boardIds) ? boardIds : [])
.filter(id => typeof id === 'string' && id);
if (ids.length === 0) {
return new Set();
}
const user = userId ? await ReactiveCache.getUser(userId) : null;
const $or = boardVisibilitySelectors({
userId: user ? userId : null,
orgIds: user && typeof user.orgIds === 'function' ? user.orgIds() : [],
teamIds: user && typeof user.teamIds === 'function' ? user.teamIds() : [],
emailDomains:
user && typeof user.emailDomains === 'function' ? user.emailDomains() : [],
});
// An anonymous caller still sees public boards, so $or is never empty.
const boards = await ReactiveCache.getBoards(
{ _id: { $in: ids }, $or },
{ fields: { _id: 1 } },
);
return new Set((boards || []).map(board => board._id));
}
/**
* @param {string} userId
* @param {string} boardId
* @return {Promise<boolean>}
*/
export async function canUserSeeBoard(userId, boardId) {
if (!boardId) return false;
const visible = await visibleBoardIds(userId, [boardId]);
return visible.has(boardId);
}
/**
* Refuse a `parentId` that names a card on a board this user cannot see.
* Throws so both the DDP mutation and the REST handler stop before writing;
* the REST layer maps `Forbidden`/`NotFound` to 403/404
* (server/lib/apiResponseHelpers.js httpStatusForError).
*
* @param {string} userId the actor
* @param {string} parentId the proposed parent card id
*/
export async function assertParentCardIsVisible(userId, parentId) {
if (!parentId) return;
const parent = await ReactiveCache.getCard(parentId);
if (!parent) {
const error = new Meteor.Error('NotFound', 'Parent card not found');
error.statusCode = 404;
throw error;
}
if (!(await canUserSeeBoard(userId, parent.boardId))) {
const error = new Meteor.Error(
'Forbidden',
'You are not allowed to use a card from that board as a parent.',
);
error.statusCode = 403;
throw error;
}
}
+34 -1
View File
@@ -9,8 +9,9 @@ import {
} from '/server/lib/apiResponseHelpers';
import { ReactiveCache } from '/imports/reactiveCache';
import Activities from '/models/activities';
import CardComments from '/models/cardComments';
import CardComments, { assertCanMutateComment } from '/models/cardComments';
import { ensureIndex } from '/server/lib/mongoStartup';
import { tripCanary } from '/server/lib/canary';
async function commentCreation(userId, doc) {
const card = await ReactiveCache.getCard(doc.cardId);
@@ -194,6 +195,38 @@ WebApp.handlers.delete(
const paramCommentId = req.params.commentId;
const paramCardId = req.params.cardId;
await Authentication.checkBoardAccess(req.userId, paramBoardId);
// GHSA-pqr4-rxgp-hv2m: board membership is not permission to delete
// ANOTHER member's comment. This used to be the only check here, and the
// object-level rule (author, or a board admin unless the board sets
// restrictCommentEditing) lived in a collection hook keyed off the Meteor
// userId — which an HTTP request does not carry, so the hook took its
// "server-internal, no user" path and allowed every deletion. Load the
// comment and apply the same rule DDP applies, before removing anything.
const comment = await ReactiveCache.getCardComment({
_id: paramCommentId,
cardId: paramCardId,
boardId: paramBoardId,
});
if (!comment) {
sendJsonResult(res, { code: 404, data: { error: 'Comment not found' } });
return;
}
// A canary with the REQUEST in hand, so the event carries the real client
// address and not just the account (docs/Security/Remediation/WeKan.md §12).
// Deleting somebody else's comment is refused exactly as before - the
// canary only records the attempt, and the 403 below is unchanged.
if (comment.userId && comment.userId !== req.userId) {
try {
await assertCanMutateComment(req.userId, comment);
} catch (refusal) {
tripCanary('comment.foreign-delete', { req, userId: req.userId });
throw refusal;
}
} else {
await assertCanMutateComment(req.userId, comment);
}
await CardComments.removeAsync({
_id: paramCommentId,
cardId: paramCardId,
+39 -4
View File
@@ -13,6 +13,7 @@ const { applyCardBoardConsistency } = require('/server/lib/cardBoardConsistency'
import { titleChanged } from '/server/lib/titleChangeActivity';
import { descriptionChanged } from '/server/lib/descriptionChangeActivity';
import { buildDeleteCardActivity } from '/server/lib/deleteActivities';
import { assertParentCardIsVisible } from '/server/lib/visibleBoardIds';
import Activities from '/models/activities';
import Boards from '/models/boards';
import Cards, {
@@ -911,6 +912,12 @@ WebApp.handlers.post('/api/boards/:boardId/lists/:listId/cards', async function(
return;
}
// GHSA-jvv9-498p-hxrg: the same read check the linked-card branch above makes
// for `linkedId`, for the parent card. A parent on another board is only
// allowed when the caller may see that board; otherwise creating one card
// would publish that board's ancestor cards to everyone subscribed here.
await assertParentCardIsVisible(req.userId, paramParentId);
const nextCardNumber = await board.getNextCardNumber();
const customFields = await ReactiveCache.getCustomFields({ boardIds: paramBoardId });
@@ -1125,6 +1132,12 @@ WebApp.handlers.put(
updated = true;
}
if (req.body.parentId) {
// GHSA-jvv9-498p-hxrg: a parent card may live on another board, and write
// access HERE says nothing about read access THERE. Without this check a
// board member could name a card on a private board as the parent and the
// board publication would then hand that private card to everyone
// subscribed to this board.
await assertParentCardIsVisible(req.userId, req.body.parentId);
await Cards.direct.updateAsync(
{ _id: paramCardId, listId: paramListId, boardId: paramBoardId, archived: false },
{ $set: { parentId: req.body.parentId } },
@@ -1599,23 +1612,45 @@ WebApp.handlers.post('/api/boards/:boardId/cards/labels', async function(req, re
return;
}
// ONE read for every card, not one per card.
//
// This used to await a getCard per id and then an update per id - at the
// BULK_CARDS_MAX of 500 that is a thousand round-trips, issued one at a time,
// for a single request. The reads are all the same question, so they are one
// `$in` query; the writes genuinely differ (each card merges its own labels),
// so they stay individual, but they are issued TOGETHER instead of each
// waiting for the last.
const cards = await ReactiveCache.getCards(
{ _id: { $in: cardIds }, boardId: paramBoardId, archived: false },
{ fields: { _id: 1, labelIds: 1 } },
);
const cardById = new Map((cards || []).map(card => [card._id, card]));
const updated = [];
const notFound = [];
const writes = [];
// Iterate cardIds, not the query result: the response has to preserve the
// caller's order and report the ids that matched nothing.
for (const cardId of cardIds) {
const card = await ReactiveCache.getCard({ _id: cardId, boardId: paramBoardId, archived: false });
const card = cardById.get(cardId);
if (!card) {
notFound.push(cardId);
continue;
}
// Merge: keep existing minus removed, then add new ones, de-duplicated.
const merged = mergeLabelIds(card.labelIds, addLabelIds, removeLabelIds);
await Cards.direct.updateAsync(
{ _id: cardId, boardId: paramBoardId, archived: false },
{ $set: { labelIds: merged } },
writes.push(
Cards.direct.updateAsync(
{ _id: cardId, boardId: paramBoardId, archived: false },
{ $set: { labelIds: merged } },
),
);
updated.push({ _id: cardId, labelIds: merged });
}
// Every write must land before the 200 says they did.
await Promise.all(writes);
sendJsonResult(res, { code: 200, data: { updated, notFound } });
});
+14 -29
View File
@@ -2,23 +2,15 @@ import Attachments from '/models/attachments';
import Boards from '/models/boards';
import AttachmentStorageSettings from '/models/attachmentStorageSettings';
import { allowIsBoardMemberWithWriteAccess } from '/server/lib/utils';
function hasUnsafeClientVersionFields(fileObj) {
const versions = fileObj?.versions;
if (!versions || typeof versions !== 'object') {
return false;
}
return Object.values(versions).some((version) => {
if (!version || typeof version !== 'object') {
return false;
}
// Path and storage are internal server-managed metadata.
return Object.prototype.hasOwnProperty.call(version, 'path') ||
Object.prototype.hasOwnProperty.call(version, 'storage');
});
}
// GHSA-4mxf-m8pq-xc9p: these guards used to be written out here and NOT in
// server/permissions/avatars.js, which is how avatars ended up without them.
// They live in one place now, and both collections import that one.
import {
hasUnsafeClientVersionFields,
touchesVersionFields,
onlyTouchesAllowedFields,
} from '/models/lib/fileVersionFields';
import { tripCanary } from '/server/lib/canary';
Attachments.allow({
async insert(userId, fileObj) {
@@ -27,7 +19,7 @@ Attachments.allow({
if (process.env.DEBUG === 'true') {
console.warn('Blocked attachment insert with client-supplied versions.path/storage');
}
return false;
return tripCanary('attachment.version-path', { userId });
}
// Admin-level hard stop for all non-API attachment uploads.
@@ -51,27 +43,20 @@ Attachments.allow({
// path traversal attacks via storage migration exploits.
// Block direct updates to server-managed version metadata.
const touchesVersions = fields.some(field => field === 'versions' || field.startsWith('versions.'));
if (touchesVersions) {
if (touchesVersionFields(fields)) {
if (process.env.DEBUG === 'true') {
console.warn('Blocked attempt to update attachment versions metadata:', fields);
}
return false;
return tripCanary('attachment.version-path', { userId });
}
// Allow normal updates for file upload/management
const allowedFields = ['name', 'size', 'type', 'extension', 'extensionWithDot', 'meta'];
const isAllowedField = fields.every(field => {
// Allow field itself or nested properties like 'versions.original'
const baseField = field.split('.')[0];
return allowedFields.includes(baseField);
});
if (!isAllowedField) {
if (!onlyTouchesAllowedFields(fields, allowedFields)) {
if (process.env.DEBUG === 'true') {
console.warn('Blocked attempt to update restricted attachment fields:', fields);
}
return false;
return tripCanary('attachment.restricted-field', { userId });
}
// ReadOnly users cannot update attachments
+48 -1
View File
@@ -1,5 +1,16 @@
import Avatars from '/models/avatars';
import AttachmentStorageSettings from '/models/attachmentStorageSettings';
import {
hasUnsafeClientVersionFields,
touchesVersionFields,
onlyTouchesAllowedFields,
} from '/models/lib/fileVersionFields';
import { tripCanary } from '/server/lib/canary';
// What an avatar's owner may change from the client. `versions` is NOT here,
// and is refused separately below so the reason can be logged: it holds the
// server-managed on-disk path.
const ALLOWED_UPDATE_FIELDS = ['name', 'size', 'type', 'extension', 'extensionWithDot', 'meta'];
function isOwner(userId, doc) {
return userId && userId === doc.userId;
@@ -23,12 +34,48 @@ async function avatarUploadsBlocked() {
Avatars.allow({
async insert(userId, doc) {
// GHSA-4mxf-m8pq-xc9p: the same guard attachments have. `versions.*.path`
// says where on disk the bytes are; a client that can choose it can make
// the board exporter read any file the WeKan process can read.
if (hasUnsafeClientVersionFields(doc)) {
if (process.env.DEBUG === 'true') {
console.warn('Blocked avatar insert with client-supplied versions.path/storage');
}
// A canary: no WeKan client sends this field, so an insert carrying it is
// somebody trying PathBleed (docs/Security/Remediation/WeKan.md §12).
return tripCanary('avatar.version-path', { userId });
}
if (await avatarUploadsBlocked()) {
return false;
}
return isOwner(userId, doc);
},
update: isOwner,
// GHSA-4mxf-m8pq-xc9p: this used to be `update: isOwner` - the owner could
// set ANY field, `versions.original.path` included. Being the owner is still
// necessary, but no longer sufficient: the storage metadata is server-managed
// and a client never writes it.
update(userId, doc, fields) {
if (!isOwner(userId, doc)) {
return tripCanary('avatar.not-owner', { userId });
}
if (touchesVersionFields(fields)) {
if (process.env.DEBUG === 'true') {
console.warn('Blocked attempt to update avatar versions metadata:', fields);
}
return tripCanary('avatar.version-path', { userId });
}
if (!onlyTouchesAllowedFields(fields, ALLOWED_UPDATE_FIELDS)) {
if (process.env.DEBUG === 'true') {
console.warn('Blocked attempt to update restricted avatar fields:', fields);
}
return tripCanary('avatar.restricted-field', { userId });
}
return true;
},
remove: isOwner,
fetch: ['userId'],
});
@@ -1,6 +1,8 @@
import CardCommentReactions from '/models/cardCommentReactions';
import Boards from '/models/boards';
import { allowIsBoardMemberCommentOnly } from '/server/lib/utils';
const { denyForeignReactionChange } = require('/models/lib/reactionOwnership');
import { tripCanaryDeny } from '/server/lib/canary';
// Reacting to a comment is a form of commenting, so it follows the same rule as
// CardComments.insert: members who may comment (Normal / Comment-only) are
@@ -19,3 +21,25 @@ CardCommentReactions.allow({
},
fetch: ['boardId'],
});
// Being allowed to react is not being allowed to react AS SOMEBODY ELSE.
//
// The rule above is board membership and nothing more, and the whole reaction
// list is one field - so a member could `$set` `reactions` to anything: add a
// colleague's userId to a reaction they never made, or remove one they did.
// Same shape as GHSA-pqr4-rxgp-hv2m (REST comment delete): a per-user object
// gated only by "are you on this board". It is integrity rather than
// confidentiality - reactions are visible to the whole board already - but it
// puts words in another person's mouth.
//
// `toggleReaction()` only ever touches Meteor.userId(), so no legitimate client
// sends anything else. Refuse an update that changes any OTHER user's presence
// in any reaction, and refuse the modifier forms ($push/$pull/dotted paths) that
// cannot be checked this way and that no client uses.
CardCommentReactions.deny({
update(userId, doc, fieldNames, modifier) {
if (!denyForeignReactionChange(userId, doc, modifier)) return false;
return tripCanaryDeny('reaction.foreign', { userId });
},
fetch: ['reactions'],
});
+52 -3
View File
@@ -1,6 +1,29 @@
import Cards from '/models/cards';
import Boards from '/models/boards';
import { allowIsBoardMemberWithWriteAccess, denyCrossBoardMove } from '/server/lib/utils';
import { canUserSeeBoard } from '/server/lib/visibleBoardIds';
import { tripCanary, tripCanaryDeny } from '/server/lib/canary';
// GHSA-jvv9-498p-hxrg: may this user name that card as a parent? Only if they
// may see the board it is on — the same question the `board` publication asks
// before sending an ancestor card.
export async function canUserSeeParentCard(userId, parentId) {
if (!parentId) return true;
const parent = await Cards.findOneAsync(parentId, { fields: { boardId: 1 } });
// A parent that does not exist is not something to point at either.
if (!parent) return false;
return await canUserSeeBoard(userId, parent.boardId);
}
// The deny-rule form: true means "refuse this write".
export async function denyInvisibleParentCard(userId, modifier) {
const set = modifier && modifier.$set;
if (!set) return false;
const parentId = set.parentId;
// Not setting a parent, or clearing it — nothing to check.
if (typeof parentId !== 'string' || !parentId) return false;
return !(await canUserSeeParentCard(userId, parentId));
}
// Centralized update policy for Cards
// Security: deny any direct client updates to 'vote' fields; require write access otherwise
@@ -8,12 +31,14 @@ export const canUpdateCard = async function(userId, doc, fields) {
if (!userId) return false;
const fieldNames = fields || [];
// Block direct updates to voting fields; voting must go through Meteor method 'cards.vote'
// A canary: the UI never writes these directly, so reaching here is somebody
// trying the field instead of the method (docs/Security/Remediation/WeKan.md §12).
if (fieldNames.some(f => typeof f === 'string' && (f === 'vote' || f.indexOf('vote.') === 0))) {
return false;
return tripCanary('card.vote-field', { userId });
}
// Block direct updates to poker fields; poker must go through Meteor methods
if (fieldNames.some(f => typeof f === 'string' && (f === 'poker' || f.indexOf('poker.') === 0))) {
return false;
return tripCanary('card.poker-field', { userId });
}
// ReadOnly users cannot edit cards
return allowIsBoardMemberWithWriteAccess(userId, await Boards.findOneAsync(doc.boardId));
@@ -38,9 +63,33 @@ Cards.allow({
// on the card's SOURCE board, so a client could move a card into a private board
// it is not a member of by setting a new boardId. Deny any cross-board move where
// the caller lacks write access to the destination board.
//
// Security (GHSA-jvv9-498p-hxrg): the same shape of hole one field over.
// `parentId` may name a card on ANOTHER board, and write access here says
// nothing about read access there — so pointing a card at a card on a private
// board made the board publication walk that private board's ancestor chain and
// send its full card documents to everyone subscribed to THIS board. Deny a
// parent whose board the caller cannot see. (The publication no longer sends
// those ancestors either; this stops the bridge being built in the first place.)
Cards.deny({
async update(userId, doc, fieldNames, modifier) {
return await denyCrossBoardMove(userId, modifier);
if (await denyCrossBoardMove(userId, modifier)) {
return tripCanaryDeny('card.cross-board-move', { userId });
}
if (await denyInvisibleParentCard(userId, modifier)) {
return tripCanaryDeny('card.invisible-parent', { userId });
}
return false;
},
fetch: [],
});
// Same rule on INSERT: a card can be created with a parentId already set.
Cards.deny({
async insert(userId, doc) {
if (!doc || !doc.parentId) return false;
if (await canUserSeeParentCard(userId, doc.parentId)) return false;
return tripCanaryDeny('card.invisible-parent', { userId });
},
fetch: [],
});
+3 -1
View File
@@ -1,6 +1,7 @@
import Cards from '/models/cards';
import ChecklistItems from '/models/checklistItems';
import { allowIsBoardMemberWithWriteAccessByCard, denyCrossBoardMoveByChecklistItem } from '/server/lib/utils';
import { tripCanaryDeny } from '/server/lib/canary';
ChecklistItems.allow({
async insert(userId, doc) {
@@ -25,7 +26,8 @@ ChecklistItems.allow({
// move whose destination board the caller cannot write to.
ChecklistItems.deny({
async update(userId, doc, fieldNames, modifier) {
return await denyCrossBoardMoveByChecklistItem(userId, modifier);
if (!(await denyCrossBoardMoveByChecklistItem(userId, modifier))) return false;
return tripCanaryDeny('checklist-item.cross-board-move', { userId });
},
fetch: [],
});
+3 -1
View File
@@ -1,6 +1,7 @@
import Cards from '/models/cards';
import Checklists from '/models/checklists';
import { allowIsBoardMemberWithWriteAccessByCard, denyCrossBoardMoveByCard } from '/server/lib/utils';
import { tripCanaryDeny } from '/server/lib/canary';
Checklists.allow({
async insert(userId, doc) {
@@ -25,7 +26,8 @@ Checklists.allow({
// card). Deny any move whose destination board the caller cannot write to.
Checklists.deny({
async update(userId, doc, fieldNames, modifier) {
return await denyCrossBoardMoveByCard(userId, modifier);
if (!(await denyCrossBoardMoveByCard(userId, modifier))) return false;
return tripCanaryDeny('checklist.cross-board-move', { userId });
},
fetch: [],
});
+3 -1
View File
@@ -1,6 +1,7 @@
import Boards from '/models/boards';
import Lists from '/models/lists';
import { allowIsBoardMemberWithWriteAccess, denyCrossBoardMove } from '/server/lib/utils';
import { tripCanaryDeny } from '/server/lib/canary';
Lists.allow({
async insert(userId, doc) {
@@ -24,7 +25,8 @@ Lists.allow({
// the caller lacks write access to the destination board.
Lists.deny({
async update(userId, doc, fieldNames, modifier) {
return await denyCrossBoardMove(userId, modifier);
if (!(await denyCrossBoardMove(userId, modifier))) return false;
return tripCanaryDeny('list.cross-board-move', { userId });
},
fetch: [],
});
+3 -1
View File
@@ -1,6 +1,7 @@
import Boards from '/models/boards';
import Swimlanes from '/models/swimlanes';
import { allowIsBoardMemberWithWriteAccess, denyCrossBoardMove } from '/server/lib/utils';
import { tripCanaryDeny } from '/server/lib/canary';
Swimlanes.allow({
async insert(userId, doc) {
@@ -24,7 +25,8 @@ Swimlanes.allow({
// where the caller lacks write access to the destination board.
Swimlanes.deny({
async update(userId, doc, fieldNames, modifier) {
return await denyCrossBoardMove(userId, modifier);
if (!(await denyCrossBoardMove(userId, modifier))) return false;
return tripCanaryDeny('swimlane.cross-board-move', { userId });
},
fetch: [],
});
+141 -110
View File
@@ -15,6 +15,7 @@ import Boards from '/models/boards';
import Cards from '/models/cards';
import { localizeBoardMemberAvatars } from '/server/lib/localizeAvatar';
import { collectAncestorIds } from '/server/lib/subtaskAncestors';
import { visibleBoardIds } from '/server/lib/visibleBoardIds';
import {
showsCardCounterList,
countCardsByListId,
@@ -25,6 +26,7 @@ const {
DEFAULT_LAZY_THRESHOLD,
} = require('/models/lib/cardsLoading');
const { boardCardScope } = require('/models/lib/boardCardScope');
const { boardVisibilitySelectors } = require('/models/lib/boardVisibilitySelectors');
// Card-loading mode (Admin Panel / Features): 'all' ships every card/checklist to
// minimongo; 'lazy' ships none (each list loads its visible window via the
@@ -63,14 +65,16 @@ publishComposite('boards', function() {
// the All Boards / Templates view can list them; the client filters by
// type per sub-view.
type: { $in: ['board', 'template-container'] },
$or: [
{ permission: 'public' },
{ members: { $elemMatch: { userId, isActive: true } } },
{ orgs: { $elemMatch: { orgId: { $in: user.orgIds() }, isActive: true } } },
{ teams: { $elemMatch: { teamId: { $in: user.teamIds() }, isActive: true } } },
// #5850: domain-based board sharing.
{ domains: { $elemMatch: { domain: { $in: user.emailDomains() }, isActive: true } } },
],
// GHSA-gwc4-fw7p-gw58: one builder answers "which boards may this user
// see", everywhere. This used to be a hand-written copy of the same
// array - and the `board` publication's copy was the one that forgot
// isActive and served revoked shares.
$or: boardVisibilitySelectors({
userId,
orgIds: user.orgIds(),
teamIds: user.teamIds(),
emailDomains: user.emailDomains(),
}),
};
return await ReactiveCache.getBoards(
selector,
@@ -355,13 +359,12 @@ Meteor.methods({
const selector = {
archived: false,
type: { $in: ['board', 'template-container'] },
$or: [
{ permission: 'public' },
{ members: { $elemMatch: { userId, isActive: true } } },
{ orgs: { $elemMatch: { orgId: { $in: user.orgIds() }, isActive: true } } },
{ teams: { $elemMatch: { teamId: { $in: user.teamIds() }, isActive: true } } },
{ domains: { $elemMatch: { domain: { $in: user.emailDomains() }, isActive: true } } },
],
$or: boardVisibilitySelectors({
userId,
orgIds: user.orgIds(),
teamIds: user.teamIds(),
emailDomains: user.emailDomains(),
}),
};
if (search) {
selector.title = new RegExp(
@@ -444,13 +447,12 @@ Meteor.methods({
const selector = {
archived: false,
type: 'board',
$or: [
{ permission: 'public' },
{ members: { $elemMatch: { userId, isActive: true } } },
{ orgs: { $elemMatch: { orgId: { $in: user.orgIds() }, isActive: true } } },
{ teams: { $elemMatch: { teamId: { $in: user.teamIds() }, isActive: true } } },
{ domains: { $elemMatch: { domain: { $in: user.emailDomains() }, isActive: true } } },
],
$or: boardVisibilitySelectors({
userId,
orgIds: user.orgIds(),
teamIds: user.teamIds(),
emailDomains: user.emailDomains(),
}),
};
let boards = await ReactiveCache.getBoards(
@@ -634,7 +636,6 @@ publishComposite('board', async function(boardId, isArchived) {
localizeBoardMemberAvatars(boardId).catch(() => {});
const thisUserId = this.userId;
const $or = [{ permission: 'public' }];
let currUser = (!Match.test(thisUserId, String) || !thisUserId) ? 'undefined' : await ReactiveCache.getUser(thisUserId);
let orgIdsUserBelongs = currUser !== 'undefined' && currUser.teams !== 'undefined' ? currUser.orgIdsUserBelongs() : '';
@@ -653,12 +654,82 @@ publishComposite('board', async function(boardId, isArchived) {
teamsIds = teamIdsUserBelongs.split(',');
}
if (thisUserId) {
$or.push({ members: { $elemMatch: { userId: thisUserId, isActive: true } } });
$or.push({ 'orgs.orgId': { $in: orgsIds } });
$or.push({ 'teams.teamId': { $in: teamsIds } });
$or.push({ 'domains.domain': { $in: emailDomains } });
}
// GHSA-gwc4-fw7p-gw58: this used to match org/team/domain shares with a
// dotted `'orgs.orgId': { $in: [...] }`, which says nothing about isActive -
// the flag a board admin flips to REVOKE a share. A revoked user disappeared
// from All Boards (that list uses the $elemMatch form) yet could still
// subscribe here with a boardId they remembered and receive the whole private
// board. Both now come from the one builder, which requires isActive: true.
const $or = boardVisibilitySelectors({
userId: thisUserId,
orgIds: orgsIds,
teamIds: teamsIds,
emailDomains,
});
// The linked cards of this board, MINUS the ones whose board this subscriber
// may not see.
//
// GHSA-jvv9-498p-hxrg was reported against the ancestor (parentId) cursor, and
// the linked-card cursors below had the identical hole - worse, in fact, since
// they publish the linked card's comments, attachments, checklists and
// checklist items as well. A `cardType-linkedCard` names a card by id, that
// card may live on any board, and creating one requires read access to that
// board (server/models/cards.js) - but the person who created it is not the
// person this publication is sending to. Every subscriber of THIS board was
// getting the linked card's full document and all of its children, whether or
// not they may see the board it lives on.
//
// A linked card whose source board the subscriber cannot see is simply not
// sent, exactly as an invisible ancestor is not sent. Cards on THIS board are
// their own answer - they are already going to this subscriber.
//
// The five cursors used to repeat this preamble verbatim; sharing it is also
// what stops the next one from being written without the check.
const visibleLinkedCardIds = async board => {
// NOT memoized. An earlier version cached the result per board to spare the
// five cursors their duplicate queries - but publishComposite re-runs a
// child's find() when the parent document changes, and a cache that lives
// for the whole subscription would then serve the ids computed the FIRST
// time forever: a linked card added later would never be published, and one
// removed would go on being published. The five cursors each ran these same
// queries before this helper existed, so computing per call is exactly the
// cost they always had, and it is correct.
const compute = (async () => {
const cardSelector = {
...boardCardScope(board),
archived: isArchived,
};
if (thisUserId && board.members) {
const member = findWhere(board.members, { userId: thisUserId, isActive: true });
if (member && (member.isNormalAssignedOnly || member.isCommentAssignedOnly || member.isReadAssignedOnly)) {
cardSelector.assignees = { $in: [thisUserId] };
}
}
const cards = await ReactiveCache.getCards(cardSelector, { fields: { _id: 1, type: 1, linkedId: 1 } }, false);
if (!cards || cards.length === 0) return [];
const linkedCardIds = cards
.filter(c => c.type === 'cardType-linkedCard' && c.linkedId)
.map(c => c.linkedId);
if (linkedCardIds.length === 0) return [];
const linked = await ReactiveCache.getCards(
{ _id: { $in: linkedCardIds } },
{ fields: { _id: 1, boardId: 1 } },
false,
);
const linkedBoardIds = [...new Set((linked || []).map(c => c.boardId).filter(Boolean))];
const allowedBoardIds = await visibleBoardIds(thisUserId, linkedBoardIds);
allowedBoardIds.add(board._id);
return (linked || []).filter(c => allowedBoardIds.has(c.boardId)).map(c => c._id);
})();
return compute;
};
// Per-board adaptive card-loading decision. In 'auto' mode we count this board's
// (non-archived) cards ONCE and decide lazy vs eager from the threshold; the
@@ -907,29 +978,41 @@ publishComposite('board', async function(boardId, isArchived) {
false,
),
);
if (ancestorIds.length === 0) return null;
return await ReactiveCache.getCards({ _id: { $in: ancestorIds } }, {}, true);
// GHSA-jvv9-498p-hxrg: an ancestor may live on ANOTHER board, and
// being able to write on THIS board says nothing about being allowed
// to read that one. Anyone who can set a parentId (a member with
// write access, or the REST API) could otherwise point a card at a
// card on a private board and have this cursor publish that board's
// full card documents - title, description, custom fields - to every
// subscriber here. Publish only the ancestors whose board this
// subscriber may actually see.
const ancestors = await ReactiveCache.getCards(
{ _id: { $in: ancestorIds } },
{ fields: { _id: 1, boardId: 1 } },
false,
);
const ancestorBoardIds = [...new Set((ancestors || []).map(c => c.boardId).filter(Boolean))];
const allowedBoardIds = await visibleBoardIds(thisUserId, ancestorBoardIds);
// This board is being published to this subscriber already, so its own
// cards need no second decision.
allowedBoardIds.add(board._id);
const allowedAncestorIds = (ancestors || [])
.filter(c => allowedBoardIds.has(c.boardId))
.map(c => c._id);
if (allowedAncestorIds.length === 0) return null;
return await ReactiveCache.getCards({ _id: { $in: allowedAncestorIds } }, {}, true);
}
},
// Linked cards (cardType-linkedCard)
{
async find(board) {
const cardSelector = {
...boardCardScope(board),
archived: isArchived,
};
if (thisUserId && board.members) {
const member = findWhere(board.members, { userId: thisUserId, isActive: true });
if (member && (member.isNormalAssignedOnly || member.isCommentAssignedOnly || member.isReadAssignedOnly)) {
cardSelector.assignees = { $in: [thisUserId] };
}
}
const cards = await ReactiveCache.getCards(cardSelector, { fields: { _id: 1, type: 1, linkedId: 1 } }, false);
if (!cards || cards.length === 0) return null;
const linkedCardIds = cards.filter(c => c.type === 'cardType-linkedCard' && c.linkedId).map(c => c.linkedId);
// Only the linked cards whose board this subscriber may see
// (GHSA-jvv9-498p-hxrg class).
const linkedCardIds = await visibleLinkedCardIds(board);
if (linkedCardIds.length === 0) return null;
return await ReactiveCache.getCards({ _id: { $in: linkedCardIds }, archived: isArchived }, {}, true);
@@ -938,22 +1021,9 @@ publishComposite('board', async function(boardId, isArchived) {
// Comments for linked cards
{
async find(board) {
const cardSelector = {
...boardCardScope(board),
archived: isArchived,
};
if (thisUserId && board.members) {
const member = findWhere(board.members, { userId: thisUserId, isActive: true });
if (member && (member.isNormalAssignedOnly || member.isCommentAssignedOnly || member.isReadAssignedOnly)) {
cardSelector.assignees = { $in: [thisUserId] };
}
}
const cards = await ReactiveCache.getCards(cardSelector, { fields: { _id: 1, type: 1, linkedId: 1 } }, false);
if (!cards || cards.length === 0) return null;
const linkedCardIds = cards.filter(c => c.type === 'cardType-linkedCard' && c.linkedId).map(c => c.linkedId);
// Only the linked cards whose board this subscriber may see
// (GHSA-jvv9-498p-hxrg class).
const linkedCardIds = await visibleLinkedCardIds(board);
if (linkedCardIds.length === 0) return null;
return await ReactiveCache.getCardComments({ cardId: { $in: linkedCardIds } }, {}, true);
@@ -962,22 +1032,9 @@ publishComposite('board', async function(boardId, isArchived) {
// Attachments for linked cards
{
async find(board) {
const cardSelector = {
...boardCardScope(board),
archived: isArchived,
};
if (thisUserId && board.members) {
const member = findWhere(board.members, { userId: thisUserId, isActive: true });
if (member && (member.isNormalAssignedOnly || member.isCommentAssignedOnly || member.isReadAssignedOnly)) {
cardSelector.assignees = { $in: [thisUserId] };
}
}
const cards = await ReactiveCache.getCards(cardSelector, { fields: { _id: 1, type: 1, linkedId: 1 } }, false);
if (!cards || cards.length === 0) return null;
const linkedCardIds = cards.filter(c => c.type === 'cardType-linkedCard' && c.linkedId).map(c => c.linkedId);
// Only the linked cards whose board this subscriber may see
// (GHSA-jvv9-498p-hxrg class).
const linkedCardIds = await visibleLinkedCardIds(board);
if (linkedCardIds.length === 0) return null;
const result = await ReactiveCache.getAttachments({ 'meta.cardId': { $in: linkedCardIds } }, {}, true);
@@ -987,22 +1044,9 @@ publishComposite('board', async function(boardId, isArchived) {
// Checklists for linked cards
{
async find(board) {
const cardSelector = {
...boardCardScope(board),
archived: isArchived,
};
if (thisUserId && board.members) {
const member = findWhere(board.members, { userId: thisUserId, isActive: true });
if (member && (member.isNormalAssignedOnly || member.isCommentAssignedOnly || member.isReadAssignedOnly)) {
cardSelector.assignees = { $in: [thisUserId] };
}
}
const cards = await ReactiveCache.getCards(cardSelector, { fields: { _id: 1, type: 1, linkedId: 1 } }, false);
if (!cards || cards.length === 0) return null;
const linkedCardIds = cards.filter(c => c.type === 'cardType-linkedCard' && c.linkedId).map(c => c.linkedId);
// Only the linked cards whose board this subscriber may see
// (GHSA-jvv9-498p-hxrg class).
const linkedCardIds = await visibleLinkedCardIds(board);
if (linkedCardIds.length === 0) return null;
return await ReactiveCache.getChecklists({ cardId: { $in: linkedCardIds } }, {}, true);
@@ -1011,22 +1055,9 @@ publishComposite('board', async function(boardId, isArchived) {
// ChecklistItems for linked cards
{
async find(board) {
const cardSelector = {
...boardCardScope(board),
archived: isArchived,
};
if (thisUserId && board.members) {
const member = findWhere(board.members, { userId: thisUserId, isActive: true });
if (member && (member.isNormalAssignedOnly || member.isCommentAssignedOnly || member.isReadAssignedOnly)) {
cardSelector.assignees = { $in: [thisUserId] };
}
}
const cards = await ReactiveCache.getCards(cardSelector, { fields: { _id: 1, type: 1, linkedId: 1 } }, false);
if (!cards || cards.length === 0) return null;
const linkedCardIds = cards.filter(c => c.type === 'cardType-linkedCard' && c.linkedId).map(c => c.linkedId);
// Only the linked cards whose board this subscriber may see
// (GHSA-jvv9-498p-hxrg class).
const linkedCardIds = await visibleLinkedCardIds(board);
if (linkedCardIds.length === 0) return null;
return await ReactiveCache.getChecklistItems({ cardId: { $in: linkedCardIds } }, {}, true);
+38 -18
View File
@@ -638,16 +638,44 @@ async function buildSelector(queryParams, userId) {
queryUsers[OPERATOR_MEMBER] = [];
queryUsers[OPERATOR_CREATOR] = [];
if (queryParams.hasOperator(OPERATOR_USER)) {
const users = [];
for (const username of queryParams.getPredicates(OPERATOR_USER)) {
const user = await ReactiveCache.getUser({ username });
if (user) {
users.push(user._id);
} else {
errors.addNotFound(OPERATOR_USER, username);
}
// Resolve every username the query names in ONE lookup.
//
// Each `user:`/`member:`/`assignee:`/`creator:` predicate used to do its own
// awaited findOne, so `member:ann member:bob member:carol` was three serial
// round-trips before the search itself could start. They are all the same
// question - which of these names is an account - so it is asked once, with
// `$in`, and answered from a map. Names that matched nothing are still
// reported per operator, exactly as before, because the operator is what
// tells the user WHERE the unknown name was typed.
const namedUsernames = new Set();
for (const key of [OPERATOR_USER, OPERATOR_MEMBER, OPERATOR_ASSIGNEE, OPERATOR_CREATOR]) {
if (queryParams.hasOperator(key)) {
for (const username of queryParams.getPredicates(key)) namedUsernames.add(username);
}
}
const userIdByUsername = new Map();
if (namedUsernames.size) {
const found = await ReactiveCache.getUsers(
{ username: { $in: [...namedUsernames] } },
{ fields: { _id: 1, username: 1 } },
);
(found || []).forEach(user => userIdByUsername.set(user.username, user._id));
}
// The ids a predicate names, and the names it got wrong.
const resolvePredicates = key => {
const ids = [];
for (const username of queryParams.getPredicates(key)) {
const id = userIdByUsername.get(username);
if (id) ids.push(id);
else errors.addNotFound(key, username);
}
return ids;
};
if (queryParams.hasOperator(OPERATOR_USER)) {
const users = resolvePredicates(OPERATOR_USER);
if (users.length) {
selector.$and.push({
$or: [{ members: { $in: users } }, { assignees: { $in: users } }],
@@ -657,15 +685,7 @@ async function buildSelector(queryParams, userId) {
for (const key of [OPERATOR_MEMBER, OPERATOR_ASSIGNEE, OPERATOR_CREATOR]) {
if (queryParams.hasOperator(key)) {
const users = [];
for (const username of queryParams.getPredicates(key)) {
const user = await ReactiveCache.getUser({ username });
if (user) {
users.push(user._id);
} else {
errors.addNotFound(key, username);
}
}
const users = resolvePredicates(key);
if (users.length) {
selector[key] = { $in: users };
}
+24 -2
View File
@@ -3,6 +3,26 @@ import { publishComposite } from 'meteor/reywood:publish-composite';
import Boards from '/models/boards';
import Cards from '/models/cards';
const { hasWhere } = require('/models/lib/mongoSelectorSafety');
const { classifySelector, injectionDetail } = require('/models/lib/injectionDetect');
import { tripCanary } from '/server/lib/canary';
// A client-supplied selector is run against the database, so an execution
// operator in one is not a query - it is an attempt to make the database run
// something. The publication already refuses it; this names WHO tried, from
// where, and what they sent (docs/Security/Remediation/WeKan.md §12.6).
// Returns the same boolean the old hasWhere() call did, so both refusal sites
// below are unchanged.
function selectorIsInjection(selector, where) {
const verdict = classifySelector(selector);
if (!verdict.injection && !hasWhere(selector)) return false;
tripCanary('injection.nosql-selector', {
detail: injectionDetail(
verdict.injection ? verdict : { kind: 'execution', operators: ['$where'] },
where,
),
});
return true;
}
const {
boardCardScope,
assignedOnlyCardScope,
@@ -59,7 +79,9 @@ publishComposite('boardCardsWindow', function(boardId, cardSelector, sort, limit
const userId = this.userId;
const lim = Math.max(1, Math.min(Math.floor(limit) || 1, MAX_WINDOW));
const safe = hasWhere(cardSelector) ? { _id: { $in: [] } } : cardSelector;
const safe = selectorIsInjection(cardSelector, 'boardCardsWindow')
? { _id: { $in: [] } }
: cardSelector;
// #6511: a UNIQUE _id tiebreaker so the LIMITED published window is deterministic
// (equal-`sort` ties would otherwise make the "first N cards" vary between polls,
// feeding the client's #each an inconsistent ordered set).
@@ -186,7 +208,7 @@ Meteor.publish('boardListCardCount', async function(countId, boardId, cardSelect
check(cardSelector, Object);
const board = await boardVisibleTo(this.userId, boardId);
if (!board || hasWhere(cardSelector)) {
if (!board || selectorIsInjection(cardSelector, 'boardCardsCount')) {
return this.ready();
}
+4 -4
View File
@@ -19,7 +19,7 @@
# base: core24 (not core26) · no build-base: devel · grade: stable
# ─────────────────────────────────────────────────────────────────────────────
name: wekan
version: '10.73'
version: '10.76'
base: core24
confinement: strict
grade: stable
@@ -353,18 +353,18 @@ parts:
armhf) WEKAN_ARCH="armhf" ;;
*) echo "Unsupported architecture: ${CRAFT_ARCH_BUILD_FOR}"; exit 1 ;;
esac
WEKAN_ZIP="wekan-10.73-${WEKAN_ARCH}.zip"
WEKAN_ZIP="wekan-10.76-${WEKAN_ARCH}.zip"
echo "Cleaning environment first"
rm -rf .build
mkdir .build
cd .build
#wget https://github.com/wekan/wekan/releases/download/v10.73/wekan-10.73-amd64.zip
#wget https://github.com/wekan/wekan/releases/download/v10.76/wekan-10.76-amd64.zip
# Retry: a release asset can 404 for a short while right after it is
# uploaded (CDN/propagation lag), and a plain wget treated that as
# fatal. --retry-on-http-error covers the 404/5xx window; a genuinely
# missing bundle still fails after the tries are spent.
wget --tries=20 --waitretry=20 --retry-on-http-error=404,403,500,502,503 \
"https://github.com/wekan/wekan/releases/download/v10.73/${WEKAN_ZIP}"
"https://github.com/wekan/wekan/releases/download/v10.76/${WEKAN_ZIP}"
unzip "${WEKAN_ZIP}"
rm "${WEKAN_ZIP}"
# Fix: bcrypt 5.0.1 (Meteor accounts-password) ships node-addon-api@3
+200
View File
@@ -0,0 +1,200 @@
'use strict';
// PathBleed — GHSA-4mxf-m8pq-xc9p, "Avatar versions.path client update enables
// arbitrary file read via board export" (High, CWE-22 / CWE-73), reported by
// Alpastx. https://wekan.fi/hall-of-fame/pathbleed/
//
// Attachments blocked client-supplied `versions.*.path`; avatars did not — their
// allow rule was the single line `update: isOwner`, with no field restriction at
// all. So any authenticated user could point their own avatar's
// `versions.original.path` at, say, /etc/passwd, then export a board they are a
// member of: the exporter read that path off disk and embedded the bytes as
// base64 in `profile.avatarFile`. Arbitrary file read as the WeKan OS user.
//
// Two halves, both tested here: the WRITE is refused (the shared guards, now
// used by avatars as well as attachments) and the READ is refused (the exporter
// only reads paths that resolve inside WeKan's own storage).
//
// Run: node tests/avatarVersionPathTraversal.test.cjs
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
const read = rel => fs.readFileSync(path.join(repoRoot, rel), 'utf8');
const {
hasUnsafeClientVersionFields,
touchesVersionFields,
onlyTouchesAllowedFields,
} = require('../models/lib/fileVersionFields');
const {
isPathInside,
isPathInsideAny,
isPathInsideBase,
} = require('../models/lib/storagePathContainment');
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
// ---------------------------------------------------------------- the write
test('the attack payload is recognised on insert', () => {
assert.strictEqual(
hasUnsafeClientVersionFields({
versions: { original: { path: '/etc/passwd' } },
}),
true,
);
});
test('a storage override is recognised too', () => {
assert.strictEqual(
hasUnsafeClientVersionFields({ versions: { original: { storage: 'gridfs' } } }),
true,
);
});
test('negative: an ordinary upload is not blocked', () => {
assert.strictEqual(
hasUnsafeClientVersionFields({
name: 'me.png',
type: 'image/png',
versions: { original: { size: 1234, type: 'image/png' } },
}),
false,
);
assert.strictEqual(hasUnsafeClientVersionFields({}), false);
assert.strictEqual(hasUnsafeClientVersionFields(null), false);
assert.strictEqual(hasUnsafeClientVersionFields({ versions: 'nonsense' }), false);
});
test('the update form: any field under versions is refused', () => {
assert.strictEqual(touchesVersionFields(['versions.original.path']), true);
// The whole sub-object carries the path with it, so naming it is the same attack.
assert.strictEqual(touchesVersionFields(['versions.original']), true);
assert.strictEqual(touchesVersionFields(['versions']), true);
assert.strictEqual(touchesVersionFields(['name', 'versions.original.path']), true);
});
test('negative: renaming an avatar is still allowed', () => {
assert.strictEqual(touchesVersionFields(['name']), false);
assert.strictEqual(touchesVersionFields(['meta.boardId', 'type']), false);
assert.strictEqual(touchesVersionFields([]), false);
// A field that merely STARTS with the same letters is not the versions subtree.
assert.strictEqual(touchesVersionFields(['versionsomething']), false);
});
test('the field whitelist compares the part before the first dot', () => {
const allowed = ['name', 'size', 'type', 'extension', 'extensionWithDot', 'meta'];
assert.strictEqual(onlyTouchesAllowedFields(['meta.boardId'], allowed), true);
assert.strictEqual(onlyTouchesAllowedFields(['name', 'size'], allowed), true);
assert.strictEqual(onlyTouchesAllowedFields(['userId'], allowed), false);
assert.strictEqual(onlyTouchesAllowedFields(['versions.original.path'], allowed), false);
});
// ----------------------------------------------------------------- the read
const AVATARS = '/data/files/avatars';
const ATTACHMENTS = '/data/files/attachments';
test('a real avatar file is readable', () => {
assert.strictEqual(isPathInside(`${AVATARS}/abc123.png`, AVATARS), true);
assert.strictEqual(isPathInsideAny(`${AVATARS}/abc123.png`, [AVATARS, ATTACHMENTS]), true);
});
test('the payload path is NOT readable', () => {
assert.strictEqual(isPathInside('/etc/passwd', AVATARS), false);
assert.strictEqual(isPathInsideAny('/etc/passwd', [AVATARS, ATTACHMENTS]), false);
});
test('..-escapes are resolved before comparing, not string-matched', () => {
assert.strictEqual(isPathInside(`${AVATARS}/../../../etc/passwd`, AVATARS), false);
assert.strictEqual(isPathInside(`${AVATARS}/../attachments/x.png`, AVATARS), false);
});
test('a sibling directory with the root as a prefix is not inside it', () => {
assert.strictEqual(isPathInside('/data/files/avatars-evil/x.png', AVATARS), false);
});
test('the storage root itself is not a file to read', () => {
assert.strictEqual(isPathInside(AVATARS, AVATARS), false);
// ...though as a BASE it contains itself, which is what the download path
// has always meant by isPathInside(base, target).
assert.strictEqual(isPathInsideBase(AVATARS, AVATARS), true);
});
test('negative: empty and non-string paths are refused, never accepted', () => {
assert.strictEqual(isPathInside('', AVATARS), false);
assert.strictEqual(isPathInside(null, AVATARS), false);
assert.strictEqual(isPathInside(undefined, AVATARS), false);
assert.strictEqual(isPathInside(`${AVATARS}/x.png`, ''), false);
assert.strictEqual(isPathInsideAny(`${AVATARS}/x.png`, null), false);
assert.strictEqual(isPathInsideAny(`${AVATARS}/x.png`, []), false);
});
// -------------------------------------------------------------- the sources
test('the avatar allow rule no longer says just `update: isOwner`', () => {
const avatars = read('server/permissions/avatars.js');
assert.ok(
!/^\s*update:\s*isOwner\s*,/m.test(avatars),
'the unrestricted `update: isOwner` is what the advisory exploited',
);
assert.ok(/touchesVersionFields\(fields\)/.test(avatars), 'update checks the versions subtree');
assert.ok(/hasUnsafeClientVersionFields\(doc\)/.test(avatars), 'insert checks the payload');
assert.ok(/onlyTouchesAllowedFields\(/.test(avatars), 'update has a field whitelist');
assert.ok(/isOwner\(userId, doc\)/.test(avatars), 'and being the owner is still required');
});
test('both permission files import the guards from ONE module', () => {
const avatars = read('server/permissions/avatars.js');
const attachments = read('server/permissions/attachments.js');
const importRe = /from '\/models\/lib\/fileVersionFields'/;
assert.ok(importRe.test(avatars), 'avatars import the shared guards');
assert.ok(importRe.test(attachments), 'attachments import the shared guards');
// The duplicate that let the two drift apart is gone.
assert.ok(
!/function hasUnsafeClientVersionFields/.test(attachments),
'attachments must not keep a private copy of the rule',
);
});
test('the exporter checks containment before reading a stored path', () => {
const exporter = read('models/exporter.js');
assert.ok(/function isReadableStoredFilePath/.test(exporter));
// The one place bytes are read, and both avatar call sites.
const guards = exporter.match(/isReadableStoredFilePath\(/g) || [];
assert.ok(guards.length >= 4, `expected the guard at every read site, found ${guards.length}`);
assert.ok(
/const storedPath = doc\?\.versions\?\.original\?\.path;[\s\S]{0,400}?if \(!isReadableStoredFilePath\(storedPath\)\)/.test(exporter),
'getBase64Data refuses an uncontained path before opening a read stream',
);
});
test('negative: no unguarded read of a stored version path remains in the exporter', () => {
const exporter = read('models/exporter.js');
assert.ok(
!/createReadStream\(doc\.versions\.original\.path\)/.test(exporter),
'the stream must be opened on the CHECKED path variable, not on the raw field',
);
const reads = exporter.match(/fs\.readFileSync\(p\)/g) || [];
assert.strictEqual(reads.length, 1, 'one readFileSync, and it is inside the checked branch');
assert.ok(/if \(p && isReadableStoredFilePath\(p\)\) \{/.test(exporter));
});
test('the download path uses the same containment module, not its own copy', () => {
const strategy = read('models/lib/fileStoreStrategy.js');
assert.ok(/require\('\.\/storagePathContainment'\)/.test(strategy));
assert.ok(
!/^function isPathInside\(/m.test(strategy),
'the private duplicate is gone — one rule, one place',
);
});
console.log(`\n${passed} tests passed`);
+138
View File
@@ -0,0 +1,138 @@
'use strict';
// RevokeBleed — GHSA-gwc4-fw7p-gw58, "Board publication ignores isActive on
// org/team/domain shares, so revoked access still exposes private boards"
// (Moderate, CWE-639 / CWE-863), reported by Alpastx.
// https://wekan.fi/hall-of-fame/revokebleed/
//
// `isActive: false` is how a board admin REVOKES an org, team or domain share.
// The All Boards list honoured it (`$elemMatch` with `isActive: true`), so a
// revoked user watched the board disappear — but the `board` publication, the
// one that sends the board document and its lists, cards and attachments,
// matched shares with a dotted `'orgs.orgId': { $in: [...] }` that says nothing
// about isActive. Anyone who still knew the boardId could subscribe and receive
// the whole private board. A revoke the primary data publication does not
// honour is not a revoke.
//
// The selectors are built in one place now, and this pins both what they contain
// and that both call sites use that builder.
//
// Run: node tests/boardShareRevokeBypass.test.cjs
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
const read = rel => fs.readFileSync(path.join(repoRoot, rel), 'utf8');
const { boardVisibilitySelectors } = require('../models/lib/boardVisibilitySelectors');
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
const USER = 'user-v';
const ARGS = {
userId: USER,
orgIds: ['org-o'],
teamIds: ['team-t'],
emailDomains: ['example.com'],
};
const findClause = (selectors, key) => selectors.find(s => Object.prototype.hasOwnProperty.call(s, key));
test('every share kind is matched with $elemMatch and isActive: true', () => {
const selectors = boardVisibilitySelectors(ARGS);
assert.deepStrictEqual(findClause(selectors, 'orgs'), {
orgs: { $elemMatch: { orgId: { $in: ['org-o'] }, isActive: true } },
});
assert.deepStrictEqual(findClause(selectors, 'teams'), {
teams: { $elemMatch: { teamId: { $in: ['team-t'] }, isActive: true } },
});
assert.deepStrictEqual(findClause(selectors, 'domains'), {
domains: { $elemMatch: { domain: { $in: ['example.com'] }, isActive: true } },
});
assert.deepStrictEqual(findClause(selectors, 'members'), {
members: { $elemMatch: { userId: USER, isActive: true } },
});
});
test('negative: the dotted form that ignored isActive is not produced', () => {
const json = JSON.stringify(boardVisibilitySelectors(ARGS));
// These are the exact three selectors the advisory exploited.
assert.ok(!json.includes('orgs.orgId'), 'no dotted orgs.orgId clause');
assert.ok(!json.includes('teams.teamId'), 'no dotted teams.teamId clause');
assert.ok(!json.includes('domains.domain'), 'no dotted domains.domain clause');
});
test('negative: no clause anywhere matches a share without requiring isActive', () => {
const selectors = boardVisibilitySelectors(ARGS);
selectors
.filter(s => !Object.prototype.hasOwnProperty.call(s, 'permission'))
.forEach(clause => {
const [field] = Object.keys(clause);
const elemMatch = clause[field].$elemMatch;
assert.ok(elemMatch, `${field} must use $elemMatch, not a dotted path`);
assert.strictEqual(elemMatch.isActive, true, `${field} must require isActive`);
});
});
test('a public board is reachable by anyone, and is the only anonymous clause', () => {
const anon = boardVisibilitySelectors({ userId: null });
assert.deepStrictEqual(anon, [{ permission: 'public' }]);
});
test('includePublic: false drops it, for searches over all boards', () => {
const selectors = boardVisibilitySelectors({ ...ARGS, includePublic: false });
assert.strictEqual(findClause(selectors, 'permission'), undefined);
assert.strictEqual(selectors.length, 4);
});
test('negative: an anonymous caller with no public clause reaches nothing', () => {
assert.deepStrictEqual(
boardVisibilitySelectors({ userId: null, includePublic: false }),
[],
);
assert.deepStrictEqual(boardVisibilitySelectors(), [{ permission: 'public' }]);
});
test('negative: junk in the id lists is dropped, not passed to Mongo', () => {
const selectors = boardVisibilitySelectors({
userId: USER,
orgIds: ['ok', '', null, undefined, 42, {}],
teamIds: 'not-an-array',
emailDomains: null,
});
assert.deepStrictEqual(findClause(selectors, 'orgs').orgs.$elemMatch.orgId, { $in: ['ok'] });
assert.deepStrictEqual(findClause(selectors, 'teams').teams.$elemMatch.teamId, { $in: [] });
assert.deepStrictEqual(findClause(selectors, 'domains').domains.$elemMatch.domain, { $in: [] });
});
// -------------------------------------------------------------- the sources
test('the board publication builds its $or with the shared builder', () => {
const pub = read('server/publications/boards.js');
assert.ok(/boardVisibilitySelectors\(\{/.test(pub), 'publication uses the builder');
assert.ok(
!/\$or\.push\(\{ 'orgs\.orgId'/.test(pub),
'the dotted org clause the advisory exploited must be gone',
);
assert.ok(!/\$or\.push\(\{ 'teams\.teamId'/.test(pub));
assert.ok(!/\$or\.push\(\{ 'domains\.domain'/.test(pub));
});
test('Boards.userBoards uses the same builder, so the two cannot drift again', () => {
const boards = read('models/boards.js');
assert.ok(/selector\.\$or = boardVisibilitySelectors\(\{/.test(boards));
assert.ok(
!/\{ orgs: \{ \$elemMatch: \{ orgId: \{ \$in: user\.orgIds\(\) \}/.test(boards),
'the hand-written copy is gone',
);
});
console.log(`\n${passed} tests passed`);
+25 -6
View File
@@ -186,14 +186,12 @@ test('every script in releases/ is reachable from BOTH menus', () => {
const SKIP = {
'run-everything.sh': 'the Tests menu runs it (EVERYTHING, sequential)',
'db-conformance.sh': 'the Tests menu runs it (all databases)',
'fix-changelog-hashes.sh': 'Setup -> Update git runs it',
'fix-changelog-hashes.sh': 'Setup -> git pull and git push both run it',
'ensure-tools.sh': 'a helper the other scripts source, not an action',
'ferretdb/start-wekan.sh': 'runs INSIDE the built snap/bundle, not here',
'ferretdb/wekan-entrypoint.sh': 'the Docker image entrypoint',
'ferretdb/recovery-bridge.mjs': 'runs inside the running server',
'ferretdb/start-wekan.bat': 'shipped INSIDE the Windows bundle, to start it',
'old-build-bundle-arm64.sh': 'superseded by build-bundle-arm64.sh',
'translations/old-pull-translations.sh': 'superseded by pull-translations.sh',
'build-bundle-win64.bat': 'a Windows batch script - bash cannot run it, so it '
+ 'is not a menu entry; build.bat\'s Bundles menu says to run it directly',
// Release-workflow helpers. These are called by
@@ -203,8 +201,6 @@ test('every script in releases/ is reachable from BOTH menus', () => {
// "install a target-CPU Node.js into the container we are inside" would be
// an entry that cannot work.
'check-arch-binaries.sh': 'release-workflow preflight: called per build job',
'resolve-fork-node-tag.sh': 'release-workflow: resolves the fork\'s newest '
+ 'v<major>.x tag carrying an asset, for the win32/mac-x64 preflights',
'embed-verified-node.sh': 'release-workflow: called per native build job to '
+ 'download+verify the bundled Node.js from the wekan/node fork',
'install-node-for-arch.sh': 'release-workflow: runs inside the build container',
@@ -241,7 +237,30 @@ test('the Releases menu is the same list, in the same order, in both scripts', (
// Same entries, same order, in the .bat's dispatch lines - EXCEPT the ones
// marked linux: systemd, ufw, snap and multipass are things Windows cannot do
// at all, so offering them there would be a menu entry that can only fail.
const forWindows = entries.filter(e => e.platform !== 'linux');
// A `!`-prefixed entry is a build.sh FUNCTION, not a script in releases/, so
// there is nothing for the .bat to `call :rel_run`. Windows implements those
// natively instead - `git pull` and `git push` are entries 3 and 4 of its main
// menu, going to :gitpull and :gitpush - and the assertion below checks that
// it really does, rather than letting a function entry silently drop out of
// one menu. Comparing them as scripts is what made this guard fail the moment
// "Update git" was replaced by the two that finish the job.
// A `!` marks an entry build.sh runs itself rather than by executing a file in
// releases/. Most are raw commands, and the .bat runs those the same way -
// those still compare. The exception is an entry naming a build.sh FUNCTION,
// which is shell the .bat has no way to call: Windows implements the same
// thing natively, and the loop below checks that it does.
const isFunction = e =>
e.script.startsWith('!') &&
/^[a-z][a-z0-9_]*$/.test(e.script.slice(1)) &&
new RegExp(`^function ${e.script.slice(1)}\\(\\)`, 'm').test(sh);
const forWindows = entries.filter(e => e.platform !== 'linux' && !isFunction(e));
for (const e of entries.filter(x => isFunction(x) && x.platform !== 'linux')) {
const label = e.script.slice(1).replace(/_/g, ''); // git_pull -> gitpull
assert.ok(new RegExp(`^:${label}\\b`, 'm').test(bat),
`build.sh offers "${e.label}" as a function; build.bat must implement :${label}`);
}
const inBat = [...bat.matchAll(/call :rel_(?:run|cmd) "([^"]+)" "([^"]*)"/g)]
.map(m => ({ what: m[1], prompt: m[2] }));
assert.deepStrictEqual(inBat.map(e => e.what),
+316
View File
@@ -0,0 +1,316 @@
'use strict';
// Which permission-override attempts are watched, and can any of them tell the
// attacker they were seen? (design: docs/Security/Remediation/WeKan.md §12)
//
// A canary is worth nothing in two situations, and this suite exists for both:
//
// 1. It is not there. A guard that refuses silently records nothing, so the
// admin never learns that somebody spent an afternoon trying to move cards
// into a board they cannot read. Below: every server-side detectable
// hall-of-fame attempt has a canary at the point that refuses it.
// 2. It announces itself. If a canaried refusal differs from an ordinary one -
// a different message, a different status, a delay, a thrown error - a probe
// simply learns which paths are watched and avoids them. Below: no call site
// returns anything but the refusal it returned before.
//
// Run: node tests/canaryCoverage.test.cjs
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
const read = rel => fs.readFileSync(path.join(repoRoot, rel), 'utf8');
const { canaryIds, canaryFor } = require('../models/lib/canaryTokens');
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
// Every canary, the file that trips it, and the published vulnerability whose
// attempt it watches. "-" means it watches a class with no single *Bleed.
const WIRED = [
['card.cross-board-move', 'server/permissions/cards.js', 'BoardBleed'],
['card.invisible-parent', 'server/permissions/cards.js', 'ParentBleed'],
['card.vote-field', 'server/permissions/cards.js', '-'],
['card.poker-field', 'server/permissions/cards.js', '-'],
['list.cross-board-move', 'server/permissions/lists.js', 'BoardBleed'],
['swimlane.cross-board-move', 'server/permissions/swimlanes.js', 'BoardBleed'],
['checklist.cross-board-move', 'server/permissions/checklists.js', 'ChecklistBleed'],
['checklist-item.cross-board-move', 'server/permissions/checklistItems.js', 'ChecklistBleed'],
['avatar.version-path', 'server/permissions/avatars.js', 'PathBleed'],
['avatar.restricted-field', 'server/permissions/avatars.js', 'PathBleed'],
['avatar.not-owner', 'server/permissions/avatars.js', 'PathBleed'],
['attachment.version-path', 'server/permissions/attachments.js', 'PathBleed'],
['attachment.restricted-field', 'server/permissions/attachments.js', 'PathBleed'],
['reaction.foreign', 'server/permissions/cardCommentReactions.js', '-'],
['comment.foreign-delete', 'server/models/cardComments.js', 'CommentBleed'],
['export.path-outside-storage', 'models/exporter.js', 'PathBleed'],
['database.canary', 'server/lib/databaseProblems.js', '-'],
// Injection - the attacker sends a query instead of a value.
['injection.nosql-selector', 'server/publications/cardsWindow.js', '-'],
['injection.nosql-operator', 'models/lib/injectionDetect.js', '-'],
['injection.sql-statement', 'server/lib/databaseProblems.js', 'EscapeBleed'],
// Sanitization - something DANGEROUS had to be removed, not merely tidied.
['sanitize.dangerous-filename', 'server/lib/filenameSanitizeLog.js', 'FileBleed'],
['sanitize.path-traversal', 'server/lib/filenameSanitizeLog.js', 'FileBleed'],
['sanitize.dangerous-content', 'models/lib/sanitizeUploadedFile.js', 'SpaceBleed'],
['sanitize.dangerous-text', 'models/cardComments.js', 'InputBleed'],
// Other common attacks.
['spoof.forwarded-header', 'models/server/metrics.js', 'MetricsBleed'],
['brute.login-lockout', 'server/apiAuthRoutes.js', 'LockoutBleed'],
];
// One canary above is declared where its DETECTOR lives rather than at a call
// site. `injection.nosql-operator` is the id a caller passes when it finds an
// operator object on an input path it owns - the detector and the id belong
// together, and naming it there keeps the catalog honest (every id has a home)
// while the table below stays the list of places that actually trip one.
const DECLARED_NOT_TRIPPED = new Set(['injection.nosql-operator']);
test('every catalogued canary is actually wired somewhere', () => {
const wiredIds = new Set(WIRED.map(w => w[0]));
const orphans = canaryIds().filter(id => !wiredIds.has(id));
assert.deepStrictEqual(orphans, [],
'a canary in the catalog that nothing trips is a tripwire nobody laid');
});
test('every wired canary is in the catalog, and its file names it', () => {
const { CANARY_IDS } = require('../models/lib/injectionDetect');
// A call site may spell the id literally, or reach it through the CANARY_IDS
// map that lives beside the detector which produced the finding. Both are
// "naming it"; a call site that names neither is a canary nobody can trace
// back to its catalog entry.
const namedBy = (src, id) => {
if (src.includes(`'${id}'`)) return true;
const alias = Object.keys(CANARY_IDS).find(k => CANARY_IDS[k] === id);
return !!alias && src.includes(`CANARY_IDS.${alias}`);
};
WIRED.forEach(([id, file]) => {
assert.ok(canaryFor(id).known, `${id} is tripped in ${file} but is not catalogued`);
const src = read(file);
assert.ok(namedBy(src, id), `${file} must name ${id}`);
if (!DECLARED_NOT_TRIPPED.has(id)) {
assert.ok(/tripCanary|tripCanaryDeny/.test(src), `${file} must actually trip a canary`);
}
});
});
test('the CANARY_IDS aliases resolve to real catalogued canaries', () => {
const { CANARY_IDS } = require('../models/lib/injectionDetect');
Object.entries(CANARY_IDS).forEach(([alias, id]) => {
assert.ok(canaryFor(id).known, `CANARY_IDS.${alias} points at "${id}", which is not catalogued`);
});
});
test('the attempts behind published vulnerabilities are watched', () => {
// Not every hall-of-fame entry can be watched server-side - a fixed XSS leaves
// nothing to detect at runtime - but every one whose ATTEMPT still reaches a
// permission check should trip something.
const watched = new Set(WIRED.map(w => w[2]).filter(b => b !== '-'));
['BoardBleed', 'ChecklistBleed', 'PathBleed', 'ParentBleed', 'CommentBleed'].forEach(bleed => {
assert.ok(watched.has(bleed), `${bleed}'s attempt has no canary`);
});
});
// ------------------------------------------------------- it must stay silent
test('SILENT: no canary call site changes what the caller gets back', () => {
// tripCanary returns false and tripCanaryDeny returns true, so a call site
// reads as the refusal it replaced. Anything else - a throw, a different
// status, an added message - would be the tell.
const files = [...new Set(WIRED.map(w => w[1]))];
files.forEach(file => {
const src = read(file);
const calls = src.match(/trip(Canary|CanaryDeny)\([^;]*?\)/g) || [];
calls.forEach(call => {
assert.ok(!/throw/.test(call), `${file}: a canary must not throw: ${call}`);
});
});
});
test('SILENT: the deny rules still refuse by returning true, allow rules by false', () => {
const cards = read('server/permissions/cards.js');
// The allow rule's vote/poker branches replaced `return false` - the refusal
// is identical, and the canary is the only thing added.
assert.ok(/return tripCanary\('card\.vote-field'/.test(cards));
assert.ok(/return tripCanary\('card\.poker-field'/.test(cards));
// The deny rules replaced `return true`.
assert.ok(/return tripCanaryDeny\('card\.cross-board-move'/.test(cards));
assert.ok(/return tripCanaryDeny\('card\.invisible-parent'/.test(cards));
});
test('SILENT: the REST comment delete still throws the SAME refusal', () => {
const src = read('server/models/cardComments.js');
const block = src.match(/if \(comment\.userId && comment\.userId !== req\.userId\)[\s\S]*?\n \}/)[0];
// The canary is recorded and the original refusal is re-thrown untouched, so
// the caller sees the same 403 with the same message as before.
assert.ok(/catch \(refusal\) \{/.test(block));
assert.ok(/tripCanary\('comment\.foreign-delete'/.test(block));
assert.ok(/throw refusal;/.test(block), 'the original error is re-thrown, not a new one');
assert.ok(!/sendJsonResult/.test(block), 'no new response shape is introduced');
});
test('SILENT: the export drops the file exactly as it did before', () => {
const src = read('models/exporter.js');
const block = src.match(/if \(!isReadableStoredFilePath\(storedPath\)\)[\s\S]*?return;\n \}/)[0];
assert.ok(/tripCanary\('export\.path-outside-storage'/.test(block));
assert.ok(/callback\(null, null\);/.test(block), 'the same "no file" answer as before');
assert.ok(/catch \(e\) \{ \/\* never break an export to report on it \*\/ \}/.test(block));
});
test('SILENT: nothing tells the caller a canary exists', () => {
const files = [...new Set(WIRED.map(w => w[1]))].concat(['server/lib/canary.js']);
files.forEach(file => {
const src = read(file);
// The words that would leak into a message. Comments are fine - they do not
// cross the wire - so only string literals are checked.
const literals = src.match(/(['"`])(?:\\.|(?!\1).)*\1/g) || [];
literals.forEach(lit => {
const low = lit.toLowerCase();
if (low.includes('canary:')) return; // the FerretDB marker
// The PHRASES that would tell somebody they were watched. Deliberately
// specific: "You've been logged out" is an ordinary message that happens
// to contain "logged", and a guard that fails on it would be turned off.
const leaks = [
'has been recorded', 'have been recorded', 'was recorded',
'has been reported', 'was reported', 'we detected', 'attempt detected',
'this attempt', 'security team', 'administrator has been',
'your ip has', 'you have been flagged', 'tripwire',
];
leaks.forEach(phrase => {
assert.ok(!low.includes(phrase),
`${file}: a message reveals detection ("${phrase}"): ${lit}`);
});
});
});
});
// --------------------------------------------------------- it must stay cheap
test('BOUNDED: every trip goes through the one rate-limited entry point', () => {
// A guard that called securityLog.record directly for an attacker-controlled
// path would be an unbounded write, which is the thing the limiter exists to
// prevent. Canary sites use tripCanary, never the raw logger.
[...new Set(WIRED.map(w => w[1]))].forEach(file => {
if (file === 'server/lib/databaseProblems.js') return; // records other streams too
const src = read(file);
assert.ok(!/securityLog\.record\(/.test(src),
`${file} must trip canaries, not write events itself`);
});
});
test('BOUNDED: the limiter is a single shared instance, not one per call', () => {
const src = read('server/lib/canary.js');
const news = src.match(/new CanaryRateLimiter\(/g) || [];
assert.strictEqual(news.length, 1, 'one limiter for the process, or the caps mean nothing');
assert.ok(/^const limiter = new CanaryRateLimiter\(\);$/m.test(src));
});
test('BOUNDED: idle pairs are swept on a timer', () => {
const src = read('server/lib/canary.js');
assert.ok(/Meteor\.setInterval\(/.test(src));
assert.ok(/limiter\.sweep\(Date\.now\(\), IDLE_MS\)/.test(src));
assert.ok(/catch \(e\)/.test(src), 'a sweeper that throws must not take the timer down');
});
// ---------------------------------------------------- it must stay attributed
test('ATTRIBUTED: the event carries the account, the name and the address', () => {
const src = read('server/lib/canary.js');
const report = src.match(/function report\(canaryId, context\)[\s\S]*?\n\}/)[0];
['userId:', 'username:', 'ip:', 'count:'].forEach(field => {
assert.ok(report.includes(field), `the recorded event must carry ${field}`);
});
});
test('ATTRIBUTED: the schema and the logger both know the new fields', () => {
const schema = read('models/eventLog.js');
['username:', 'ip:', 'count:'].forEach(f => assert.ok(schema.includes(f),
`models/eventLog.js schema must declare ${f} - collection2 drops what it does not know`));
const logger = read('server/lib/securityLog.js');
assert.ok(/doc\.username = String\(m\.username\)\.slice/.test(logger));
assert.ok(/doc\.ip = String\(m\.ip\)\.slice/.test(logger));
});
test('ATTRIBUTED: the Admin Panel shows them, and they are searchable', () => {
const reports = read('client/components/settings/adminReports.js');
assert.ok(/labelKey: 'event-ip'/.test(reports), 'an IP column');
assert.ok(/labelKey: 'event-attempts'/.test(reports), 'an attempts column');
assert.ok(/r\.username \|\| userName\(r\.userId\)/.test(reports),
'the stored username wins over a lookup, so a rename does not rewrite history');
const model = read('models/eventLog.js');
assert.ok(/\{ username: rx \}, \{ ip: rx \}/.test(model),
'searching the table for an address or an account must find its rows');
const i18n = require('../imports/i18n/data/en.i18n.json');
assert.ok(i18n['event-ip'] && i18n['event-attempts'], 'both columns have English labels');
});
// -------------------------------------------------------------- the database
test('the FerretDB canary marker is parsed the same way on both sides', () => {
const { databaseCanaryId } = requireDatabaseProblemsHelpers();
assert.strictEqual(databaseCanaryId('operation not supported by this build (canary:db.javascript eval)'),
'db.javascript');
assert.strictEqual(databaseCanaryId({ message: 'x (canary:db.drop-database dropdatabase)' }),
'db.drop-database');
// Not one of ours.
assert.strictEqual(databaseCanaryId('some unrelated failure'), '');
assert.strictEqual(databaseCanaryId(null), '');
// A marker that is not an identifier is not a marker: an error string is
// attacker-influenced and must not choose a category.
assert.strictEqual(databaseCanaryId('canary:<script>'), '');
assert.strictEqual(databaseCanaryId('canary:' + 'a'.repeat(200)), '');
});
test('an unrecognised database canary id is recorded generically, not trusted', () => {
const src = read('server/lib/databaseProblems.js');
assert.ok(/KNOWN_DB_CANARIES/.test(src));
assert.ok(/unrecognised database canary/.test(src));
// The id is chosen first (SQL injection gets its own), then tripped through
// the ordinary path, so a database canary is rate-limited and attributed
// exactly like every other one.
assert.ok(/'database\.canary'/.test(src) && /'injection\.sql-statement'/.test(src));
assert.ok(/tripCanary\(canaryId, \{/.test(src),
'it goes through the rate-limited canary path like every other one');
assert.ok(/if \(recordDatabaseCanary\(error, options\)\) return null;/.test(src),
'and does not also land in the database-problem stream');
});
test('FerretDB refuses these operations and marks the refusal', () => {
const go = path.join(repoRoot, '.tools/FerretDB/internal/util/canary/canary.go');
if (!fs.existsSync(go)) {
console.log(' -- .tools/FerretDB not cloned; skipping the Go side');
return;
}
const src = fs.readFileSync(go, 'utf8');
assert.ok(/const Marker = "canary:"/.test(src), 'the marker both sides agree on');
['db.javascript', 'db.result-to-collection', 'db.drop-database', 'db.server-admin']
.forEach(id => assert.ok(src.includes(`"${id}"`), `FerretDB must define ${id}`));
// CLAUDE.md: FerretDB .go files carry no application-specific names.
assert.ok(!/wekan|WeKan|WEKAN/.test(src), 'no application name in a FerretDB source file');
// It must write nothing: the client decides what is recorded.
assert.ok(!/os\.OpenFile|sql\.Open|log\.New\(/.test(src),
'the canary package must not store anything itself');
});
function requireDatabaseProblemsHelpers() {
// databaseProblems.js is an ES module full of Meteor imports, so it cannot be
// require()d here. Its extractor is small and self-contained, so the guard
// evaluates that ONE function out of the source - which also means the test
// fails if the function is renamed or its shape changes.
const src = read('server/lib/databaseProblems.js');
const marker = src.match(/const FERRETDB_CANARY_MARKER = '[^']+';/)[0];
const fn = src.match(/export function databaseCanaryId\(error\) \{[\s\S]*?\n\}/)[0]
.replace('export function', 'function');
// eslint-disable-next-line no-new-func
return new Function(`${marker}\n${fn}\nreturn { databaseCanaryId };`)();
}
console.log(`\n${passed} tests passed`);
+265
View File
@@ -0,0 +1,265 @@
'use strict';
// Canary tokens (design: docs/Security/Remediation/WeKan.md §12).
//
// A canary is a tripwire on a path only a permission-override attempt reaches.
// Three properties are the whole point, and each is tested here:
//
// SILENT - tripping one changes nothing the caller can observe, so a probe
// cannot map which paths are watched and route around them.
// BOUNDED - it sits where an attacker can loop, so it must not cost one
// database row and one insert per attempt: repeats inside a
// window are counted and flushed as a single summary, the map of
// tracked pairs is capped, and a pair cannot write forever.
// ATTRIBUTED - every event carries who (userId + username) and from where (IP).
//
// Run: node tests/canaryTokens.test.cjs
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
const read = rel => fs.readFileSync(path.join(repoRoot, rel), 'utf8');
const {
CANARIES,
DEFAULTS,
CanaryRateLimiter,
canaryFor,
canaryIds,
canaryPairKey,
canaryDetail,
} = require('../models/lib/canaryTokens');
const { categoryFor } = require('../models/lib/securityCategories');
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
// ------------------------------------------------------------- the catalog
test('every canary resolves to a real security category and *Bleed name', () => {
canaryIds().forEach(id => {
const canary = canaryFor(id);
assert.ok(canary.known, `${id} must be in the catalog`);
const cat = categoryFor(canary.key);
assert.notStrictEqual(cat.category, 'unknown',
`${id} points at securityCategories key "${canary.key}", which is not defined`);
assert.ok(cat.bleed && cat.bleed !== 'Generic', `${id} must map to a named *Bleed`);
});
});
test('every canary says what was ATTEMPTED, in the words of the feature', () => {
canaryIds().forEach(id => {
const { what } = canaryFor(id);
assert.ok(what && what.length > 15, `${id}: "${what}" is not a sentence an admin can act on`);
// "authz.board blocked" is not something anyone can do anything about.
assert.ok(!/^[a-z-]+\.[a-z-]+$/.test(what), `${id}: the description must not be a key`);
});
});
test('an unknown canary id is still recorded, generically', () => {
// Losing the event would be the worst possible failure mode for a tripwire.
const canary = canaryFor('something.nobody.catalogued');
assert.strictEqual(canary.known, false);
assert.strictEqual(canary.id, 'something.nobody.catalogued');
assert.notStrictEqual(categoryFor(canary.key).category, 'unknown');
assert.ok(canary.what);
});
test('negative: junk ids do not throw', () => {
[null, undefined, 42, {}, ''].forEach(bad => {
const canary = canaryFor(bad);
assert.strictEqual(canary.id, 'unknown');
assert.ok(canary.what);
});
});
// --------------------------------------------------------------- BOUNDED
test('the first attempt is recorded at once, the rest of the window are counted', () => {
const limiter = new CanaryRateLimiter({ windowMs: 1000 });
const key = 'k';
assert.deepStrictEqual(limiter.consider(key, 0), { write: true, count: 1, suppressed: 0 });
assert.deepStrictEqual(limiter.consider(key, 10), { write: false, count: 2, suppressed: 1 });
assert.deepStrictEqual(limiter.consider(key, 999), { write: false, count: 3, suppressed: 2 });
});
test('the window closing flushes ONE summary carrying the suppressed count', () => {
const limiter = new CanaryRateLimiter({ windowMs: 1000 });
const key = 'k';
limiter.consider(key, 0);
for (let i = 1; i < 500; i++) limiter.consider(key, i);
const flush = limiter.consider(key, 1000);
assert.strictEqual(flush.write, true, 'the window closing writes');
assert.strictEqual(flush.count, 501, 'and the row stands for every attempt');
assert.ok(flush.suppressed >= 499, 'almost all of which were never written');
});
test('THE POINT: a thousand attempts cost two rows, not a thousand', () => {
const limiter = new CanaryRateLimiter({ windowMs: 60000 });
let writes = 0;
for (let i = 0; i < 1000; i++) {
if (limiter.consider('attacker', i).write) writes += 1;
}
assert.strictEqual(writes, 1, 'one row for a burst inside one window');
});
test('an endless probe stops writing rather than one row a minute forever', () => {
const limiter = new CanaryRateLimiter({ windowMs: 1000, maxEventsPerPair: 5 });
let writes = 0;
// A full day of attempts, one per second.
for (let t = 0; t < 86400 * 1000; t += 1000) {
if (limiter.consider('slow-attacker', t).write) writes += 1;
}
assert.strictEqual(writes, 5, 'the per-pair ceiling holds however long it runs');
});
test('the tracked-pair map is capped, so a botnet cannot grow it without bound', () => {
const limiter = new CanaryRateLimiter({ maxTracked: 50 });
for (let i = 0; i < 5000; i++) limiter.consider(`ip-${i}`, i);
assert.ok(limiter.size() <= 50, `map grew to ${limiter.size()}`);
});
test('...and the pair evicted is the least RECENTLY seen, not the first seen', () => {
// Otherwise a long-running attacker is evicted while a one-off probe is kept.
const limiter = new CanaryRateLimiter({ maxTracked: 3, windowMs: 10 });
limiter.consider('persistent', 0);
limiter.consider('a', 1);
limiter.consider('b', 2);
limiter.consider('persistent', 3); // seen again: now the most recent
limiter.consider('c', 4); // forces an eviction
// 'a' was the least recently seen, so it is the one that went.
assert.strictEqual(limiter.consider('persistent', 5).count > 1, true,
'the persistent attacker kept its counter');
});
test('idle pairs are swept, so a long-lived server does not hold every address', () => {
const limiter = new CanaryRateLimiter({ windowMs: 1000 });
for (let i = 0; i < 100; i++) limiter.consider(`ip-${i}`, 0);
assert.strictEqual(limiter.size(), 100);
assert.strictEqual(limiter.sweep(60000, 10000), 100);
assert.strictEqual(limiter.size(), 0);
});
test('negative: junk keys and clocks are handled, never thrown on', () => {
const limiter = new CanaryRateLimiter();
assert.strictEqual(limiter.consider(null, null).write, true);
assert.strictEqual(limiter.consider(undefined, NaN).write, false, 'both fall to the same key');
assert.strictEqual(limiter.sweep(undefined, undefined) >= 0, true);
});
test('the shipped defaults are bounded, not "unlimited"', () => {
assert.ok(DEFAULTS.windowMs >= 1000);
assert.ok(DEFAULTS.maxTracked > 0 && DEFAULTS.maxTracked <= 100000);
assert.ok(DEFAULTS.maxEventsPerPair > 0 && DEFAULTS.maxEventsPerPair <= 1000);
});
// ------------------------------------------------------------ ATTRIBUTED
test('a logged-in actor is keyed by account, so changing IP does not reset it', () => {
const a = canaryPairKey('card.vote-field', { userId: 'u1', ip: '1.1.1.1' });
const b = canaryPairKey('card.vote-field', { userId: 'u1', ip: '2.2.2.2' });
assert.strictEqual(a, b);
});
test('an anonymous actor is keyed by address', () => {
const a = canaryPairKey('card.vote-field', { ip: '1.1.1.1' });
const b = canaryPairKey('card.vote-field', { ip: '2.2.2.2' });
assert.notStrictEqual(a, b);
});
test('different canaries are counted separately', () => {
assert.notStrictEqual(
canaryPairKey('card.vote-field', { userId: 'u1' }),
canaryPairKey('card.poker-field', { userId: 'u1' }),
);
});
test('negative: an actor with nothing at all still gets a stable key', () => {
assert.strictEqual(canaryPairKey('x', {}), canaryPairKey('x', undefined));
assert.ok(canaryPairKey(null, null).length > 0);
});
// -------------------------------------------------------- what is recorded
test('the detail says what was tried and how many times', () => {
const canary = canaryFor('card.cross-board-move');
assert.ok(canaryDetail(canary, 1).includes('tried to move a card'));
assert.ok(!canaryDetail(canary, 1).includes('attempts'), 'a single attempt says no count');
assert.ok(canaryDetail(canary, 42).includes('42 attempts'));
});
test('negative: the detail never carries the payload', () => {
// The security log is not a place to store attacker-controlled data.
const canary = canaryFor('card.cross-board-move');
const detail = canaryDetail(canary, 2, 'board move');
assert.ok(detail.length < 200);
// Said where a future caller will read it, on the parameter itself.
assert.ok(/attacker-controlled text/.test(read('server/lib/canary.js')),
'canary.js must warn that context.detail is not a place for the payload');
// And the logger truncates and strips control characters regardless, so a
// caller that ignores the warning still cannot turn the log into a sink.
assert.ok(/sanitizeDetail/.test(read('server/lib/securityLog.js')));
});
// -------------------------------------------------------------- SILENT
test('tripCanary always returns false, and tripCanaryDeny always true', () => {
const src = read('server/lib/canary.js');
const trip = src.match(/export function tripCanary\([\s\S]*?\n\}/)[0];
assert.ok(/return false;/.test(trip), 'the guard gets its refusal');
assert.ok(!/return true/.test(trip));
const deny = src.match(/export function tripCanaryDeny\([\s\S]*?\n\}/)[0];
assert.ok(/return true;/.test(deny), 'a deny rule refuses by returning true');
});
test('the refusal is decided BEFORE any reporting work', () => {
const src = read('server/lib/canary.js');
const trip = src.match(/export function tripCanary\([\s\S]*?\n\}/)[0];
// try/catch around the reporting, return outside it: reporting cannot change
// the answer, and cannot throw into the caller either.
assert.ok(/try \{[\s\S]*?report\(canaryId, context\);[\s\S]*?\} catch[\s\S]*?\}\s*return false;/.test(trip),
'reporting is wrapped and the return value is not inside the try');
});
test('nothing is awaited on the caller\'s path', () => {
const src = read('server/lib/canary.js');
// A canary that awaits a database read would let an attacker slow every
// request they choose to trip it with.
assert.ok(/const p = finish\(\);[\s\S]{0,120}p\.catch\(\(\) => \{\}\)/.test(src),
'the username lookup and the insert are fire-and-forget');
const trip = src.match(/export function tripCanary\([\s\S]*?\n\}/)[0];
assert.ok(!/await/.test(trip), 'tripCanary itself awaits nothing');
});
test('the rate limit decision comes before the work, not after', () => {
const src = read('server/lib/canary.js');
const report = src.match(/function report\(canaryId, context\)[\s\S]*?\n\}/)[0];
const decideAt = report.indexOf('limiter.consider');
const workAt = report.indexOf('fillUsername');
assert.ok(decideAt > -1 && workAt > -1 && decideAt < workAt,
'a suppressed trip must cost one map lookup and nothing else');
assert.ok(/if \(!decision\.write\) return;/.test(report));
});
test('the username lookup is cached, so probing cannot make WeKan work harder', () => {
const src = read('server/lib/canary.js');
assert.ok(/NAME_TTL_MS/.test(src) && /NAME_CACHE_MAX/.test(src));
assert.ok(/nameCache\.size >= NAME_CACHE_MAX/.test(src), 'and the cache itself is capped');
});
test('the client address is resolved with the spoofing-safe rule', () => {
const src = read('server/lib/canary.js');
// Same helper as the login throttle: X-Forwarded-For only as far as
// HTTP_FORWARDED_COUNT says to trust it, or an attacker writes somebody
// else's address into the security log by sending a header.
assert.ok(/resolveClientKey/.test(src));
assert.ok(/forwardedCount: process\.env\.HTTP_FORWARDED_COUNT/.test(src));
});
console.log(`\n${passed} tests passed`);
+213
View File
@@ -0,0 +1,213 @@
'use strict';
// ParentBleed — GHSA-jvv9-498p-hxrg, "Cross-board card parentId causes the board
// publication to over-publish private ancestor cards" (Moderate, CWE-200 /
// CWE-862), reported by Alpastx. https://wekan.fi/hall-of-fame/parentbleed/
//
// Also guards BoardBleed's cross-board move deny (see the bottom of this file).
//
// A card's `parentId` may name a card on ANOTHER board, and setting it was
// authorized only against the CHILD board's write ACL. The board publication
// then walked the whole ancestor chain (it renders a subtask's full path, #3453)
// and published the complete ancestor card documents to every subscriber of the
// child board — with no check that any of them may see the parent's board. So
// anyone who could write on board B and knew a card id on private board A could
// bridge A's cards, descriptions and custom fields into B's DDP feed.
//
// Fixed at both ends: the WRITE refuses a parent whose board the actor cannot
// see (REST create, REST update, and the DDP deny rule), and the PUBLICATION
// sends only the ancestors whose board the subscriber may see.
//
// Run: node tests/crossBoardParentCardLeak.test.cjs
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
const read = rel => fs.readFileSync(path.join(repoRoot, rel), 'utf8');
const publication = read('server/publications/boards.js');
const cardPermissions = read('server/permissions/cards.js');
const restCards = read('server/models/cards.js');
const visibleBoards = read('server/lib/visibleBoardIds.js');
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
// --------------------------------------------------------- the publish side
test('the ancestor cursor filters by the boards the subscriber may see', () => {
const cursor = publication.match(/\/\/ Parent cards \(for subtasks\)[\s\S]*?\n \},/);
assert.ok(cursor, 'the parent-cards child cursor is found');
const body = cursor[0];
assert.ok(/visibleBoardIds\(thisUserId, ancestorBoardIds\)/.test(body),
'it asks which ancestor boards this subscriber may see');
assert.ok(/allowedBoardIds\.add\(board\._id\)/.test(body),
"the board being published is its own answer — its cards need no second decision");
assert.ok(/filter\(c => allowedBoardIds\.has\(c\.boardId\)\)/.test(body),
'ancestors on other boards are filtered out');
});
test('negative: the unfiltered publish of every ancestor is gone', () => {
const cursor = publication.match(/\/\/ Parent cards \(for subtasks\)[\s\S]*?\n \},/)[0];
assert.ok(
!/getCards\(\{ _id: \{ \$in: ancestorIds \} \}, \{\}, true\)/.test(cursor),
'publishing the raw ancestorIds is exactly what leaked the private cards',
);
// The full documents that DO go out are the filtered set.
assert.ok(/getCards\(\{ _id: \{ \$in: allowedAncestorIds \} \}, \{\}, true\)/.test(cursor));
});
test('an empty result publishes nothing rather than falling through', () => {
const cursor = publication.match(/\/\/ Parent cards \(for subtasks\)[\s\S]*?\n \},/)[0];
assert.ok(/if \(ancestorIds\.length === 0\) return null;/.test(cursor));
assert.ok(/if \(allowedAncestorIds\.length === 0\) return null;/.test(cursor));
});
// ----------------------------------------------------------- the write side
test('the visibility question is asked with the shared board selectors', () => {
assert.ok(/boardVisibilitySelectors\(\{/.test(visibleBoards),
'visibleBoardIds uses the same selectors as the publication and All Boards');
assert.ok(/isActive/.test(read('models/lib/boardVisibilitySelectors.js')),
'which means a revoked share does not grant visibility here either');
});
test('a parent card that does not exist is refused, not silently accepted', () => {
assert.ok(/if \(!parent\) \{[\s\S]*?NotFound[\s\S]*?statusCode = 404/.test(visibleBoards));
assert.ok(/if \(!parent\) return false;/.test(cardPermissions),
'the deny rule treats a missing parent as not visible');
});
test('an invisible parent board is a 403, not a 500', () => {
assert.ok(/Forbidden/.test(visibleBoards));
assert.ok(/statusCode = 403/.test(visibleBoards));
});
test('the DDP deny rule refuses a parentId on an invisible board', () => {
assert.ok(/export async function denyInvisibleParentCard/.test(cardPermissions));
assert.ok(/denyInvisibleParentCard\(userId, modifier\)/.test(cardPermissions),
'the update deny rule calls it');
assert.ok(/canUserSeeParentCard\(userId, doc\.parentId\)/.test(cardPermissions),
'and insert is covered too — a card can be created with a parent already set');
});
test('negative: clearing or omitting a parent is still allowed', () => {
const deny = cardPermissions.match(/export async function denyInvisibleParentCard[\s\S]*?\n\}/)[0];
assert.ok(/if \(typeof parentId !== 'string' \|\| !parentId\) return false;/.test(deny),
'no parentId in the modifier means nothing to refuse');
assert.ok(/if \(!set\) return false;/.test(deny), 'a modifier without $set is not a parent change');
});
test('negative: BoardBleed\'s cross-board MOVE deny is still enforced', () => {
// BoardBleed (CVE-2026-55234, GHSA-gm7v-pc38-53jr) is the same rule one field
// over: the allow rule checked write access on the card's SOURCE board only,
// so a client could $set a new boardId and inject content into a private board
// it cannot even read. Adding the parentId check must not have displaced it -
// and it covered Cards, Lists and Swimlanes alike.
assert.ok(/denyCrossBoardMove\(userId, modifier\)/.test(cardPermissions),
'Cards keeps its BoardBleed deny');
['lists', 'swimlanes'].forEach(collection => {
const src = read(`server/permissions/${collection}.js`);
assert.ok(/\.deny\(\{/.test(src), `${collection} keeps its BoardBleed deny rule`);
assert.ok(/denyCrossBoardMove/.test(src), `${collection} still calls the shared move guard`);
});
});
test('both REST paths that set a parent check it first', () => {
assert.ok(/assertParentCardIsVisible\(req\.userId, paramParentId\)/.test(restCards),
'card create');
assert.ok(/assertParentCardIsVisible\(req\.userId, req\.body\.parentId\)/.test(restCards),
'card update — the PUT the advisory used');
// The check must come BEFORE the write it guards.
const put = restCards.match(/if \(req\.body\.parentId\) \{[\s\S]*?updated = true;\n \}/)[0];
const checkAt = put.indexOf('assertParentCardIsVisible');
const writeAt = put.indexOf('updateAsync');
assert.ok(checkAt > -1 && writeAt > -1 && checkAt < writeAt,
'the parent is validated before the update runs');
});
test('the linked-card read check it was modelled on is still there', () => {
// The advisory pointed at this as the precedent: linking across boards
// already required read access to the source card's board.
assert.ok(/checkBoardAccess\(req\.userId, sourceCard\.boardId\)/.test(restCards));
});
// ------------------------------------------------- the same hole, five cursors
//
// The advisory named the ancestor cursor. The LINKED-CARD cursors beside it had
// the identical bug and a wider blast radius: they published the linked card,
// its comments, its attachments, its checklists AND its checklist items, from
// whatever board the linked card lives on, to every subscriber of this board.
// A `cardType-linkedCard` names a card by id exactly as `parentId` does.
test('all five linked-card cursors filter by the subscriber\'s visible boards', () => {
const calls = publication.match(/const linkedCardIds = await visibleLinkedCardIds\(board\);/g) || [];
assert.strictEqual(calls.length, 5,
'linked cards, comments, attachments, checklists and checklist items — all five');
});
test('the shared helper asks the same visibility question as the ancestor cursor', () => {
const helper = publication.match(/const visibleLinkedCardIds = async board => \{[\s\S]*?\n \};/);
assert.ok(helper, 'the helper exists');
const body = helper[0];
assert.ok(/visibleBoardIds\(thisUserId, linkedBoardIds\)/.test(body));
assert.ok(/allowedBoardIds\.add\(board\._id\)/.test(body),
'cards on THIS board are their own answer');
assert.ok(/filter\(c => allowedBoardIds\.has\(c\.boardId\)\)/.test(body));
});
test('negative: no cursor publishes the raw linkedId list any more', () => {
// This is the shape that leaked: ids straight off this board's cards, with
// nothing asked about the boards they point at.
assert.ok(
!/getCards\(\{ _id: \{ \$in: linkedCardIds \}, archived: isArchived \}, \{\}, true\)[\s\S]{0,80}?linkedCardIds = cards/.test(publication),
'no cursor may build linkedCardIds itself and publish them unfiltered',
);
const selfBuilt = publication.match(/const linkedCardIds = cards\.filter\(/g) || [];
assert.strictEqual(selfBuilt.length, 0,
'the five duplicated preambles are gone; there is one helper and it checks');
});
test('the assigned-only member restriction survived the de-duplication', () => {
// Members marked isNormalAssignedOnly / isCommentAssignedOnly /
// isReadAssignedOnly only see cards they are assigned to. That narrowing used
// to be repeated in each cursor; it must still be applied inside the helper.
const helper = publication.match(/const visibleLinkedCardIds = async board => \{[\s\S]*?\n \};/)[0];
assert.ok(/isNormalAssignedOnly \|\| member\.isCommentAssignedOnly \|\| member\.isReadAssignedOnly/.test(helper));
assert.ok(/cardSelector\.assignees = \{ \$in: \[thisUserId\] \}/.test(helper));
});
test('the helper is NOT cached across a cursor re-run', () => {
// It was, briefly. publishComposite re-runs a child's find() when the parent
// document changes, so a cache living for the whole subscription serves the
// ids computed the FIRST time forever: a linked card added later would never
// be published, and one removed would go on being published. The five cursors
// each ran these queries before the helper existed, so computing per call is
// the cost they always had - and it is correct.
assert.ok(!/_linkedIdsByBoard/.test(publication),
'no subscription-lifetime cache of the linked card ids');
const helper = publication.match(/const visibleLinkedCardIds = async board => \{[\s\S]*?\n \};/)[0];
assert.ok(/NOT memoized/.test(helper), 'and the reason is written where the next reader is');
});
// ------------------------------------------- no hand-written visibility copies
test('every board-visibility selector in the publication comes from the builder', () => {
// RevokeBleed was one hand-written copy disagreeing with another. There were
// three more copies in this file, all correct at the time — and that is
// exactly the state the broken one was in before it drifted.
const copies = publication.match(/\{ orgs: \{ \$elemMatch: \{ orgId: \{ \$in: user\.orgIds\(\) \}/g) || [];
assert.strictEqual(copies.length, 0, 'no hand-written visibility array remains');
const builders = publication.match(/boardVisibilitySelectors\(\{/g) || [];
assert.ok(builders.length >= 4, `every selector uses the builder, found ${builders.length}`);
});
console.log(`\n${passed} tests passed`);
+5
View File
@@ -1,5 +1,10 @@
'use strict';
// ExportBleed - https://wekan.fi/hall-of-fame/exportbleed/
// Named for tests/securityRegressionCoverage.test.cjs, which checks the published
// Hall of Fame list against the tests that guard it - a test that does not say
// which vulnerability it belongs to cannot be checked against that list.
// Regression for GHSA-8r5p-4q9j-f5jx: Stored XSS in HTML board exports through a
// card-title second parse.
//
+6 -1
View File
@@ -1,6 +1,11 @@
'use strict';
// GHSA-3gcg-g6rf-w2rx — a bad authToken crashed the server.
// CrashBleed — CVE-2026-68901, GHSA-3gcg-g6rf-w2rx (CWE-476): a bad authToken
// crashed the server. https://wekan.fi/hall-of-fame/crashbleed/
//
// The name matters as much as the id: tests/securityRegressionCoverage.test.cjs
// checks the published Hall of Fame list against the tests that guard it, and it
// can only do that for a test that says which vulnerability it belongs to.
//
// The board export REST endpoints look a user up by the login token in
// `?authToken=`. A token that matches nothing makes that lookup answer `undefined`,
+360
View File
@@ -0,0 +1,360 @@
'use strict';
// Filesystem storage integrity, crashes and downtime
// (design: docs/Security/Remediation/WeKan.md §13).
//
// WeKan's attachments and avatars are files under WRITABLE_PATH, and nothing
// checked that they are still the files WeKan stored. A daily, CPU-gated, paced
// scan now records name, size, modification time and md5/sha256/sha512 per file,
// signs that record with ed25519, and re-checks it.
//
// The finding an admin is warned about is a change WITH NO RECORD SAYING WHY -
// files change when people use WeKan, and those changes are accounted for. So
// most of this suite is about NOT crying wolf: an ordinary edit, a first sight
// of a file, a busy machine, a run that ran out of time.
//
// Run: node tests/fileIntegrity.test.cjs
const assert = require('assert');
const crypto = require('crypto');
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
const read = rel => fs.readFileSync(path.join(repoRoot, rel), 'utf8');
const {
DIGESTS,
FINDINGS,
PACING,
manifestLine,
compareEntry,
classifyChange,
nextStep,
isDue,
} = require('../models/lib/fileIntegrity');
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
const digestsOf = text => {
const out = {};
DIGESTS.forEach(name => {
out[name] = crypto.createHash(name).update(text).digest('hex');
});
return out;
};
const entry = (text, over = {}) => ({
path: '/data/files/attachments/a1.png',
size: Buffer.byteLength(text),
mtimeMs: 1_700_000_000_000,
digests: digestsOf(text),
...over,
});
// ------------------------------------------------- all three hashes, checked
test('all three digests are kept, and md5 is never the only one', () => {
assert.deepStrictEqual(DIGESTS, ['md5', 'sha256', 'sha512']);
assert.ok(DIGESTS.length >= 3, 'md5 alone is not collision resistant');
});
test('an unchanged file produces no finding at all', () => {
const before = entry('the original bytes');
assert.deepStrictEqual(compareEntry(before, { ...before }), []);
});
test('THE POINT: replaced contents are caught by every digest', () => {
const before = entry('the original bytes');
const after = entry('something else entirely');
const findings = compareEntry(before, after);
const kinds = findings.map(f => f.finding);
assert.ok(kinds.includes(FINDINGS.CONTENT), 'the content change is reported');
assert.strictEqual(findings.find(f => f.finding === FINDINGS.CONTENT).severity, 'high');
});
test('a file kept the SAME SIZE but changed is still caught', () => {
// The case a size check alone misses, which is why digests are the primary
// test and size is a secondary note.
const before = entry('aaaa');
const after = entry('bbbb');
assert.strictEqual(before.size, after.size);
assert.ok(compareEntry(before, after).some(f => f.finding === FINDINGS.CONTENT));
});
test('digests DISAGREEING is worse than digests changing', () => {
// Two digests over the same bytes cannot disagree. If they do, the bytes were
// not read the same way twice - or somebody updated one hash and not the rest.
const before = entry('the original bytes');
const after = entry('the original bytes');
after.digests.sha256 = crypto.createHash('sha256').update('tampered').digest('hex');
const findings = compareEntry(before, after);
const disagree = findings.find(f => f.finding === FINDINGS.DIGEST_DISAGREE);
assert.ok(disagree, 'a partial digest change is its own finding');
assert.strictEqual(disagree.severity, 'critical');
});
test('a missing file, and a file WeKan never stored', () => {
const before = entry('x');
assert.strictEqual(compareEntry(before, null)[0].finding, FINDINGS.MISSING);
assert.strictEqual(compareEntry(null, before)[0].finding, FINDINGS.UNTRACKED);
// An untracked file is a low note: a leftover, a backup copy, an operator's
// scratch file. It is not evidence of anything on its own.
assert.strictEqual(compareEntry(null, before)[0].severity, 'low');
});
test('the modification time is checked, and moving BACKWARDS is worse', () => {
const before = entry('same bytes');
const forward = { ...before, mtimeMs: before.mtimeMs + 60_000 };
const backward = { ...before, mtimeMs: before.mtimeMs - 60_000 };
const f = compareEntry(before, forward).find(x => x.finding === FINDINGS.MTIME);
assert.strictEqual(f.severity, 'low', 'a copy or a restore moves it forward');
const b = compareEntry(before, backward).find(x => x.finding === FINDINGS.MTIME);
assert.strictEqual(b.severity, 'medium', 'nothing moves a timestamp backwards by accident');
assert.ok(/BACKWARDS/.test(b.detail));
});
// --------------------------------------------------------- the ed25519 half
test('ed25519 signs the record, so the RECORD cannot be quietly rewritten', () => {
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519');
const original = entry('the original bytes');
const line = manifestLine(original);
const signature = crypto.sign(null, Buffer.from(line), privateKey);
assert.strictEqual(crypto.verify(null, Buffer.from(line), publicKey, signature), true);
// An attacker who replaces the file AND updates the stored hashes to match
// still cannot produce a signature for the new record.
const forged = entry('replaced bytes');
assert.strictEqual(
crypto.verify(null, Buffer.from(manifestLine(forged)), publicKey, signature),
false,
);
});
test('an invalid signature is a critical finding on its own', () => {
const before = entry('x');
const findings = compareEntry(before, { ...before }, { signatureValid: false });
assert.strictEqual(findings[0].finding, FINDINGS.SIGNATURE);
assert.strictEqual(findings[0].severity, 'critical');
});
test('the signed line is canonical: same input, same bytes, every time', () => {
const a = entry('x');
// Key order must not matter - it is built field by field, not from JSON.
const b = { digests: a.digests, mtimeMs: a.mtimeMs, size: a.size, path: a.path };
assert.strictEqual(manifestLine(a), manifestLine(b));
assert.ok(manifestLine(a).includes('md5='));
assert.ok(manifestLine(a).includes('sha512='));
assert.ok(!manifestLine(a).includes('{'), 'not JSON, whose escaping is not guaranteed stable');
});
test('negative: a missing field does not change the line\'s SHAPE', () => {
const line = manifestLine({});
assert.strictEqual(line.split('\n').length, 3 + DIGESTS.length);
assert.doesNotThrow(() => manifestLine(null));
assert.doesNotThrow(() => manifestLine(undefined));
});
// -------------------------------------------- explained vs not explained
test('THE WARNING: a change WeKan has no record of is the finding', () => {
const findings = compareEntry(entry('a'), entry('b'));
const verdict = classifyChange(findings, false);
assert.strictEqual(verdict.explained, false);
assert.ok(/no record saying why/.test(verdict.summary));
assert.strictEqual(verdict.severity, 'high');
});
test('negative: a change WeKan DID record is not a warning', () => {
// Somebody replaced an attachment through WeKan. The file changed, and that
// is exactly what should have happened.
const verdict = classifyChange(compareEntry(entry('a'), entry('b')), true);
assert.strictEqual(verdict.explained, true);
assert.strictEqual(verdict.severity, 'info');
});
test('negative: an unchanged file is never a warning', () => {
assert.deepStrictEqual(classifyChange([], false),
{ explained: true, severity: 'info', summary: 'unchanged' });
assert.strictEqual(classifyChange(null, false).explained, true);
});
test('a rewritten RECORD is never explained away by an ordinary edit', () => {
const findings = compareEntry(entry('a'), entry('a'), { signatureValid: false });
const verdict = classifyChange(findings, true);
assert.strictEqual(verdict.explained, false,
'WeKan does not rewrite a baseline without re-signing it');
assert.strictEqual(verdict.severity, 'critical');
});
test('an unexplained change is at least medium, even when each part is minor', () => {
const before = entry('same');
const after = { ...before, mtimeMs: before.mtimeMs + 1000 };
const verdict = classifyChange(compareEntry(before, after), false);
assert.strictEqual(verdict.severity, 'medium',
'the point is that nobody knows why it happened');
});
// ---------------------------------------------------------------- the pacing
test('the scan does not run while the machine is busy', () => {
const step = nextStep({ cpuPercent: 85, elapsedMs: 0, fileSize: 1000 });
assert.strictEqual(step.action, 'stop');
assert.ok(/CPU at 85%/.test(step.reason));
});
test('...and it does run when the machine is idle', () => {
const step = nextStep({ cpuPercent: 5, elapsedMs: 0, fileSize: 1000 });
assert.strictEqual(step.action, 'pause');
});
test('there is a pause between files, and a bigger one for a bigger file', () => {
const small = nextStep({ cpuPercent: 5, elapsedMs: 0, fileSize: 1024 });
const large = nextStep({ cpuPercent: 5, elapsedMs: 0, fileSize: 100 * 1024 * 1024 });
assert.ok(small.pauseMs >= PACING.pauseMsPerFile, 'never a tight read loop');
assert.ok(large.pauseMs > small.pauseMs, 'a directory of large files is not a sustained read');
});
test('a run that has used its time stops and continues another day', () => {
const step = nextStep({ cpuPercent: 5, elapsedMs: PACING.maxRunMs + 1, fileSize: 1 });
assert.strictEqual(step.action, 'stop');
assert.ok(/continuing next time/.test(step.reason));
});
test('it runs once a day, not on every restart', () => {
const day = PACING.intervalMs;
assert.strictEqual(isDue(0, Date.now()), true, 'a server that never ran one runs it');
assert.strictEqual(isDue(1000, 1000 + day), true);
assert.strictEqual(isDue(1000, 1000 + day - 1), false);
assert.strictEqual(isDue(1000, 1000 + 60_000), false, 'not on a restart loop');
});
test('negative: junk pacing input never turns into "go as fast as you can"', () => {
assert.strictEqual(nextStep({}).action, 'pause', 'an unreadable CPU figure still paces');
assert.ok(nextStep({}).pauseMs >= PACING.pauseMsPerFile);
assert.strictEqual(isDue(null, null), false, 'an unreadable clock does not start a scan');
assert.strictEqual(isDue('x', 'y'), false);
});
test('the shipped pacing is conservative', () => {
assert.ok(PACING.maxCpuPercent <= 80);
assert.ok(PACING.pauseMsPerFile >= 10);
assert.ok(PACING.maxRunMs <= 60 * 60 * 1000, 'a scan may not run for an hour');
assert.strictEqual(PACING.intervalMs, 24 * 60 * 60 * 1000, 'once a day, as specified');
});
// ------------------------------------------------- crashes and downtime
test('a clean stop is not a problem, and is not reported as one', () => {
const { classifyPreviousRun } = requireUptimeHelper();
const now = Date.now();
const verdict = classifyPreviousRun({ at: new Date(now - 60_000), cleanShutdown: true }, now);
assert.strictEqual(verdict.kind, 'clean');
});
test('a first run is not a problem either', () => {
const { classifyPreviousRun } = requireUptimeHelper();
assert.strictEqual(classifyPreviousRun(null, Date.now()).kind, 'first-run');
assert.strictEqual(classifyPreviousRun({}, Date.now()).kind, 'first-run');
});
test('THE CRASH: a long gap with no clean-shutdown mark', () => {
const { classifyPreviousRun } = requireUptimeHelper();
const now = Date.now();
const verdict = classifyPreviousRun({ at: new Date(now - 45 * 60_000) }, now);
assert.strictEqual(verdict.kind, 'crash');
assert.ok(/WITHOUT SHUTTING DOWN CLEANLY/.test(verdict.detail));
assert.ok(/45 minute/.test(verdict.detail), 'and says how long it was down');
});
test('negative: a quick restart is a note, not an alarm', () => {
// The shutdown hook may simply not have run. Calling every deploy a crash is
// how a Problems page gets ignored.
const { classifyPreviousRun } = requireUptimeHelper();
const now = Date.now();
assert.strictEqual(classifyPreviousRun({ at: new Date(now - 30_000) }, now).kind, 'restart');
});
// ------------------------------------------------------------- the wiring
test('the baseline is never published, and clients cannot write it', () => {
const src = read('models/fileIntegrity.js');
// It is a map of every file on the server, and the key document is a PRIVATE
// KEY. Neither belongs on a client, admin or not.
assert.ok(!/Meteor\.publish/.test(src), 'nothing publishes the baseline');
assert.ok(/FileIntegrity\.deny\(/.test(src) && /IntegrityKeys\.deny\(/.test(src));
assert.ok((src.match(/insert: \(\) => true/g) || []).length === 2, 'both collections deny writes');
});
test('the scan reads each file ONCE for all three digests', () => {
const src = read('server/lib/fileIntegrityScan.js');
assert.ok(/DIGESTS\.forEach\(name => hashes\[name\]\.update\(chunk\)\)/.test(src),
'three passes would be three times the disk read for the same bytes');
assert.ok(/createReadStream/.test(src), 'streamed, not read into memory');
});
test('a first sight of a file records a baseline instead of warning', () => {
const src = read('server/lib/fileIntegrityScan.js');
assert.ok(/if \(!baseline\) \{[\s\S]{0,400}?storeBaseline/.test(src),
'a new instance must not report every file it has');
});
test('an EXPLAINED change is re-baselined; an unexplained one is not', () => {
const src = read('server/lib/fileIntegrityScan.js');
assert.ok(/if \(verdict\.explained\) await storeBaseline/.test(src),
'so it is reported once, not every day');
assert.ok(/left, so it keeps showing/.test(src) || /deliberately left/.test(src),
'and an unexplained one keeps showing until somebody looks');
});
test('a run that stopped early does not report the rest as missing', () => {
const src = read('server/lib/fileIntegrityScan.js');
assert.ok(/if \(!stoppedEarly\) \{/.test(src),
'it has not looked everywhere, and saying otherwise would be a lie');
});
test('an operator-supplied key is never written to the database', () => {
const src = read('server/lib/fileIntegrityScan.js');
const branch = src.match(/const supplied = process\.env\.WEKAN_INTEGRITY_PRIVATE_KEY;[\s\S]*?\n \}/)[0];
assert.ok(!/upsertAsync/.test(branch), 'a supplied key must not be stored');
// A malformed supplied key must NOT silently fall back to a generated one:
// that looks like it is working while checking nothing the operator meant.
assert.ok(/could not be read as a private key/.test(branch));
assert.ok(/return null;/.test(branch));
});
test('the integrity stream is registered everywhere it has to be', () => {
assert.ok(/'integrity'\]/.test(read('models/eventLog.js')), 'the stream list');
assert.ok(/report-integrity/.test(read('client/components/settings/adminReports.js')), 'the menu');
assert.ok(/showIntegrity/.test(read('client/components/settings/adminReports.jade')), 'the pane');
assert.ok(require('../imports/i18n/data/en.i18n.json').integrityReportTitle, 'a label');
const imports = read('server/imports.js');
assert.ok(/fileIntegrityScan/.test(imports) && /uptimeWatch/.test(imports),
'and both modules are actually loaded, or none of this runs');
});
function requireUptimeHelper() {
// uptimeWatch.js is an ES module full of Meteor imports; its decision function
// is self-contained, so the guard evaluates that one function out of the
// source - which also fails if it is renamed or reshaped.
const src = read('server/lib/uptimeWatch.js');
const consts = [
src.match(/const HEARTBEAT_MS = [^;]+;/)[0],
src.match(/const DOWNTIME_THRESHOLD_MS = [^;]+;/)[0],
].join('\n');
const fn = src.match(/export function classifyPreviousRun\(previous, nowMs\) \{[\s\S]*?\n\}/)[0]
.replace('export function', 'function');
// eslint-disable-next-line no-new-func
return new Function(`${consts}\n${fn}\nreturn { classifyPreviousRun };`)();
}
console.log(`\n${passed} tests passed`);
+189
View File
@@ -0,0 +1,189 @@
'use strict';
// NoSQL injection detection, and the canaries that report an attempt
// (design: docs/Security/Remediation/WeKan.md §12.6).
//
// Two shapes, and they are different problems:
//
// EXECUTION - a client-supplied selector carrying $where / $function /
// $accumulator / $out / $merge. A selector is data; these turn it
// into code the DATABASE runs.
// OPERATOR - {"$ne": null} or {"$gt": ""} where the string a user typed
// belongs. This is how "match every row" is spelled in a document
// database, and it needs no JavaScript at all.
//
// The hard part of a detector like this is NOT catching the attacks. It is not
// firing on ordinary traffic: a security report that cries wolf on every search
// is a report nobody reads, so most of what follows is the negative half.
//
// Run: node tests/injectionDetect.test.cjs
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
const read = rel => fs.readFileSync(path.join(repoRoot, rel), 'utf8');
const {
executionOperatorsIn,
isOperatorObject,
classifyScalarParam,
classifySelector,
injectionDetail,
} = require('../models/lib/injectionDetect');
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
// ------------------------------------------------------------- the attacks
test('server-side JavaScript is found wherever it hides', () => {
assert.deepStrictEqual(executionOperatorsIn({ $where: 'this.x' }), ['$where']);
assert.deepStrictEqual(executionOperatorsIn({ a: { b: { $where: '1' } } }), ['$where']);
assert.deepStrictEqual(executionOperatorsIn({ $or: [{ x: 1 }, { $where: '1' }] }), ['$where']);
assert.deepStrictEqual(
executionOperatorsIn({ $expr: { $function: { body: 'f' } } }),
['$function'],
);
assert.deepStrictEqual(executionOperatorsIn({ $accumulator: {} }), ['$accumulator']);
});
test('an aggregation that writes somewhere is found too', () => {
assert.deepStrictEqual(executionOperatorsIn([{ $match: {} }, { $out: 'stolen' }]), ['$out']);
assert.deepStrictEqual(executionOperatorsIn([{ $merge: { into: 'x' } }]), ['$merge']);
});
test('THE CLASSIC: an operator object where a value was expected', () => {
// The login-bypass shape, and its relatives.
[{ $ne: null }, { $gt: '' }, { $regex: '.*' }, { $exists: true }, { $in: ['a', 'b'] }]
.forEach(payload => {
const verdict = classifyScalarParam(payload);
assert.strictEqual(verdict.injection, true, JSON.stringify(payload));
assert.strictEqual(verdict.kind, 'operator');
assert.ok(verdict.operators.length >= 1);
});
});
test('an execution operator in a scalar position is reported as execution', () => {
const verdict = classifyScalarParam({ $where: 'true' });
assert.strictEqual(verdict.kind, 'execution', 'the worse of the two names the event');
});
test('a selector is only an attack when it can EXECUTE something', () => {
// Comparison operators are what a selector is made of - flagging them would
// flag every query WeKan itself builds.
assert.strictEqual(classifySelector({ boardId: 'b1', archived: false }).injection, false);
assert.strictEqual(classifySelector({ sort: { $gt: 5 } }).injection, false);
assert.strictEqual(classifySelector({ $or: [{ a: 1 }, { b: 2 }] }).injection, false);
assert.strictEqual(classifySelector({ $where: '1' }).injection, true);
});
// ------------------------------------------------------------ the negatives
test('negative: an ordinary typed value is never an injection', () => {
['hello', '', 'a title with $ in it', '$where', 42, 0, true, false, null, undefined]
.forEach(v => assert.strictEqual(classifyScalarParam(v).injection, false, String(v)));
});
test('negative: a $ inside a STRING is text, not an operator', () => {
// A card titled "$100 refund" must not put anybody in the security report.
assert.strictEqual(classifyScalarParam('$100 refund').injection, false);
assert.strictEqual(classifySelector({ title: '$where' }).injection, false);
assert.strictEqual(classifySelector({ title: { $regex: '\\$ne' } }).injection, false);
});
test('negative: a plain object is a type error, not an attack', () => {
// Somebody sending the wrong shape is a validation problem. Calling it an
// attack would fill the report with noise and hide the real ones.
assert.strictEqual(isOperatorObject({ title: 'x' }), false);
assert.strictEqual(isOperatorObject({ $ne: null, title: 'x' }), false,
'EVERY key must be an operator, or it is somebody sending a document');
assert.strictEqual(isOperatorObject({}), false);
assert.strictEqual(isOperatorObject([]), false);
assert.strictEqual(isOperatorObject([{ $ne: 1 }]), false);
assert.strictEqual(isOperatorObject(null), false);
assert.strictEqual(isOperatorObject('$ne'), false);
});
test('negative: the walk is depth-bounded, so a nested payload cannot burn CPU', () => {
let deep = { $where: 'x' };
for (let i = 0; i < 200; i++) deep = { a: deep };
const started = Date.now();
const found = executionOperatorsIn(deep);
assert.ok(Date.now() - started < 500, 'must not walk an arbitrarily deep tree');
assert.deepStrictEqual(found, [], 'and below the limit it simply reports nothing');
});
test('negative: cycles and exotic values do not throw', () => {
const cyclic = { a: 1 };
cyclic.self = cyclic;
assert.doesNotThrow(() => executionOperatorsIn(cyclic));
assert.doesNotThrow(() => classifyScalarParam(new Date()));
assert.doesNotThrow(() => classifySelector(Symbol('x')));
});
// -------------------------------------------------------------- the report
test('the detail names the operators and nothing else', () => {
const detail = injectionDetail(classifyScalarParam({ $ne: null }), 'username');
assert.ok(detail.includes('$ne'));
assert.ok(detail.includes('username'));
// The VALUE is attacker-controlled and has no business in the log.
assert.ok(!detail.includes('null') || detail.indexOf('null') > detail.indexOf('$ne'));
assert.ok(detail.length < 160);
});
// ---------------------------------------------------------------- the wiring
test('the windowed card publication trips a canary on an injected selector', () => {
const src = read('server/publications/cardsWindow.js');
assert.ok(/function selectorIsInjection\(selector, where\)/.test(src));
assert.ok(/tripCanary\('injection\.nosql-selector'/.test(src));
// Both refusal sites go through it.
const uses = src.match(/selectorIsInjection\(cardSelector, '/g) || [];
assert.strictEqual(uses.length, 2, 'the window AND the count');
});
test('SILENT: the publication still answers exactly as before', () => {
const src = read('server/publications/cardsWindow.js');
// An injected selector still becomes the empty result / a ready() - the
// caller cannot tell the canary is there.
assert.ok(/\? \{ _id: \{ \$in: \[\] \} \}/.test(src));
assert.ok(/if \(!board \|\| selectorIsInjection\(cardSelector, 'boardCardsCount'\)\) \{\s*\n\s*return this\.ready\(\);/.test(src));
assert.ok(/return true;/.test(src.match(/function selectorIsInjection[\s\S]*?\n\}/)[0]),
'and it returns the same boolean hasWhere() did');
});
test('the old $where-only check is still honoured, not replaced', () => {
const src = read('server/publications/cardsWindow.js');
assert.ok(/hasWhere\(selector\)/.test(src),
'the narrower existing guard stays: a widened detector must not lose a case');
});
// ----------------------------------------------- SQL: the database reports it
test('the SQL guard marks its refusal so the attempt reaches the admin', () => {
const go = path.join(repoRoot, '.tools/FerretDB/internal/util/sqlguard/sqlguard.go');
if (!fs.existsSync(go)) {
console.log(' -- .tools/FerretDB not cloned; skipping the Go side');
return;
}
const src = fs.readFileSync(go, 'utf8');
assert.ok(/canary:db\.sql-injection/.test(src),
'a refused statement must carry the marker, or nobody is told');
assert.ok(!/wekan/i.test(src), 'no application name in a FerretDB source file');
});
test('WeKan turns that marker into its own canary, not a database problem', () => {
const src = read('server/lib/databaseProblems.js');
assert.ok(/'db\.sql-injection':/.test(src), 'the id is known');
assert.ok(/id === 'db\.sql-injection' \? 'injection\.sql-statement'/.test(src),
'and gets its own canary id, so the report distinguishes it at a glance');
});
console.log(`\n${passed} tests passed`);
+5
View File
@@ -1,5 +1,10 @@
'use strict';
// LockoutBleed - https://wekan.fi/hall-of-fame/lockoutbleed/ (the REST throttle)
// Named for tests/securityRegressionCoverage.test.cjs, which checks the published
// Hall of Fame list against the tests that guard it - a test that does not say
// which vulnerability it belongs to cannot be checked against that list.
// Plain-Node unit test (no Meteor) for the REST /users/login brute-force
// throttle. Run: node tests/loginAttemptThrottle.test.cjs
//
@@ -1,5 +1,10 @@
'use strict';
// LockoutBleed - https://wekan.fi/hall-of-fame/lockoutbleed/ (the wiring)
// Named for tests/securityRegressionCoverage.test.cjs, which checks the published
// Hall of Fame list against the tests that guard it - a test that does not say
// which vulnerability it belongs to cannot be checked against that list.
// Plain-Node source guard for GHSA-2g94-9x3m-hv37. The behaviour is unit-tested
// in loginFailureDecision / loginTimingDefense / loginAttemptThrottle; this
// suite pins the WIRING that connects those helpers to the real login paths, so
+5
View File
@@ -1,5 +1,10 @@
'use strict';
// LockoutBleed - https://wekan.fi/hall-of-fame/lockoutbleed/ (the lockout half)
// Named for tests/securityRegressionCoverage.test.cjs, which checks the published
// Hall of Fame list against the tests that guard it - a test that does not say
// which vulnerability it belongs to cannot be checked against that list.
// Plain-Node unit test (no Meteor) for the accounts-lockout failure decision.
// Run: node tests/loginFailureDecision.test.cjs
//
+5
View File
@@ -1,5 +1,10 @@
'use strict';
// LockoutBleed - https://wekan.fi/hall-of-fame/lockoutbleed/ (the timing half)
// Named for tests/securityRegressionCoverage.test.cjs, which checks the published
// Hall of Fame list against the tests that guard it - a test that does not say
// which vulnerability it belongs to cannot be checked against that list.
// Plain-Node unit test (no Meteor) for the login timing side-channel defence.
// Run: node tests/loginTimingDefense.test.cjs
//
+4
View File
@@ -1,5 +1,9 @@
'use strict';
// Guards the Hall of Fame vulnerabilities IdentityBleed and PatternBleed, which
// are the same CodeQL finding twice - naming them here is what lets
// tests/securityRegressionCoverage.test.cjs confirm they still have a test.
//
// A string replacement that replaces something with itself does nothing, and it
// is almost never written on purpose - GitHub CodeQL reports it as
// js/identity-replacement (CWE-116), tagged both correctness and security.
+12 -12
View File
@@ -9,7 +9,7 @@
"mongodb": "^7.5.0"
},
"devDependencies": {
"@playwright/test": "^1.62.0"
"@playwright/test": "^1.62.1"
}
},
"node_modules/@mongodb-js/saslprep": {
@@ -22,13 +22,13 @@
}
},
"node_modules/@playwright/test": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz",
"integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==",
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.1.tgz",
"integrity": "sha512-DTcUc8qii+cpHvtOwggMtBRMjKZHXYWdw8syRYu2vtzuq4Wxphqq4NfCs5Zt44L6mA8rfDfj+PHnxFc/FeK6mQ==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.62.0"
"playwright": "1.62.1"
},
"bin": {
"playwright": "cli.js"
@@ -142,13 +142,13 @@
}
},
"node_modules/playwright": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz",
"integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==",
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.1.tgz",
"integrity": "sha512-0M+L3LAD8/nm554LOla9Ayx0j0tmFZ0FBcoQ7F1VuVHpM/XpiC8RcDzBQB8W5+hA8L22THxELzeF+2WcUzvcLg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.62.0"
"playwright-core": "1.62.1"
},
"bin": {
"playwright": "cli.js"
@@ -161,9 +161,9 @@
}
},
"node_modules/playwright-core": {
"version": "1.62.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz",
"integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
"version": "1.62.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.1.tgz",
"integrity": "sha512-wPYSwEBJY9GHraISXqyqtx0na0LpO3XEX7jNDhntbex7tzUS7kLnZsOlFruFJB4Hi/rhDMjXGqHewDZ68nYZVw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
+1 -1
View File
@@ -2,7 +2,7 @@
"name": "wekan-playwright-tests",
"private": true,
"devDependencies": {
"@playwright/test": "^1.62.0"
"@playwright/test": "^1.62.1"
},
"dependencies": {
"mongodb": "^7.5.0"
+153
View File
@@ -0,0 +1,153 @@
'use strict';
// Three round-trip counts that were linear in the input, and are not any more.
//
// None of these was a wrong answer - they were the right answer asked one
// document at a time. A query per item is invisible on a developer's board with
// four cards and is the whole cost of the request on a real one, because each
// await is a full round-trip that starts only when the last one finished.
//
// Run: node tests/queryBatching.test.cjs
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
const read = rel => fs.readFileSync(path.join(repoRoot, rel), 'utf8');
const cards = read('server/models/cards.js');
const search = read('server/publications/cards.js');
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
// ───────────────────────────── the bulk label endpoint (up to 500 cards) ─────
const bulkLabels = cards.match(
/\/\/ ONE read for every card, not one per card\.[\s\S]*?await Promise\.all\(writes\);/,
);
test('the bulk label endpoint reads every card in ONE query', () => {
assert.ok(bulkLabels, 'the batched block is there');
assert.ok(
/getCards\(\s*\{ _id: \{ \$in: cardIds \}, boardId: paramBoardId, archived: false \}/.test(bulkLabels[0]),
'one $in read for the whole batch',
);
assert.ok(/new Map\(\(cards \|\| \[\]\)\.map\(card => \[card\._id, card\]\)\)/.test(bulkLabels[0]),
'and the result is indexed, not searched per id');
});
test('negative: no per-card read remains in that handler', () => {
assert.ok(!/for \(const cardId of cardIds\) \{[\s\S]{0,200}?await ReactiveCache\.getCard\(/.test(bulkLabels[0]),
'a getCard inside the loop is the thing this replaced');
});
test('the writes are issued together, not one waiting for the last', () => {
// They genuinely differ per card - each merges its own labelIds - so they
// stay individual updates. What changed is that they no longer serialize.
assert.ok(/writes\.push\(\s*Cards\.direct\.updateAsync\(/.test(bulkLabels[0]));
assert.ok(/await Promise\.all\(writes\);/.test(bulkLabels[0]),
'and every one must land before the 200 says they did');
});
test('the response still preserves the caller\'s order and reports misses', () => {
// The loop iterates cardIds, NOT the query result: a batch read comes back in
// the database's order and without the ids that matched nothing, so iterating
// it would silently reorder `updated` and lose `notFound`.
assert.ok(/for \(const cardId of cardIds\) \{/.test(bulkLabels[0]));
assert.ok(/const card = cardById\.get\(cardId\);/.test(bulkLabels[0]));
assert.ok(/if \(!card\) \{\s*\n\s*notFound\.push\(cardId\);/.test(bulkLabels[0]));
});
test('the cap that bounds the batch is still there', () => {
assert.ok(/const BULK_CARDS_MAX = \d+;/.test(cards));
const max = Number(cards.match(/const BULK_CARDS_MAX = (\d+);/)[1]);
assert.ok(max > 0 && max <= 1000, `the cap is ${max}`);
});
test('negative: the bulk DELETE is deliberately NOT batched', () => {
// Its per-card work is real - cardRemover runs the sub-item hooks and each
// card gets its own activity - so batching the reads would save one query in
// a loop that does far more than query. Left alone on purpose.
const del = cards.match(/const deleted = \[\];[\s\S]*?for \(const cardId of cardIds\) \{[\s\S]*?await cardRemover\(/);
assert.ok(del, 'the delete handler still reads per card, and that is fine');
});
// ─────────────────────────────── global search: one lookup for every name ────
const batchBlock = search.match(
/\/\/ Resolve every username the query names in ONE lookup\.[\s\S]*?const resolvePredicates = key => \{[\s\S]*?\n \};/,
);
test('global search resolves every named username in ONE query', () => {
assert.ok(batchBlock, 'the batched block is there');
assert.ok(/getUsers\(\s*\{ username: \{ \$in: \[\.\.\.namedUsernames\] \} \}/.test(batchBlock[0]),
'one $in over every name the query mentions');
assert.ok(/fields: \{ _id: 1, username: 1 \}/.test(batchBlock[0]),
'and only the two fields it needs');
});
test('the names are collected across all four operators before asking', () => {
assert.ok(/for \(const key of \[OPERATOR_USER, OPERATOR_MEMBER, OPERATOR_ASSIGNEE, OPERATOR_CREATOR\]\)/
.test(batchBlock[0]));
assert.ok(/new Set\(\)/.test(batchBlock[0]),
'a name typed under two operators is one lookup, not two');
});
test('negative: no per-username query remains', () => {
assert.ok(!/for \(const username of queryParams\.getPredicates\([^)]*\)\) \{\s*\n\s*const user = await ReactiveCache\.getUser\(/.test(search),
'an awaited getUser per predicate is the thing this replaced');
});
test('an unknown name is still reported against the operator it was typed under', () => {
// This is what makes it worth keeping a per-operator step at all: "ann is not
// a user" is not useful if it does not say WHERE ann was typed.
assert.ok(/errors\.addNotFound\(key, username\)/.test(batchBlock[0]));
assert.ok(/resolvePredicates\(OPERATOR_USER\)/.test(search));
['OPERATOR_MEMBER', 'OPERATOR_ASSIGNEE', 'OPERATOR_CREATOR'].forEach(op => {
assert.ok(new RegExp(`\\[${'OPERATOR_MEMBER, OPERATOR_ASSIGNEE, OPERATOR_CREATOR'}\\]`).test(search),
`${op} still goes through the same resolver`);
});
});
test('the selectors built from the resolved ids are unchanged', () => {
// user: matches members OR assignees; the other three are their own field.
assert.ok(/\$or: \[\{ members: \{ \$in: users \} \}, \{ assignees: \{ \$in: users \} \}\]/.test(search));
assert.ok(/selector\[key\] = \{ \$in: users \};/.test(search));
});
// ─────────────────────────────────── FerretDB: the $or that never pushed down ─
test('FerretDB pushes a top-level $or down when every branch can be', () => {
const go = path.join(repoRoot, '.tools/FerretDB/internal/backends/sqlite/query.go');
if (!fs.existsSync(go)) {
console.log(' -- .tools/FerretDB not cloned; skipping the Go side');
return;
}
const src = fs.readFileSync(go, 'utf8');
assert.ok(/func pushdownOrCondition/.test(src));
assert.ok(/if k == "\$or" \{/.test(src), 'the top-level $or is no longer skipped outright');
// ALL OR NOTHING: an OR that drops a branch removes rows the Go filter never
// sees, which is the opposite of every other pushdown's superset contract.
assert.ok(/if !condOK \{\s*\n\s*return "", nil, false\s*\n\s*\}/.test(src),
'one unpushable branch must refuse the whole $or');
assert.ok(/\$and, another \$or/.test(src) || /nested operator inside a branch/.test(src),
'a nested operator branch is refused, not silently dropped');
});
test('negative: the other top-level operators still stay in Go', () => {
const go = path.join(repoRoot, '.tools/FerretDB/internal/backends/sqlite/query.go');
if (!fs.existsSync(go)) return;
const src = fs.readFileSync(go, 'utf8');
const block = src.match(/if strings\.HasPrefix\(k, "\$"\) \{[\s\S]*?continue\n\t\t\}/)[0];
assert.ok(/k == "\$or"/.test(block), 'only $or learned this');
assert.ok(!/\$and|\$nor|\$not/.test(block), 'nothing else was quietly included');
assert.ok(/continue/.test(block), 'and everything else still falls through to the Go filter');
});
console.log(`\n${passed} tests passed`);
+185
View File
@@ -0,0 +1,185 @@
'use strict';
// Being allowed to react is not being allowed to react AS SOMEBODY ELSE.
//
// Found while auditing for more of GHSA-pqr4-rxgp-hv2m (CommentBleed: the REST
// comment delete that checked board membership and nothing about WHOSE comment
// it was). A CardCommentReactions document holds
//
// { cardCommentId, reactions: [ { reactionCodepoint, userIds: [...] } ] }
//
// and the whole array is ONE field, whose allow rule was board membership for
// insert, update and remove alike. So any member could $set `reactions` to
// anything: add a colleague's userId to a reaction they never made, or delete
// one they did. `toggleReaction()` only ever touches Meteor.userId(), so no
// legitimate client sends anything else - the rule simply never said so.
//
// Integrity rather than confidentiality (reactions are visible to the whole
// board already), but it puts words in another person's mouth.
//
// Run: node tests/reactionOwnership.test.cjs
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
const read = rel => fs.readFileSync(path.join(repoRoot, rel), 'utf8');
const {
changesOnlyOwnReactions,
denyForeignReactionChange,
} = require('../models/lib/reactionOwnership');
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
const ME = 'user-me';
const THEM = 'user-them';
const THUMB = '128077';
const HEART = '10084';
const set = reactions => ({ $set: { reactions } });
// ------------------------------------------------------- what must be allowed
test('adding my own reaction is allowed', () => {
const before = [{ reactionCodepoint: THUMB, userIds: [THEM] }];
const after = [{ reactionCodepoint: THUMB, userIds: [THEM, ME] }];
assert.strictEqual(changesOnlyOwnReactions(before, after, ME), true);
});
test('removing my own reaction is allowed', () => {
const before = [{ reactionCodepoint: THUMB, userIds: [THEM, ME] }];
const after = [{ reactionCodepoint: THUMB, userIds: [THEM] }];
assert.strictEqual(changesOnlyOwnReactions(before, after, ME), true);
});
test('the first reaction of all, and the last one going away, are allowed', () => {
assert.strictEqual(changesOnlyOwnReactions([], [{ reactionCodepoint: THUMB, userIds: [ME] }], ME), true);
assert.strictEqual(changesOnlyOwnReactions([{ reactionCodepoint: THUMB, userIds: [ME] }], [], ME), true);
});
test('a reordered array with the same membership is not a change', () => {
// The client rebuilds the array on every toggle; order is not meaning.
const before = [
{ reactionCodepoint: THUMB, userIds: [THEM, ME] },
{ reactionCodepoint: HEART, userIds: [THEM] },
];
const after = [
{ reactionCodepoint: HEART, userIds: [THEM] },
{ reactionCodepoint: THUMB, userIds: [ME, THEM] },
];
assert.strictEqual(changesOnlyOwnReactions(before, after, ME), true);
});
test('I may toggle several of my own reactions at once', () => {
const before = [{ reactionCodepoint: THUMB, userIds: [ME, THEM] }];
const after = [
{ reactionCodepoint: THUMB, userIds: [THEM] },
{ reactionCodepoint: HEART, userIds: [ME] },
];
assert.strictEqual(changesOnlyOwnReactions(before, after, ME), true);
});
// -------------------------------------------------------- the attacks refused
test('THE ATTACK: adding somebody else to a reaction is refused', () => {
const before = [{ reactionCodepoint: THUMB, userIds: [ME] }];
const after = [{ reactionCodepoint: THUMB, userIds: [ME, THEM] }];
assert.strictEqual(changesOnlyOwnReactions(before, after, ME), false);
});
test('THE ATTACK: deleting somebody else\'s reaction is refused', () => {
const before = [{ reactionCodepoint: THUMB, userIds: [ME, THEM] }];
const after = [{ reactionCodepoint: THUMB, userIds: [ME] }];
assert.strictEqual(changesOnlyOwnReactions(before, after, ME), false);
});
test('wiping the whole array is refused when it was not only mine', () => {
const before = [{ reactionCodepoint: THUMB, userIds: [THEM] }];
assert.strictEqual(changesOnlyOwnReactions(before, [], ME), false);
});
test('moving somebody else from one reaction to another is refused', () => {
const before = [{ reactionCodepoint: THUMB, userIds: [THEM] }];
const after = [{ reactionCodepoint: HEART, userIds: [THEM] }];
assert.strictEqual(changesOnlyOwnReactions(before, after, ME), false);
});
test('an anonymous caller changes nobody\'s reactions', () => {
assert.strictEqual(changesOnlyOwnReactions([], [{ reactionCodepoint: THUMB, userIds: [ME] }], null), false);
assert.strictEqual(changesOnlyOwnReactions([], [], ''), false);
});
// -------------------------------------------------------- the deny-rule shape
test('the deny rule refuses exactly the foreign changes', () => {
const doc = { reactions: [{ reactionCodepoint: THUMB, userIds: [THEM] }] };
// Mine: allowed.
assert.strictEqual(
denyForeignReactionChange(ME, doc, set([{ reactionCodepoint: THUMB, userIds: [THEM, ME] }])),
false,
);
// Theirs: refused.
assert.strictEqual(
denyForeignReactionChange(ME, doc, set([{ reactionCodepoint: THUMB, userIds: [] }])),
true,
);
});
test('negative: an update that does not touch reactions is left alone', () => {
const doc = { reactions: [{ reactionCodepoint: THUMB, userIds: [THEM] }] };
assert.strictEqual(denyForeignReactionChange(ME, doc, { $set: { cardId: 'c1' } }), false);
assert.strictEqual(denyForeignReactionChange(ME, doc, {}), false);
assert.strictEqual(denyForeignReactionChange(ME, doc, null), false);
});
test('the modifier forms that cannot be checked field by field are refused', () => {
// toggleReaction() always $sets the whole array, so nothing legitimate sends
// these - and letting them through would be a hole exactly the size of the one
// being closed.
const doc = { reactions: [{ reactionCodepoint: THUMB, userIds: [THEM] }] };
assert.strictEqual(denyForeignReactionChange(ME, doc, { $push: { reactions: {} } }), true);
assert.strictEqual(denyForeignReactionChange(ME, doc, { $pull: { reactions: {} } }), true);
assert.strictEqual(denyForeignReactionChange(ME, doc, { $addToSet: { 'reactions.0.userIds': THEM } }), true);
assert.strictEqual(denyForeignReactionChange(ME, doc, { $unset: { reactions: '' } }), true);
});
test('negative: junk in the stored or incoming array does not throw or pass', () => {
assert.strictEqual(changesOnlyOwnReactions(null, null, ME), true, 'nothing to nothing is no change');
assert.strictEqual(
changesOnlyOwnReactions('nonsense', [{ reactionCodepoint: THUMB, userIds: [THEM] }], ME),
false,
'inventing somebody else\'s reaction out of junk is still refused',
);
assert.strictEqual(
changesOnlyOwnReactions([{ reactionCodepoint: THUMB, userIds: [THEM] }], 'nonsense', ME),
false,
'and deleting theirs by sending junk is refused',
);
});
// -------------------------------------------------------------- the wiring
test('the deny rule is registered on the collection, with the reactions fetched', () => {
const src = read('server/permissions/cardCommentReactions.js');
assert.ok(/CardCommentReactions\.deny\(\{/.test(src), 'a deny rule exists');
assert.ok(/denyForeignReactionChange\(userId, doc, modifier\)/.test(src));
assert.ok(/fetch: \['reactions'\]/.test(src),
'the deny rule must be given the stored reactions, or it has nothing to compare against');
});
test('the membership rule it sits on top of is unchanged', () => {
const src = read('server/permissions/cardCommentReactions.js');
// Read-only / no-comment members still may not react at all (the earlier fix).
// Count the CALLS, not the import line that also carries the name.
const allows = src.match(/allowIsBoardMemberCommentOnly\(userId,/g) || [];
assert.strictEqual(allows.length, 3, 'insert, update and remove all still require comment rights');
});
console.log(`\n${passed} tests passed`);
+131
View File
@@ -0,0 +1,131 @@
'use strict';
// CommentBleed — GHSA-pqr4-rxgp-hv2m, "REST comment DELETE allows any board
// member to delete other users' comments (BOLA)" (Moderate, CWE-639 / CWE-863),
// reported by Alpastx. https://wekan.fi/hall-of-fame/commentbleed/
//
// Over DDP, deleting a comment is author-or-board-admin, and a board that sets
// restrictCommentEditing takes even the admin's ability away (#5906). That rule
// was enforced in a CardComments collection hook keyed off the Meteor userId,
// with an early `if (!userId) return` for genuine server-internal work (board
// copy, cleanup, migrations).
//
// An HTTP request carries no Meteor userId into the invocation context. So
// `DELETE /api/boards/:b/cards/:c/comments/:id` — which checked only board
// membership — reached the collection, the hook took its "server-internal"
// path, and any normal member could delete anybody's comment, restricted board
// or not.
//
// The fix is not to trust the hook: the handler loads the comment and applies
// the same rule itself, with req.userId, before removing anything.
//
// Run: node tests/restCommentDeleteAcl.test.cjs
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
const read = rel => fs.readFileSync(path.join(repoRoot, rel), 'utf8');
const restComments = read('server/models/cardComments.js');
const modelComments = read('models/cardComments.js');
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
const deleteHandler = restComments.match(
/WebApp\.handlers\.delete\([\s\S]*?\n\);/,
);
// ------------------------------------------------- the rule itself (pure)
// canEditComment is the pure decision the whole thing rests on. It has its own
// mocha suite (server/lib/tests/comments.permissions.tests.js); re-pinned here
// because this advisory is about it being SKIPPED, not about it being wrong.
test('the pure rule still says author-or-admin, and restricted means author only', () => {
const source = modelComments.match(/export function canEditComment\([\s\S]*?\n\}/)[0];
assert.ok(/if \(isAuthor\) \{\s*\n\s*return true;/.test(source));
assert.ok(/if \(restrictCommentEditing\) \{\s*\n\s*return false;/.test(source));
assert.ok(/return !!isBoardAdmin;/.test(source));
});
// ------------------------------------------------------ the REST handler
test('the DELETE handler loads the comment before removing it', () => {
assert.ok(deleteHandler, 'the DELETE handler is found');
const body = deleteHandler[0];
assert.ok(/const comment = await ReactiveCache\.getCardComment\(/.test(body));
});
test('a comment that does not exist is a 404, not a silent success', () => {
const body = deleteHandler[0];
assert.ok(/if \(!comment\) \{[\s\S]*?code: 404[\s\S]*?return;/.test(body));
});
test('the object ACL is applied, with the REST caller identity', () => {
const body = deleteHandler[0];
assert.ok(/await assertCanMutateComment\(req\.userId, comment\);/.test(body),
'the same rule DDP applies, called with req.userId');
});
test('the ACL runs BEFORE the removal, not after it', () => {
const body = deleteHandler[0];
const aclAt = body.indexOf('assertCanMutateComment');
const removeAt = body.indexOf('CardComments.removeAsync');
assert.ok(aclAt > -1 && removeAt > -1, 'both are present');
assert.ok(aclAt < removeAt, 'the check must precede the delete');
});
test('negative: board membership alone is no longer the only check', () => {
const body = deleteHandler[0];
assert.ok(/checkBoardAccess\(req\.userId, paramBoardId\)/.test(body),
'membership is still required...');
assert.ok(/assertCanMutateComment/.test(body),
'...but it is no longer sufficient, which is the whole advisory');
});
// --------------------------------------------------- the shared rule module
test('the rule is exported, so REST and DDP cannot enforce different things', () => {
assert.ok(/export async function assertCanMutateComment\(userId, doc\)/.test(modelComments));
assert.ok(
/import CardComments, \{ assertCanMutateComment \} from '\/models\/cardComments'/.test(restComments),
'the REST file imports that exact function',
);
});
test('the hooks still call the same function, so DDP is unchanged', () => {
assert.ok(/CardComments\.before\.update\(async \(userId, doc\) => \{\s*\n\s*await assertCanMutateComment\(userId, doc\);/.test(modelComments));
assert.ok(/CardComments\.before\.remove\(async \(userId, doc\) => \{\s*\n\s*await assertCanMutateComment\(userId, doc\);/.test(modelComments));
});
test('the no-userId trusted path is documented as internal-only, and kept', () => {
const fn = modelComments.match(/export async function assertCanMutateComment[\s\S]*?\n\}/)[0];
// Board copy, cleanup and migrations legitimately run with no user; removing
// this would break them. What changed is that REST no longer arrives here
// anonymously.
assert.ok(/if \(!userId\) \{\s*\n\s*return;/.test(fn));
assert.ok(/GHSA-pqr4-rxgp-hv2m/.test(fn), 'and why, so it is not "simplified" back');
});
test('the refusal is a 403 for REST callers, not a 500', () => {
const fn = modelComments.match(/export async function assertCanMutateComment[\s\S]*?\n\}/)[0];
assert.ok(/error\.statusCode = 403;/.test(fn));
assert.ok(/error-comment-edit-not-allowed/.test(fn), 'and keeps the DDP error id');
});
test('negative: there is still no REST edit route that could bypass the same rule', () => {
// If a PUT is ever added it must get the same check; this fails when one
// appears without it.
const routes = restComments.match(/WebApp\.handlers\.(get|post|put|delete)\(/g) || [];
const puts = routes.filter(r => r.includes('.put('));
assert.strictEqual(puts.length, 0,
'a comment edit route was added — give it assertCanMutateComment too');
});
console.log(`\n${passed} tests passed`);
+25 -4
View File
@@ -43,15 +43,36 @@ test('the public clause is optional, and only that clause', () => {
const body = boards.slice(at, boards.indexOf('Boards.userBoardIds', at));
assert.ok(/includePublic = options\.includePublic !== false/.test(body),
'it must default to INCLUDING public boards, so every existing caller is unchanged');
assert.ok(/\.\.\.\(includePublic \? \[\{ permission: 'public' \}\] : \[\]\)/.test(body),
// The `$or` used to be written out here as an array literal, and this test
// matched that literal. GHSA-gwc4-fw7p-gw58 moved it into ONE builder shared
// with the `board` publication - the publication had its own copy that
// ignored isActive, so a revoked org/team/domain share still published the
// whole private board. The BEHAVIOUR this test protects is unchanged, so it
// now asks the builder instead of the literal.
assert.ok(/selector\.\$or = boardVisibilitySelectors\(\{/.test(body),
'the scope comes from the shared builder');
assert.ok(/includePublic,/.test(body), 'and the option is passed to it');
const { boardVisibilitySelectors } = require('../models/lib/boardVisibilitySelectors');
const args = {
userId: 'u', orgIds: ['o'], teamIds: ['t'], emailDomains: ['example.com'],
};
const withPublic = boardVisibilitySelectors(args);
const withoutPublic = boardVisibilitySelectors({ ...args, includePublic: false });
assert.ok(withPublic.some(c => c.permission === 'public'),
'included by default');
assert.ok(!withoutPublic.some(c => c.permission === 'public'),
'the public clause is the one that drops out');
// The four ways a user actually reaches a board must never be optional.
for (const clause of ['members:', 'orgs:', 'teams:', 'domains:']) {
const line = body.split('\n').find(l => l.includes(clause));
assert.ok(line && !/includePublic/.test(line),
for (const clause of ['members', 'orgs', 'teams', 'domains']) {
assert.ok(withoutPublic.some(c => Object.prototype.hasOwnProperty.call(c, clause)),
`${clause} is a real relationship and must always be in the scope`);
}
assert.strictEqual(withPublic.length - withoutPublic.length, 1,
'dropping the public clause drops nothing else');
});
test('the option reaches userBoardIds, which is what the search calls', () => {
+15 -1
View File
@@ -163,7 +163,21 @@ check('upload rejections are logged (fileValidation)', () => {
});
check('forged X-Forwarded-For denial is logged (metrics)', () => {
const src = read('models/server/metrics.js');
assert.ok(/key: 'spoofing\.xff'/.test(src) && /x-forwarded-for/.test(src));
// It used to call securityLog.record({ key: 'spoofing.xff' }) directly. It
// trips a CANARY now (docs/Security/Remediation/WeKan.md §12), which records
// the same category through the same logger and adds the two things the raw
// call could not: the client address, resolved spoofing-safely from the
// request, and rate limiting - this endpoint is unauthenticated, so a bare
// record() there was one insert per request an attacker chose to send. The
// behaviour this guard protects is unchanged: a denial with a forwarded-for
// header present must reach Admin Panel / Problems / Security.
assert.ok(/tripCanary\('spoof\.forwarded-header'/.test(src));
assert.ok(/x-forwarded-for/.test(src));
// ...and the canary resolves to the same category it always did.
const { canaryFor } = require('../models/lib/canaryTokens');
assert.strictEqual(canaryFor('spoof.forwarded-header').key, 'spoofing.xff');
// The 401 is untouched: the caller must not be able to tell.
assert.ok(/res\.writeHead\(401\)/.test(src));
});
check('export authorization denials are logged (export.js)', () => {
const src = read('models/export.js');
+14 -8
View File
@@ -4,6 +4,12 @@
// against WeKan v9.95.0 (see ../log/v10/sec/). These assert, from source, that
// each fix is present and each vulnerable pattern is gone. They run under plain
// node (no Meteor/DB), matching the other tests/*.test.cjs guards.
//
// Each section names the Hall of Fame vulnerability it guards - RedirectBleed,
// SourceBleed, LiveBleed, CasBleed, OidcBleed, MetricsBleed, ImpersonateBleed,
// InviteBleed - because a regression test that does not say WHICH published
// vulnerability it belongs to cannot be checked against the published list. See
// tests/securityRegressionCoverage.test.cjs, which does that checking.
const fs = require('fs');
const path = require('path');
@@ -19,7 +25,7 @@ function check(name, fn) {
console.log(' ok -', name);
}
// ── #1 Avatar localization redirect SSRF ────────────────────────────────────
// ── #1 Avatar localization redirect SSRF — RedirectBleed ────────────────────
check('#1 avatar localization fetches via fetchSafe, not native redirect-follow', () => {
const src = read('server/lib/localizeAvatar.js');
assert.ok(/fetchSafe\(url,/.test(src), 'localizeAvatar must fetch via fetchSafe');
@@ -40,7 +46,7 @@ check('#1 setAvatarUrl validates the URL scheme at write time', () => {
assert.ok(/scheme === 'http' \|\| scheme === 'https'/.test(src));
});
// ── #2 Trello import source URL stored XSS ──────────────────────────────────
// ── #2 Trello import source URL stored XSS — SourceBleed ────────────────────
check('#2 activity sourceLink only links http(s) URLs', () => {
const src = read('client/components/activities/activities.js');
const fn = src.slice(src.indexOf('sourceLink()'), src.indexOf('memberLink()'));
@@ -55,7 +61,7 @@ check('#2 Trello import stores only http(s) source.url', () => {
);
});
// ── #3 Trello live import readable-response SSRF ────────────────────────────
// ── #3 Trello live import readable-response SSRF — LiveBleed ────────────────
check('#3 live Trello import guards every download with validateAttachmentUrl', () => {
const src = read('server/trelloApiImport.js');
assert.ok(/import \{ validateAttachmentUrl \}/.test(src), 'must import validateAttachmentUrl');
@@ -77,7 +83,7 @@ check('#3 …and downloads through fetchSafe, never a bare redirect-following fe
);
});
// ── #4 CAS login global user-data race ──────────────────────────────────────
// ── #4 CAS login global user-data race — CasBleed ───────────────────────────
check('#4 CAS stores user data per token, not in a module global', () => {
const src = read('packages/wekan-accounts-cas/cas_server.js');
assert.ok(!/^\s*let _userData\b/m.test(src), 'the module-global _userData must be gone');
@@ -88,7 +94,7 @@ check('#4 CAS stores user data per token, not in a module global', () => {
'login handler must read the per-token userData');
});
// ── #5 OIDC shared serviceData race (already fixed upstream, #4897) ──────────
// ── #5 OIDC shared serviceData race — OidcBleed (fixed upstream, #4897) ─────
check('#5 OIDC per-login objects are declared inside the callback', () => {
const src = read('packages/wekan-oidc/oidc_server.js');
const cbStart = src.indexOf("OAuth.registerService('oidc'");
@@ -101,7 +107,7 @@ check('#5 OIDC per-login objects are declared inside the callback', () => {
}
});
// ── #6 Metrics X-Forwarded-For whitelist bypass ─────────────────────────────
// ── #6 Metrics X-Forwarded-For whitelist bypass — MetricsBleed ──────────────
check('#6 metrics endpoint does not trust X-Forwarded-For unconditionally', () => {
const src = read('models/server/metrics.js');
assert.ok(/function metricsClientIp/.test(src), 'metricsClientIp helper must exist');
@@ -114,7 +120,7 @@ check('#6 metrics endpoint does not trust X-Forwarded-For unconditionally', () =
assert.ok(/const ipAddress = metricsClientIp\(req\)/.test(src));
});
// ── #7 Stale impersonation record allows private board export ───────────────
// ── #7 Stale impersonation allows private board export — ImpersonateBleed ───
check('#7 export authorization no longer bypasses via stale impersonation', () => {
for (const f of ['models/export.js', 'models/exportExcel.js', 'models/exportExcelCard.js', 'models/exportPDF.js']) {
const src = read(f);
@@ -123,7 +129,7 @@ check('#7 export authorization no longer bypasses via stale impersonation', () =
}
});
// ── #8 Invitation code brute force ──────────────────────────────────────────
// ── #8 Invitation code brute force — InviteBleed ────────────────────────────
check('#8 invitation codes are CSPRNG, not 6-digit Math.random', () => {
const src = read('server/models/settings.js');
assert.ok(!/getRandomNum\(100000, 999999\)/.test(src), 'the 6-digit Math.random code must be gone');
+248
View File
@@ -0,0 +1,248 @@
'use strict';
// Every published vulnerability must have a regression test, or a recorded
// reason why it does not.
//
// WeKan publishes its fixed vulnerabilities at https://wekan.fi/hall-of-fame/ ,
// each with a name. Whether a given one still has a test guarding it was, until
// this file, unanswerable: some suites name the vulnerability they belong to,
// most do not, and the Hall of Fame lives in a different repository that CI
// never checks out. So "are the security tests enough" could only be answered by
// reading 58 pages and 300 suites by hand, which means in practice it was never
// answered at all - and a regression test that quietly stops existing is worth
// nothing, which is the failure mode tests/testsAreRegistered.test.cjs already
// caught once (two *bleed suites had drifted out of the mocha index).
//
// So the list lives HERE, in the repository that has the tests, and this guard
// keeps the two in step:
//
// * GUARDED - the vulnerability is named by at least one test file, and that
// file still exists. Naming is the whole mechanism: a test that
// does not say which published vulnerability it belongs to
// cannot be checked against the published list.
// * RECORDED - no test names it, and the reason is written down. This is the
// CHANGELOG's "TODO Later" pattern applied to tests: a gap with
// a reason is a decision, a gap without one is a surprise.
//
// The list may only move in one direction. Adding a vulnerability to RECORDED is
// allowed and expected when it is published; moving one from RECORDED to GUARDED
// is the work. The count below fails if RECORDED grows, so a new vulnerability
// cannot be published with no test and no note.
//
// Run: node tests/securityRegressionCoverage.test.cjs
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '..');
const SUITE_DIRS = ['tests', 'server/lib/tests', 'client/lib/tests'];
// Files that mention a vulnerability name for bookkeeping rather than testing
// it. They must not be accepted as coverage, or this guard would pass itself.
const NOT_COVERAGE = new Set([
'securityRegressionCoverage.test.cjs', // this file
'testsAreRegistered.test.cjs', // checks registration, not behaviour
'index.js',
'index.test.js',
]);
// ─────────────────────────────────────────────────────────────────────────────
// GUARDED: vulnerability -> the suite(s) that name it.
// ─────────────────────────────────────────────────────────────────────────────
const GUARDED = {
boardbleed: ['tests/crossBoardParentCardLeak.test.cjs'],
casbleed: ['tests/securityMeifukun.test.cjs'],
checklistbleed: ['server/lib/tests/checklistbleed.security.tests.js'],
clonebleed: ['server/lib/tests/clonebleed.security.tests.js'],
commentbleed: ['tests/restCommentDeleteAcl.test.cjs'],
crashbleed: ['tests/exportTokenGuard.test.cjs'],
dnsbleed: ['server/lib/tests/dnsbleed.security.tests.js'],
exportbleed: ['tests/exportHTMLXss.test.cjs'],
followbleed: ['tests/followbleed.test.cjs'],
identitybleed: ['tests/noIdentityReplacement.test.cjs'],
impersonatebleed: ['tests/securityMeifukun.test.cjs'],
integrationbleed: ['server/lib/tests/dnsbleed.security.tests.js'],
invitebleed: ['tests/securityMeifukun.test.cjs'],
livebleed: ['tests/followbleed.test.cjs', 'tests/securityMeifukun.test.cjs'],
lockoutbleed: [
'tests/loginFailureDecision.test.cjs',
'tests/loginTimingDefense.test.cjs',
'tests/loginAttemptThrottle.test.cjs',
'tests/loginBruteForceEnumerationWiring.test.cjs',
],
metricsbleed: ['tests/securityMeifukun.test.cjs'],
mimebleed: ['server/lib/tests/fileValidationBypass.security.tests.js'],
oidcbleed: ['tests/securityMeifukun.test.cjs'],
parentbleed: ['tests/crossBoardParentCardLeak.test.cjs'],
pathbleed: ['tests/avatarVersionPathTraversal.test.cjs'],
patternbleed: ['tests/noIdentityReplacement.test.cjs'],
proxybleed: ['server/lib/tests/proxybleed.security.tests.js'],
redirectbleed: ['tests/securityLog.test.cjs', 'tests/securityMeifukun.test.cjs'],
revokebleed: ['tests/boardShareRevokeBypass.test.cjs'],
sortbleed: ['server/lib/tests/boards.security.tests.js'],
sourcebleed: ['tests/securityMeifukun.test.cjs'],
transitbleed: ['tests/transitbleed.test.cjs'],
webhookbleed: ['server/lib/tests/dnsbleed.security.tests.js'],
zipbleed: ['tests/zipbleed.test.cjs'],
// Guarded by ATTEMPT DETECTION rather than by a fix-regression test: a canary
// sits where the attack is tried, and tests/canaryCoverage.test.cjs pins that
// the canary is there, is silent, and is attributed
// (docs/Security/Remediation/WeKan.md §12). That is weaker than a test of the
// fix itself - it proves an attempt is SEEN, not that it still fails - so
// these stay candidates for a real regression test. It is stronger than
// nothing, which is what they had.
escapebleed: ['tests/canaryCoverage.test.cjs'],
filebleed: ['tests/canaryCoverage.test.cjs'],
inputbleed: ['tests/canaryCoverage.test.cjs'],
spacebleed: ['tests/canaryCoverage.test.cjs'],
};
// ─────────────────────────────────────────────────────────────────────────────
// RECORDED: published, fixed, and no test names it - with why.
//
// Most of these are older fixes from before WeKan tested its security fixes at
// all. They are NOT known to be unprotected; they are known to be unchecked,
// which is a different and more honest statement. Whoever writes one of these
// tests moves the entry up into GUARDED.
// ─────────────────────────────────────────────────────────────────────────────
const RECORDED = {
adminbleed: 'privilege escalation to Admin; needs a running instance to reproduce, no source guard written',
anchorbleed: 'GHSL-2026-035 CursorBleed; reported before the *bleed suites existed',
authbleed: 'unauthenticated getServiceConfiguration leaked the OIDC client secret; needs a DDP test',
avatarbleed: 'predates the test suites; superseded in part by tests/avatarUrlSafety.test.cjs, which does not name it',
bflableed: '48 REST endpoints missing await on the access check - wants a source sweep over every route, not one test',
brutebleed: 'user data published unconditionally; overlaps userbleed, both need a publication test',
bypassbleed: 'authentication bypass; predates the *bleed test suites, no source guard was written',
duebleed: 'Due Cards showed other users\' private board cards to an Admin; needs a publication test',
emailbleed: 'SMTP password readable behind the asterisks in Admin Panel; a UI-only fix',
excelbleed: 'un-awaited access-control guard in the Excel export route; same class as bflableed',
fieldbleed: 'JavaScript stored in a field ran when the page was reloaded; predates the *bleed test suites',
floppybleed: 'FileBleed variant; predates the *bleed test suites, no source guard was written',
framebleed: 'cross-frame scripting; a header fix with no test',
invisiblebleed: 'HTML comments were not visible in rendered content; predates the *bleed test suites',
ldapbleed: 'LDAP TLS certificate validation off by default; needs an LDAP stack to test',
megableed: 'IDOR in setCreateTranslation; needs a DDP method test',
randombleed: 'modulo bias on crypto.randomBytes (CWE-1204); tests/securityMeifukun.test.cjs touches randomBytes but guards invitebleed, not this',
reactionbleed: 'XSS in comment reactions; predates the *bleed test suites - note the reaction OWNERSHIP hole found in this round is a different bug and is guarded by tests/reactionOwnership.test.cjs',
readonlybleed: 'read-only members could write Custom Fields; needs a permissions test',
scannerbleed: 'CVE-2026-68560 shell injection via an upload filename in the antivirus scanner command; needs a source guard on the command path',
snowbleed: 'MigrationsBleed - a database migration fix; predates the *bleed test suites',
socialbleed: 'social media links on wekan.fi - a website fix, not a WeKan one',
splicebleed: 'incomplete multi-character sanitization stripping markup from a shown filename; tests/fileNameDisplay.test.cjs is the likely guard but does not name it',
tokenbleed: 'predates the *bleed test suites; the REST token paths are exercised by 23-rest-api-more.e2e.js, which does not name it',
userbleed: 'user data published unconditionally; overlaps brutebleed',
};
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
const readSuite = rel => fs.readFileSync(path.join(ROOT, rel), 'utf8');
// Every suite file in the tree, by basename, with its text.
function allSuites() {
const out = new Map();
SUITE_DIRS.forEach(dir => {
const abs = path.join(ROOT, dir);
if (!fs.existsSync(abs)) return;
fs.readdirSync(abs).forEach(f => {
if (!f.endsWith('.cjs') && !f.endsWith('.js')) return;
out.set(path.join(dir, f), fs.readFileSync(path.join(abs, f), 'utf8'));
});
});
return out;
}
test('every GUARDED vulnerability has a suite that exists and names it', () => {
Object.entries(GUARDED).forEach(([vuln, suites]) => {
assert.ok(suites.length > 0, `${vuln} lists no suite`);
suites.forEach(rel => {
const abs = path.join(ROOT, rel);
assert.ok(fs.existsSync(abs), `${vuln}: ${rel} does not exist`);
const text = readSuite(rel).toLowerCase();
assert.ok(
text.includes(vuln),
`${rel} must NAME ${vuln} - a test that does not say which published ` +
`vulnerability it guards cannot be checked against the published list`,
);
});
});
});
test('no vulnerability is both guarded and recorded as a gap', () => {
const both = Object.keys(GUARDED).filter(v => v in RECORDED);
assert.deepStrictEqual(both, [], 'a vulnerability is either tested or it is not');
});
test('every recorded gap says WHY, in a sentence', () => {
Object.entries(RECORDED).forEach(([vuln, reason]) => {
assert.strictEqual(typeof reason, 'string', `${vuln} needs a reason`);
assert.ok(reason.length >= 25,
`${vuln}: "${reason}" is too short to be a reason somebody can act on`);
});
});
test('a recorded gap is not silently guarded after all', () => {
// If a suite starts naming a vulnerability that is listed as a gap, the entry
// belongs in GUARDED - and leaving it in RECORDED would understate coverage.
const suites = allSuites();
const wrong = [];
Object.keys(RECORDED).forEach(vuln => {
suites.forEach((text, rel) => {
if (NOT_COVERAGE.has(path.basename(rel))) return;
if (text.toLowerCase().includes(vuln)) wrong.push(`${vuln} is named by ${rel}`);
});
});
assert.deepStrictEqual(wrong, [],
'these are tested after all - move them from RECORDED to GUARDED');
});
test('the gap list may not grow', () => {
// Publishing a vulnerability with neither a test nor a note must fail here.
// Lower this number when a gap is closed; raising it is the thing this guard
// exists to make deliberate.
assert.strictEqual(Object.keys(RECORDED).length, 25,
'the number of published vulnerabilities with no regression test changed');
});
test('the whole published list is accounted for', () => {
const total = Object.keys(GUARDED).length + Object.keys(RECORDED).length;
// https://wekan.fi/hall-of-fame/ - one directory per vulnerability. Raise this
// when a new one is published, and put it in GUARDED or RECORDED at the same
// time; the two assertions together are what make "every published
// vulnerability is accounted for" a fact rather than a hope.
assert.strictEqual(total, 58, 'the Hall of Fame and this list disagree on how many there are');
});
test('the four newest advisories are guarded, not recorded', () => {
// The ones this round fixed. If any of these ever slips into RECORDED, the
// fix has lost its test.
['pathbleed', 'revokebleed', 'parentbleed', 'commentbleed'].forEach(v => {
assert.ok(v in GUARDED, `${v} must stay guarded`);
});
});
test('negative: a suite that only registers others is not counted as coverage', () => {
// server/lib/tests/index.js names checklistbleed and proxybleed in its import
// list. Importing a suite is not testing a vulnerability, and counting it
// would make this guard pass on the strength of a filename.
assert.ok(NOT_COVERAGE.has('index.js'));
assert.ok(NOT_COVERAGE.has('testsAreRegistered.test.cjs'));
Object.values(GUARDED).flat().forEach(rel => {
assert.ok(!NOT_COVERAGE.has(path.basename(rel)),
`${rel} is bookkeeping, not a regression test`);
});
});
console.log(`\n${passed} tests passed`);
console.log(
`\n ${Object.keys(GUARDED).length} of ` +
`${Object.keys(GUARDED).length + Object.keys(RECORDED).length} published ` +
`vulnerabilities have a named regression test; ` +
`${Object.keys(RECORDED).length} are recorded gaps.`,
);