Compare commits

...
67 Commits
Author SHA1 Message Date
github-actions[bot] 149944726e Bump versions for v10.78 2026-08-09 15:27:00 +00:00
Lauri Ojansivu bf0b757862 Prepare v10.78 release 2026-08-09 18:25:59 +03:00
Lauri Ojansivu 1c7de782f0 CHANGELOG: the snap and release-notes fixes from the v10.77 build logs.
Thanks to xet7 !
2026-08-09 18:21:43 +03:00
Lauri Ojansivu a820b056b2 Snap: libcurl4 does not exist on armhf, so name the package noble ships.
The armhf snap failed all three attempts in v10.77 while s390x, ppc64el
and riscv64 each built on attempt 1. The Launchpad build log says why,
one line into the mongodb part:

    Stage package not found in part 'mongodb': libcurl4.
    Build failed

Ubuntu 24.04's 64-bit time_t transition renamed the library, and
`libcurl4` is not a real package on ANY architecture in noble - the
binary is `libcurl4t64` everywhere. On the 64-bit architectures the
renamed package keeps the old name alive:

    Package: libcurl4t64
    Architecture: amd64
    Provides: libcurl4 (= 8.5.0-2ubuntu10)

and the armhf libcurl4t64 has no Provides line at all, because there the
ABI really changed. That is what makes this class of bug reach exactly
one architecture, which is also what makes it look like flakiness on
that architecture.

It had already happened, to libssl3 and libgoogle-perftools4 in v10.71;
the comment above this list describes it exactly. libcurl4 was the same
transition and was left behind. Every other stage-package was checked
against the noble armhf main+universe indices at the same time - all 14
of them - and libcurl4 was the only one that did not resolve.

The workflow's own guidance made it worse. snapcraft ends a missing
stage-package as a "Stopped" build with no artifact, which from the
outside is indistinguishable from Launchpad cancelling a build, so the
message said "often an OOM in the Meteor npm install, or a transient
build-farm reset ... re-run, and if it persists on this arch open a
Launchpad question" - the wrong advice for a failure that will never
succeed on a re-run. The step now looks for the line first, names the
package that is missing, says it is NOT transient, explains the t64
Provides asymmetry, and gives the one-line archive query that checks a
name.

New test tests/snapStagePackages.test.cjs, 6 checks: no stage-package is
a known pre-t64 name, libcurl is asked for by the name noble publishes,
the two fixed in v10.71 have not regressed, and - negatively - a name
that was never renamed keeps its plain name, because the rule is not
"add t64 to everything". It also pins that the reason stays written down
beside the list. Verified it fails when libcurl4 is put back.

Thanks to xet7 !
2026-08-09 18:20:42 +03:00
Lauri Ojansivu 6dce9dda79 Release notes and snap logs: report what really happened.
Four things the v10.77 run got wrong, all of them silent - nothing
errored, the reports were simply not what they claimed to be.

THREE GOOD SNAPS WERE REPORTED AS FAILED. s390x, ppc64el and riscv64
each printed "Remote build <arch> succeeded on attempt 1" and downloaded
their .snap, and each job then ended FAILED - because the step that
SAVES the build log could not upload it:

    The path for one of the files in artifact is not valid:
    /snapcraft-wekan-f82a93c2…_s390x_2026-08-09T11:39:05.txt.
    Contains the following character:  Colon :

snapcraft names a remote-build log after the recipe and an ISO
timestamp, and upload-artifact refuses a colon because NTFS cannot hold
one. The colons become dashes now, so the timestamp is kept rather than
the name thrown away - and the upload is continue-on-error, because a
DIAGNOSTIC upload must never be able to fail the build it is diagnosing.
The snap is the deliverable; the renaming is not the last thing that
could ever make a log file unuploadable.

EVERY ROW OF THE PROVENANCE TABLE APPEARED TWICE. provenance-table.sh
globbed `provenance/**/*.tsv provenance/*.tsv`, and with globstar `**/`
matches ZERO or more directories, so the first pattern already covered
the second and every file was read twice. It is one pattern now, and
rows are also deduplicated on the WHOLE line - a retried step or a job
that ran again records an identical line, and nothing tells the copies
apart. Deliberately not `sort -u -k1,1 -k2,2`: two rows sharing a bundle
and a binary but differing elsewhere are not a duplicate, they are a
real disagreement about which Node.js went into a bundle, and hiding one
at random is worse than showing both.

AMD64 WAS MISSING FROM THAT TABLE - the platform most people download.
Its step runs `cd .build` before `mkdir -p provenance`, so the rows went
to .build/provenance while upload-artifact looked at provenance/ from
the workspace root and found nothing: "No files were found with the
provided path: provenance/". Every other build job records from the
workspace root, which is why only this one was affected. It uses
$GITHUB_WORKSPACE now.

SIX PLATFORMS SAID VERSION "LATEST". amd64, arm64, win64, win32,
mac-arm64 and mac-x64 passed the literal string; only the
extra-architecture job asked which version `latest` actually was, and it
did so with its own inline curl+python. "Which FerretDB did v10.77 ship"
is the one question that column exists to answer. All seven sites now
call the new releases/ferretdb-latest-tag.sh, which asks once per job
and caches, authenticates when there is a token so a shared 60/hour
limit is not what makes it fail, checks that the answer is shaped like a
tag before printing it into a markdown cell, and prints NOTHING and
exits 0 when it cannot find out - the caller's ${FERRET_TAG:-latest} is
then exactly what it had before. A release note must never fail a build
that produced a good bundle.

The table's own prose linked [wekan/node](https://github.com/wekan/node);
the binaries come from wekan/node-patches, which is what the rows
themselves already linked.

New test tests/provenanceTable.test.cjs, 20 checks, most of them running
the scripts rather than reading them. Verified in both directions: with
the doubled glob restored the glob guard fails, with amd64's relative
path restored its guard fails, and with the literal 'latest' restored
its guard fails.

Thanks to xet7 !
2026-08-09 18:15:22 +03:00
Lauri Ojansivu 47c81cd478 CHANGELOG: rewrap the Upcoming summary.
Thanks to xet7 !
2026-08-09 16:01:11 +03:00
Lauri Ojansivu bc426ba7c2 CHANGELOG: the Upcoming binaries table is FerretDB v1.48.0, not v10.77's v1.45.0.
Thanks to xet7 !
2026-08-09 15:58:58 +03:00
Lauri Ojansivu a0f445c723 CHANGELOG: the Docker provenance and bundled-binary freshness work.
Thanks to xet7 !
2026-08-09 15:51:44 +03:00
Lauri Ojansivu ce69db8c5f Pin that every bundled binary is fetched as the newest one.
WeKan ships binaries other projects publish - FerretDB, the MongoDB
Database Tools, Node.js - and fetches each from a release URL. There are
many such URLs, across workflows, release scripts and compose files, and
they must all agree: one that names a fixed version stops receiving
security fixes silently, and nothing about the build fails when it does.

That is not hypothetical. A Quay scan of the v10.77 image reported Go
advisories in the FerretDB binary baked into the bundle - stdlib 1.25.9
wanting 1.25.11, golang.org/x/sys v0.38.0 wanting 0.44.0. The source was
already fixed and v1.48.0 was already published carrying go1.25.11 and
x/sys v0.46.0; the image had simply captured an older latest at build
time. Had any of these URLs been pinned instead, the rebuild would not
have fixed it either.

The test finds the URLs by searching the repository rather than from a
list, so a build site added later is checked too, and it accepts three
shapes: latest/download/<asset>, the bare latest API endpoint that
release-all.yml asks which version latest resolved to for the provenance
table, and ${VAR} whose default is checked separately to be latest.
Node.js is a different mechanism with the same effect - NODE_VERSION is
the bare major 24 and resolve-node-source.sh answers with that CPU's
newest 24.x from whichever of nodejs.org, unofficial-builds or
wekan/node-patches has one - so it is asserted to stay that way.

Thanks to xet7 !
2026-08-09 15:50:11 +03:00
Lauri Ojansivu 5b31695343 Docker images: full build provenance, at every site that pushes one.
BuildKit attaches MINIMAL provenance by default - a build id and a timestamp -
which is what those `unknown on unknown` rows on quay.io are: the OCI marker for
"not a runnable image", one per platform, so `docker pull` never selects them.
mode=max records what actually answers a supply-chain question instead: which
commit, which base image, which build arguments.

WeKan pushes images from FOUR places - docker-publish.yml's build-push-action,
two `docker buildx build --push` in release-all.yml, and one in
releases/docker-build.sh - and none of them said anything about provenance, so
all four took the default. All four ask for mode=max now. The local `--load`
check build in docker-build.sh deliberately does not: the docker exporter cannot
carry an attestation at all.

SBOM stays off, and the test says why so it is a decision rather than a drift:
it enumerates every OS package and npm dependency, taking the attestation from
~10 KiB per platform to megabytes.

tests/dockerProvenance.test.cjs finds the build sites by SEARCHING for them
rather than from a list, so a fifth one cannot quietly fall back to the default.

It also pins a trap I fell into twice while writing this, once in the shell
script and once in a YAML `run:` block:

    docker buildx build \
      # explain the next flag
      --provenance=mode=max \
      --platform ... \

A `#` after a line continuation comments out the REST OF THE JOINED LINE, so
that is `docker buildx build` with every argument swallowed - no platforms, no
tags, no push. `bash -n` accepts it, because it is valid syntax; it is simply a
different command. Both comments live above their command now, and the test
fails on any comment sitting among a continued command's arguments - while
ignoring a comment block that merely quotes a multi-line example, which is what
it flagged first.

Thanks to xet7 !
2026-08-09 15:38:53 +03:00
Lauri Ojansivu 4a8fbd954e CHANGELOG: the Flatpak fix, beside the AppImage one it repeats.
Same group, because it is the same area and the same one-line cause - a gh call
without --repo in a job that does not check out. The subsection is 'bugs' now
rather than 'bug'.

Thanks to xet7 !
2026-08-09 15:25:28 +03:00
Lauri Ojansivu 04fb6e3381 Flatpak: attach the bundles to the release, and only the bundles.
The same one-line bug AppImage.yml had, and a second one beside it.

    gh release upload 'v10.77' assets/* --clobber
    failed to run git: fatal: not a git repository

Both flatpaks built and were uploaded as artifacts; the job that attaches them
does not check the repository out - it only downloads artifacts - so `gh` had no
git remote to infer the repository from. Every gh call in this workflow passes
--repo now, including the ones in jobs that DO check out.

The second: `assets/*` is not the bundles. The artifacts also carry the ostree
repository the flatpak was exported through - config, objects/, refs/,
summaries/, summary, summary.idx - which is build scaffolding, and attaching it
to a release would have put a few hundred directories there beside the two
files anyone wants. The upload names the bundles and their checksums instead.
TSC hit this exact thing and fixed it the same way.

No architectures to add, and the header now says why rather than leaving it to
be rediscovered. A flatpak runs against a RUNTIME, so an architecture exists
here only if freedesktop.org publishes org.freedesktop.Platform for it: x86_64
and aarch64, the i386 and arm runtimes having been discontinued. That is the
difference from AppImage.yml, which could grow to four - an AppImage carries its
own runtime binary, and those exist for i686 and armhf too.

Thanks to xet7 !
2026-08-09 15:13:39 +03:00
Lauri Ojansivu 5957fa0ad8 CHANGELOG: the two AppImage commits, under a new Upcoming section.
v10.77 was cut while they were being written, so they get their own Upcoming
above the release, and the links point at the hashes the rebase onto the version
bump produced rather than the ones that will be collected.

Thanks to xet7 !
2026-08-09 15:09:54 +03:00
Lauri Ojansivu 97e57e531b AppImage: add the other two architectures a runtime exists for.
x86_64 and aarch64 become four, with i686 and armhf. That is the whole set: an
AppImage BEGINS with a runtime binary the kernel executes, and runtimes exist
for exactly x86_64, aarch64, i686 and armhf. ppc64le, s390x, riscv64 and loong64
have none, so they cannot have an AppImage at all however the job is written -
they are served by the bundle zip and the .deb, and the matrix now says so where
somebody would otherwise try.

The two are not equally safe, and they are not treated as though they were.

i686 builds on the x86_64 runner, which runs 32-bit x86 natively: no emulation,
no multiarch, and appimagetool-i686 runs as-is. It is an ordinary job.

armhf builds on the aarch64 runner, which runs 32-bit ARM only if the kernel has
CONFIG_COMPAT and the 32-bit loader is installed. GitHub promises neither, so it
is continue-on-error - the same treatment TSC gives its emulated armhf job. If
it proves reliable, drop the flag; if it never works, drop the entry. What it
must not do is decide whether the other three reach the release.

The smoke test needed the distinction too. It starts the AppImage and waits for
WeKan to answer on port 8080, which a 64-bit runner cannot do for a 32-bit build
it cannot execute. A CHECK THAT CANNOT RUN IS NOT A FAILED CHECK: when the
binary will not execute at all AND it is not this machine's architecture, the
AppImage is as good as anything here knows, so it warns, says it is uploaded
unchecked, and moves on. A build that DOES start and then does not answer still
fails - that is the bug the step exists for.

The publish job's two hardcoded architecture lists learned all four, so a
missing one warns instead of going unnoticed, and the base-bundle check looks
for wekan-<v>-{i386,armhf}.zip as well.

Untested here: this is a workflow, so the first real run is the test. The two
new jobs are the only ones that can fail, and armhf cannot take the release with
it.

Thanks to xet7 !
2026-08-09 15:08:23 +03:00
Lauri Ojansivu fa296d0c80 AppImage: attach the built AppImages to the release again.
Both AppImages built, both passed the smoke test that starts them and waits for
WeKan to answer on port 8080, both uploaded as artifacts, both downloaded by the
attach job with matching digests - and then nothing reached the release:

    gh release upload 'v10.77' assets/* --clobber
    failed to run git: fatal: not a git repository

The attach job does not check the repository out. It has no reason to - it only
downloads artifacts - so `gh` has no git remote to infer the repository from.
Every other gh call in this workflow already passes --repo; these two did not.

Every gh call passes it now, including the two in jobs that DO check out. A call
that relies on an inferred remote breaks the day somebody removes the checkout,
and the error it gives names neither gh nor the workflow.

Worth recording for whoever reads the failed run: the log looks far worse than
the bug. `::error::` lines appear in the build jobs' output for the bundle-file
check and the port-8080 check, and in the attach job for "no architecture
produced an AppImage" - none of which fired. They are the SCRIPT being echoed by
the ##[group] header, not output. The real failure is one line, in the last step
of the last job.

Thanks to xet7 !
2026-08-09 15:08:23 +03:00
github-actions[bot] 040c18bc23 Bump versions for v10.77 2026-08-09 11:16:31 +00:00
Lauri Ojansivu 7241570491 Prepare v10.77 release 2026-08-09 14:15:35 +03:00
Lauri Ojansivu 979ad3a5cd CHANGELOG: the two snap commits, under a new Upcoming section.
v10.76 was cut while they were being written, so they get their own Upcoming
above the release.

Thanks to xet7 !
2026-08-09 14:09:41 +03:00
Lauri Ojansivu 3c9291338c Snap CPU platforms: say which six, why not the other two, and all four channels.
The page listed five architectures and omitted armhf, which snapcraft.yaml has
built all along. It said the release publishes candidate, beta and edge and that
"the stable channel is published manually later" - which is no longer true and
was the reason a build reached three channels of four. And it said nothing about
the two architectures that are bundles but not snaps, so "missing" and "cannot
be there" looked identical.

The matrix is the six `build-for:` entries with the bundle name beside each,
because the two names are not always the same, and a new section explains the
three ways they differ:

  ppc64le / ppc64el   the same 64-bit POWER under two names - the bundles use
                      the kernel's, the store uses Debian's. The only rename.

  armhf / armv7       NOT a rename, and getting this wrong ships a snap that
                      crashes. node-patches builds armhf to the Debian baseline
                      (hard-float, VFPv3-D16, no NEON assumed) so it runs on any
                      ARMv7-A, and armv7 with NEON for boards that have it. The
                      store has ONE 32-bit ARM architecture serving every such
                      device, so it must carry the BASELINE build; the NEON one
                      would be an illegal instruction on a board without NEON.
                      armv7 ships as a bundle only.

  i386                cannot have a new snap at all, and this is categorically
                      different from a missing Node.js build. node-patches
                      patches SOURCE so a binary can be built; here the BASE SNAP
                      does not exist, because Ubuntu 24.04 has no i386 port. No
                      patch set can produce a base Canonical does not publish.
                      The last base with one was core18, end-of-life.

Then what to do about it: the three snaps, all four channels, and why stable is
possible - core24 is a released base, so the snap carries grade: stable, which a
grade: devel core26 snap cannot. A table of what each snap has in the store
today and what is still to upload, including the two fossils that channel
promotion cannot fix: wekan-ondra's armhf at 0.22 and its i386 at 0.X-ci, which
have no newer revision to promote.

And the caveat that matters when running it: without a pinned version the newest
revision of each architecture is promoted, and edge is often ahead of stable, so
a bare run publishes edge builds to stable users.

The armv7 note in models/lib/snapArchitectures.js said "different CPUs", which
was the right conclusion from the wrong reason. It now says what node-patches
actually builds, and a test cross-checks it against that repo's own workflow so
the explanation cannot drift from the binaries.

Thanks to xet7 !
2026-08-09 14:05:17 +03:00
Lauri Ojansivu a5d8a1fd97 Snap: release every snap, every architecture, to all four channels.
The helper that did this released ONE snap, ONE revision, to THREE channels:

    snapcraft release wekan $1 edge,beta,candidate

wrong three ways at once. It names only `wekan`, leaving wekan-ondra and
wekan-gantt-gpl to be done by hand. It takes ONE revision number, and revisions
are PER ARCHITECTURE - the store's own listing shows wekan at 3601 on amd64 and
3600 on arm64 for the same 10.76 - so one number can only ever be right for one
of them. And it leaves out `stable`, so a build reached three channels of four
and somebody had to remember the fourth.

releases/snap-release-all-channels.sh resolves the revision per (snap,
architecture) from the store itself, so no revision number is ever typed, and
releases it to all four channels in ONE call - a revision reaches all of them or
none. A (snap, architecture) with no revision is reported and skipped rather
than failing the run: the three snaps genuinely have different architecture
sets, and one architecture failing must not stop the rest.

THE MAPPING IS THE HAZARD, and it is now in one place with both directions
tested (models/lib/snapArchitectures.js):

  ppc64le and ppc64el ARE the same hardware. The bundles at
  https://github.com/wekan/wekan name it the way Node.js and the kernel do; the
  Snap Store names it the way Debian does. Nothing warns when the wrong one is
  used - an unrecognised --arch is simply an architecture the store has never
  heard of, so it looks like it worked.

  armhf and armv7 are NOT. armhf is 32-bit Raspberry Pi OS; armv7 is the
  ODroid-U3. Different CPUs, different bundles. The store has ONE 32-bit ARM
  architecture, armhf, so that is what is published; mapping armv7 onto it would
  hand Raspberry Pi users the ODroid build. The negative test for that is the
  most important one in the suite.

The six architectures are exactly snapcraft.yaml's `build-for` list, and a test
compares the two so the table cannot drift from what is actually built. i386 and
armv7 are recorded as not-built, with their reasons, and the script prints them
on every run - "it is missing" and "it cannot be there" look identical from the
outside.

tests/snapArchitectures.test.cjs: 23 checks including that the newest revision
for THAT architecture wins, that a version filter publishes what was built
rather than whatever was uploaded last, that `snapcraft list-revisions` parses
when a revision has no channels, and that junk never reaches snapcraft.

Thanks to xet7 !
2026-08-09 14:05:17 +03:00
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
github-actions[bot] 0409b348af Bump versions for v10.73 2026-08-08 06:39:43 +00:00
Lauri Ojansivu 466bf21a57 Prepare v10.73 release 2026-08-08 09:38:41 +03:00
Lauri Ojansivu c2ded4a599 CHANGELOG: the CodeQL fix belongs in Upcoming, not inside the released v10.72.
v10.72 was tagged and published at 08:32; the PatternBleed fix was committed at
09:05. Its entry went into the section directly above the previous release, and
by then that section had been renamed from "Upcoming" to "v10.72" by the release
commit - so the changelog claimed a released version contains a fix that is not
in its tag. Checked rather than assumed: the v10.72 tag points at "Bump versions
for v10.72", and the fix is not an ancestor of it.

There is a "# Upcoming WeKan ® release" section above v10.72 again, holding the
security subsection, and v10.72's first subsection reads "This release updates
the following dependencies:" once more instead of the "and updates" it was left
with. The Upcoming section carries the shape CLAUDE.md prescribes: the
**In short:** paragraph, then the binaries table - v10.72's rows, since nothing
here rebuilds them, and the paragraph says so - then the subsection, then the
closing thanks line.

The entry's commit link is repointed. `git pull` rebased the three commits after
the release onto it, which gave them new hashes, so the link written before the
rebase named a commit that no longer exists.

And a real bug in the guard that was supposed to notice all this:

    /^(This release|and ) .*:$/

The space is INSIDE the alternative AND after it, so matching an "and" header
needs "and  updates" with two spaces. Every `and ...:` subsection header
therefore failed to match, and the grouping check never reset its current group
at a subsection boundary: entries under a later subsection were attributed to the
last group line above them, which is exactly the mistake that loop exists to
catch. With it fixed, the section reports 22 grouped entries and one loose - the
one-entry security subsection, which CLAUDE.md keeps flat.

Thanks to GitHub CodeQL code scanning and xet7 !
2026-08-08 09:31:19 +03:00
Lauri Ojansivu 9dbdcbefc0 CHANGELOG: name the CodeQL finding PatternBleed and link its Hall of Fame page.
CLAUDE.md's security process is the Hall of Fame as well as the CHANGELOG: the
name, the row in hall-of-fame/index.html and the page under it. I had written the
entry without them.

Thanks to GitHub CodeQL code scanning and xet7 !
2026-08-08 09:27:28 +03:00
Lauri Ojansivu 74223ce69f CHANGELOG: the CodeQL identity-replacement fix, under Upcoming.
Its own SECURITY ISSUES subsection, first in the release as CLAUDE.md prescribes,
so the dependency subsection that used to open it becomes an "and updates".

Thanks to GitHub CodeQL code scanning and xet7 !
2026-08-08 09:27:28 +03:00
Lauri Ojansivu f3a12533d7 CodeQL js/identity-replacement: a replacement that replaced a hyphen with itself.
Code scanning alert #431, tests/releaseNodeSources.test.cjs:

    new RegExp(`^\s*${p.replace('-', '-')}\)\s+nodename=`, 'm')

It reads as "escape the hyphen before interpolating this into a regex" and does
nothing whatsoever, so the value went in raw. Nothing failed, because a hyphen
outside a character class needs no escaping - but the guard it looked like was
not there, and a platform name carrying a `.` or a `+` would have matched the
wrong row or thrown. CWE-116, and CodeQL is right to call it out: the usual cause
of the shape is a mistyped backslash escape, where '\"' is silently just '"'.

The platform name is escaped for real now, with the same escapeRegExp the other
guards in tests/ use.

tests/noIdentityReplacement.test.cjs catches the class rather than the instance,
because code scanning reports these days later in a web UI and the node suites
report in fifteen seconds. Three things it took to make it honest:

  * it compares the two sides as VALUES, not as source text. '\"' IS '"' - that
    is the entire point of CodeQL's example - so a text comparison would call
    them different and miss the mistake the guard exists for.
  * the two quote styles are separate alternatives, not a backreference with a
    character class excluding both quotes: `replace('"', '\"')` contains a double
    quote inside a single-quoted literal, so the first shape of the pattern could
    not match the very example above. Caught by asserting the pattern against
    that line rather than trusting it.
  * comments are stripped and the guard skips its own file: this one and the file
    it was written for both QUOTE the bad line to explain it, and a guard that
    reports the sentence describing a bug as the bug gets switched off.

Verified in both directions: the repository is clean now, the same scan against
the previous commit reports the offending line, all three bad shapes are detected
and four working replacements - including a real escapeRegExp - are not.

Thanks to xet7 !
2026-08-08 09:27:28 +03:00
104 changed files with 9419 additions and 893 deletions

No files matched your search

+69 -10
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:
@@ -92,7 +92,12 @@ jobs:
run: |
set -euo pipefail
tag='${{ inputs.tag }}'
[ -n "$tag" ] || tag="$(gh release list --limit 1 --json tagName --jq '.[0].tagName')"
# --repo on every gh call, whether or not the job happens to have
# checked out: a call that relies on an inferred remote breaks the day
# somebody removes the checkout, and the error it gives - "fatal: not
# a git repository" - names neither gh nor the workflow.
[ -n "$tag" ] || tag="$(gh release list --repo "${{ github.repository }}" \
--limit 1 --json tagName --jq '.[0].tagName')"
if [ -z "$tag" ]; then
echo "::error::No release to wrap, and no tag was given."
exit 1
@@ -107,6 +112,9 @@ jobs:
name: AppImage ${{ matrix.appimage_arch }}
needs: release
runs-on: ${{ matrix.runs_on }}
# A 32-bit job that cannot run on the runner it was given must not fail the
# release for the architectures that built fine.
continue-on-error: ${{ matrix.tolerate_failure || false }}
timeout-minutes: 60
strategy:
fail-fast: false
@@ -118,6 +126,28 @@ jobs:
- appimage_arch: aarch64
bundle_arch: arm64
runs_on: ubuntu-24.04-arm
# The other two architectures an AppImage runtime EXISTS for. There
# are four in total - x86_64, aarch64, i686, armhf - and that is the
# whole limit: the runtime is the ELF the kernel executes, so an
# architecture with no runtime cannot have an AppImage at all. That is
# why ppc64le, s390x, riscv64 and loong64 are not here and never will
# be; they are served by the bundle zip and the .deb.
#
# i686 builds on the x86_64 runner, which runs 32-bit x86 natively -
# no emulation, no multiarch, and appimagetool-i686 runs as-is.
- appimage_arch: i686
bundle_arch: i386
runs_on: ubuntu-24.04
# armhf builds on the aarch64 runner, which runs 32-bit ARM only if
# the kernel was built with CONFIG_COMPAT and the 32-bit loader is
# installed. GitHub does not promise either, so this one is allowed to
# fail without taking the release with it - the same treatment TSC
# gives its emulated armhf job. If it turns out to work reliably,
# drop the flag; if it never does, drop the entry.
- appimage_arch: armhf
bundle_arch: armhf
runs_on: ubuntu-24.04-arm
tolerate_failure: true
# WHICH entries this run wants, decided here because it cannot be decided
# where it belongs. A job-level `if:` cannot see `matrix` - GitHub exposes
# that context to runs-on, env, name, steps and a few others, but not to a
@@ -130,7 +160,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
@@ -163,7 +193,8 @@ jobs:
run: |
set -euo pipefail
zip="wekan-${{ needs.release.outputs.version }}-${{ matrix.bundle_arch }}.zip"
gh release download '${{ needs.release.outputs.tag }}' --pattern "$zip" --clobber
gh release download '${{ needs.release.outputs.tag }}' \
--repo "${{ github.repository }}" --pattern "$zip" --clobber
unzip -q "$zip"
# A zip that exists is not a bundle that runs; these are the two files
# the AppImage is built around.
@@ -281,6 +312,22 @@ jobs:
# for WeKan to answer on its port, then stop it - so a broken bundle
# is caught here rather than by whoever downloads it.
export WRITABLE_PATH="$PWD/smoke-data" PORT=8080
# A CHECK THAT CANNOT RUN IS NOT A FAILED CHECK. The 32-bit builds are
# packed on a 64-bit runner, which executes them only if the kernel and
# loader for that word size are present - GitHub promises neither. If
# the binary cannot be executed AT ALL, the AppImage is as good as
# anything here knows and the release must not be withheld for it; say
# so and move on. Anything else - it starts and does not answer - is
# the bug this step exists for and still fails.
if ! APPIMAGE_EXTRACT_AND_RUN=1 "$app" --appimage-help >/dev/null 2>&1 \
&& ! APPIMAGE_EXTRACT_AND_RUN=1 "$app" --help >/dev/null 2>&1; then
if ! file "$app" | grep -q "$(uname -m)"; then
echo "::warning::this runner cannot execute a ${{ matrix.appimage_arch }} binary, so the AppImage was packed but not started. It is uploaded unchecked."
exit 0
fi
fi
APPIMAGE_EXTRACT_AND_RUN=1 "$app" > smoke.log 2>&1 &
pid=$!
ok=false
@@ -298,7 +345,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 +358,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
@@ -324,7 +371,11 @@ jobs:
set -euo pipefail
ls -lh assets 2>/dev/null || true
if ls assets/*.AppImage >/dev/null 2>&1; then
for want in x86_64 aarch64; do
# Every architecture the matrix builds. A missing one WARNS rather
# than fails: the 32-bit pair is allowed not to build, and an
# architecture whose base bundle was never published has nothing to
# wrap. What must not happen silently is one going missing unnoticed.
for want in x86_64 aarch64 i686 armhf; do
ls assets/*"-$want.AppImage" >/dev/null 2>&1 || \
echo "::warning::no AppImage for $want - see that build job's log"
done
@@ -334,7 +385,7 @@ jobs:
# build jobs skip (not fail) when the base bundle is not published yet.
published="$(gh release view '${{ needs.release.outputs.tag }}' --repo "${{ github.repository }}" --json assets --jq '.assets[].name' 2>/dev/null || true)"
base_present=false
for arch in amd64 arm64; do
for arch in amd64 arm64 i386 armhf; do
printf '%s\n' "$published" | grep -qx "wekan-${{ needs.release.outputs.version }}-${arch}.zip" && base_present=true
done
if [ "$base_present" = false ]; then
@@ -354,5 +405,13 @@ jobs:
echo "Nothing to upload (no base bundle was published to wrap)."
exit 0
fi
gh release upload '${{ needs.release.outputs.tag }}' assets/* --clobber
echo "Uploaded to $(gh release view '${{ needs.release.outputs.tag }}' --json url --jq .url)"
# --repo, because this job does NOT check the repository out - it only
# downloads artifacts - so `gh` has no git remote to infer the repo
# from and fails with "fatal: not a git repository". Every other gh
# call in this workflow already passes it; these two did not, so the
# AppImages were built, smoke-tested and uploaded as artifacts, and
# then nothing was attached to the release.
gh release upload '${{ needs.release.outputs.tag }}' assets/* \
--repo "${{ github.repository }}" --clobber
echo "Uploaded to $(gh release view '${{ needs.release.outputs.tag }}' \
--repo "${{ github.repository }}" --json url --jq .url)"
+38 -11
View File
@@ -24,6 +24,15 @@ name: Flatpak
# and WeKan publishes Linux bundles for amd64 and arm64, so those are the two.
# Each builds on a runner of its own CPU rather than under emulation.
#
# AND THAT IS THE WHOLE LIST - there is no third to add. A flatpak runs against
# a RUNTIME, not against the host's libraries, so an architecture exists here
# only if freedesktop.org publishes org.freedesktop.Platform for it. It does so
# for x86_64 and aarch64; the i386 and arm runtimes were discontinued. That is
# the difference from AppImage.yml, which was able to grow from two
# architectures to four: an AppImage carries its own runtime binary, and
# runtimes exist for i686 and armhf as well. WeKan's ppc64le, s390x, riscv64,
# loong64 and 32-bit bundles are served by the zip and the .deb.
#
# THE SANDBOX. WeKan is a server you reach in a browser, so it needs the network
# and a place to keep its data, and nothing else - no X11, no audio, no access
# to your files. --share=network and --persist=.wekan are the whole permission
@@ -89,14 +98,18 @@ 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 }}
run: |
set -euo pipefail
tag='${{ inputs.tag }}'
[ -n "$tag" ] || tag="$(gh release list --limit 1 --json tagName --jq '.[0].tagName')"
# --repo on every gh call, whether or not the job happens to have
# checked out: one that relies on an inferred remote breaks the day
# somebody removes the checkout.
[ -n "$tag" ] || tag="$(gh release list --repo "${{ github.repository }}" \
--limit 1 --json tagName --jq '.[0].tagName')"
if [ -z "$tag" ]; then
echo "::error::No release to wrap, and no tag was given."
exit 1
@@ -133,7 +146,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
@@ -154,7 +167,8 @@ jobs:
run: |
set -euo pipefail
zip="wekan-${{ needs.release.outputs.version }}-${{ matrix.bundle_arch }}.zip"
gh release download '${{ needs.release.outputs.tag }}' --pattern "$zip" --clobber
gh release download '${{ needs.release.outputs.tag }}' \
--repo "${{ github.repository }}" --pattern "$zip" --clobber
unzip -q "$zip"
for f in bundle/main.js bundle/node bundle/ferretdb bundle/start-wekan.sh; do
if [ ! -e "$f" ]; then
@@ -273,14 +287,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 +307,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
@@ -318,11 +332,24 @@ jobs:
GH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
gh release upload '${{ needs.release.outputs.tag }}' assets/* --clobber
echo "Uploaded to $(gh release view '${{ needs.release.outputs.tag }}' --json url --jq .url)"
# --repo, because this job does NOT check the repository out - it
# only downloads artifacts - so `gh` has no git remote to infer the
# repo from and fails with "fatal: not a git repository". The same
# line was wrong in AppImage.yml for the same reason.
#
# And the bundles BY NAME, not assets/*: the artifacts also carry the
# ostree repository the flatpak was exported through - config,
# objects/, refs/, summaries/, summary, summary.idx - which is build
# scaffolding, not something to attach to a release. TSC hit this
# exact thing ("upload only the bundle files, not the ostree repo").
gh release upload '${{ needs.release.outputs.tag }}' \
assets/*.flatpak assets/*.flatpak.md5sum assets/*.flatpak.sha256sum \
--repo "${{ github.repository }}" --clobber
echo "Uploaded to $(gh release view '${{ needs.release.outputs.tag }}' \
--repo "${{ github.repository }}" --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 +422,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
+4
View File
@@ -87,3 +87,7 @@ jobs:
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
# Full build provenance (SLSA), not the minimal default that BuildKit
# attaches on its own. See release-all.yml for what it is and why the
# manifest list gains an `unknown/unknown` entry per platform.
provenance: mode=max
+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 }}
+82 -21
View File
@@ -622,13 +622,23 @@ jobs:
sha256sum "wekan-${VERSION}-amd64.zip" > "wekan-${VERSION}-amd64.zip.sha256sum"
cat "wekan-${VERSION}-amd64.zip.sha256sum"
# Where this bundle's binaries came from, for the release notes.
mkdir -p provenance
PROVENANCE_FILE=provenance/amd64.tsv bash releases/record-provenance.sh \
#
# $GITHUB_WORKSPACE, not a relative path: this step ran `cd .build`
# above, so `mkdir -p provenance` made .build/provenance while the
# upload-artifact below looks at provenance/ from the workspace root.
# It found nothing - "No files were found with the provided path:
# provenance/" - and amd64, the platform most people download, was the
# one bundle missing from the v10.77 release-notes table. Every other
# build job records from the workspace root, which is why only this one
# was affected.
mkdir -p "$GITHUB_WORKSPACE/provenance"
PROVENANCE_FILE="$GITHUB_WORKSPACE/provenance/amd64.tsv" bash releases/record-provenance.sh \
'amd64' 'Node.js' "${NODE_FROM:-}" "${NODE_FULL:-$NODE_VERSION}" \
"${NODE_URL:-}" \
"${NODE_SHA256:-}" || true
PROVENANCE_FILE=provenance/amd64.tsv bash releases/record-provenance.sh \
'amd64' 'FerretDB' 'wekan/FerretDB' 'latest' \
FERRET_TAG="$(bash releases/ferretdb-latest-tag.sh)"
PROVENANCE_FILE="$GITHUB_WORKSPACE/provenance/amd64.tsv" bash releases/record-provenance.sh \
'amd64' 'FerretDB' 'wekan/FerretDB' "${FERRET_TAG:-latest}" \
'https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-amd64' \
"${FERRET_SHA:-}" || true
# A zip that EXISTS is not a zip that WORKS, and the log should say which of the
@@ -659,7 +669,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/
@@ -832,8 +842,9 @@ jobs:
'arm64' 'Node.js' "${NODE_FROM:-}" "${NODE_FULL:-$NODE_VERSION}" \
"${NODE_URL:-}" \
"${NODE_SHA256:-}" || true
FERRET_TAG="$(bash releases/ferretdb-latest-tag.sh)"
PROVENANCE_FILE=provenance/arm64.tsv bash releases/record-provenance.sh \
'arm64' 'FerretDB' 'wekan/FerretDB' 'latest' \
'arm64' 'FerretDB' 'wekan/FerretDB' "${FERRET_TAG:-latest}" \
'https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-arm64' \
"${FERRET_SHA:-}" || true
# A zip that EXISTS is not a zip that WORKS, and the log should say which of the
@@ -864,7 +875,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 +932,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 +942,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
@@ -1219,8 +1230,9 @@ jobs:
"${node_url:-}" \
"${node_sha256:-}" || true
if [ -s bundle/ferretdb.exe ]; then
FERRET_TAG="$(bash src/releases/ferretdb-latest-tag.sh)"
PROVENANCE_FILE=provenance/win64.tsv bash src/releases/record-provenance.sh \
'win64' 'FerretDB' 'wekan/FerretDB' 'latest' \
'win64' 'FerretDB' 'wekan/FerretDB' "${FERRET_TAG:-latest}" \
'https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-win64.exe' \
"${FERRET_SHA:-}" || true
fi
@@ -1561,8 +1573,9 @@ jobs:
"${node_url:-}" \
"${node_sha256:-}" || true
if [ -s bundle/ferretdb.exe ]; then
FERRET_TAG="$(bash src/releases/ferretdb-latest-tag.sh)"
PROVENANCE_FILE=provenance/win32.tsv bash src/releases/record-provenance.sh \
'win32' 'FerretDB' 'wekan/FerretDB' 'latest' \
'win32' 'FerretDB' 'wekan/FerretDB' "${FERRET_TAG:-latest}" \
'https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-win32.exe' \
"${FERRET_SHA:-}" || true
fi
@@ -1805,8 +1818,9 @@ jobs:
"${node_url:-}" \
"${node_sha256:-}" || true
if [ -s bundle/ferretdb ]; then
FERRET_TAG="$(bash releases/ferretdb-latest-tag.sh)"
PROVENANCE_FILE=provenance/mac-arm64.tsv bash releases/record-provenance.sh \
'mac-arm64' 'FerretDB' 'wekan/FerretDB' 'latest' \
'mac-arm64' 'FerretDB' 'wekan/FerretDB' "${FERRET_TAG:-latest}" \
'https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-mac-arm64' \
"${FERRET_SHA:-}" || true
fi
@@ -2057,8 +2071,9 @@ jobs:
"${node_url:-}" \
"${node_sha256:-}" || true
if [ -s bundle/ferretdb ]; then
FERRET_TAG="$(bash releases/ferretdb-latest-tag.sh)"
PROVENANCE_FILE=provenance/mac-x64.tsv bash releases/record-provenance.sh \
'mac-x64' 'FerretDB' 'wekan/FerretDB' 'latest' \
'mac-x64' 'FerretDB' 'wekan/FerretDB' "${FERRET_TAG:-latest}" \
'https://github.com/wekan/FerretDB/releases/latest/download/ferretdb-mac-amd64' \
"${FERRET_SHA:-}" || true
fi
@@ -2290,7 +2305,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
@@ -2421,7 +2436,7 @@ jobs:
else
echo "::warning::wekan/FerretDB publishes no checksum for ferretdb-${{ matrix.ferretdb_arch }} on this release, so it is unverified."
fi
FERRET_TAG="$(curl -fsSL https://api.github.com/repos/wekan/FerretDB/releases/latest | python3 -c 'import json,sys; print(json.load(sys.stdin).get("tag_name",""))' || true)"
FERRET_TAG="$(bash releases/ferretdb-latest-tag.sh)"
bash releases/record-provenance.sh '${{ matrix.arch }}' 'FerretDB' \
'wekan/FerretDB' "${FERRET_TAG:-latest}" "$FERRET_URL" "$FERRET_SHA"
# Overwrite the inherited amd64 MongoDB Database Tools with this arch's ones.
@@ -2490,7 +2505,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 }}
@@ -2966,7 +2981,21 @@ jobs:
# manifest yet, so it ships as a .zip bundle only. Every candidate arch
# is in debian:trixie's manifest AND has a fork Node the Dockerfile can
# install.
# --provenance=mode=max: full build provenance (SLSA), not the
# minimal default - what built this image, from which commit, with
# which base and build arguments. It rides in the manifest list as an
# `unknown/unknown` entry per platform, the OCI marker for "not a
# runnable image" so `docker pull` never selects it, which is why Quay
# lists one beside each real platform. mode=max makes those entries
# BIGGER, not fewer; `--provenance=false` is what removes them, at the
# cost of the evidence.
#
# The comment is here and not among the arguments: a `#` after a line
# continuation comments out the rest of the joined line, leaving
# `docker buildx build` with no platforms and no tags. This block is a
# shell script like any other, and it was written the wrong way first.
docker buildx build \
--provenance=mode=max \
--platform "${PLATFORMS}" \
--build-arg VERSION=${VERSION} \
-t wekanteam/wekan:v${VERSION} \
@@ -3521,6 +3550,19 @@ jobs:
fi
done
echo "::error::Remote build ${{ matrix.arch }} failed after $attempts attempts (no wekan_${VERSION}_${{ matrix.arch }}.snap produced). The Launchpad build log printed above says why; the builds are also at https://launchpad.net/~xet7/+snaps" >&2
# A DETERMINISTIC failure, named before the guesses below. Three
# identical attempts on ONE architecture while the others build on
# attempt 1 is not the build farm having a bad day - and the guidance
# below ("often an OOM, or a transient reset; re-run") sent v10.77's
# armhf failure down exactly that path. snapcraft ends a missing
# stage-package as a "Stopped" build with no artifact, which looks
# identical to a cancelled one from the outside; the difference is one
# line in the log, so say it here rather than leave it to be found.
if grep -qs 'Stage package not found' snapcraft-wekan-*.txt; then
missingpkg="$(grep -hs -o "Stage package not found in part '[^']*': [^ ]*" snapcraft-wekan-*.txt | sort -u | tr '\n' ' ')"
echo "::error::This is NOT a transient failure and re-running will not fix it: ${missingpkg}. A stage-package that resolves on the 64-bit architectures can be missing on ${{ matrix.arch }} - Ubuntu 24.04's 64-bit time_t transition renamed libraries to a t64 suffix, and the renamed package carries 'Provides: <old name>' on amd64/arm64 but NOT on armhf, where the ABI really changed. Name the real package in snapcraft.yaml (libssl3 -> libssl3t64, libcurl4 -> libcurl4t64, libgoogle-perftools4 -> libgoogle-perftools4t64). Check a name against the archive: curl -fsSL http://ports.ubuntu.com/ubuntu-ports/dists/noble/main/binary-armhf/Packages.gz | gunzip -c | grep '^Package: <name>$'" >&2
exit 1
fi
# A "Stopped" build with no artifact is Launchpad ENDING the build
# itself, not a compile error in it. Two causes, in the order to check:
# 1. The auto-generated Launchpad PROJECT (xet7-craft-remote-build)
@@ -3563,11 +3605,29 @@ jobs:
run: |
set -uo pipefail
mkdir -p snap-logs
cp -f snapcraft-wekan-*.txt snap-logs/ 2>/dev/null || true
cp -f "$HOME"/.local/state/snapcraft/log/*.log snap-logs/ 2>/dev/null || true
# RENAMED, not just copied. snapcraft names a remote-build log after the
# recipe and an ISO timestamp:
# snapcraft-wekan-f82a93c2…_s390x_2026-08-09T11:39:05.txt
# and upload-artifact REFUSES a colon - "Invalid characters include:
# Double quote, Colon, Less than, ..." - because NTFS cannot hold one.
# That failed the whole job for s390x, ppc64el and riscv64 in v10.77
# AFTER all three snaps had built and downloaded on attempt 1, so three
# good builds were reported as FAILED by the step that saves their logs.
# The timestamp is worth keeping, so the colons become dashes rather
# than the name being thrown away.
for f in snapcraft-wekan-*.txt "$HOME"/.local/state/snapcraft/log/*.log; do
[ -f "$f" ] || continue
base="$(basename "$f")"
cp -f "$f" "snap-logs/${base//:/-}" || true
done
ls -la snap-logs || true
- uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v7
# A DIAGNOSTIC upload can never fail the build it is diagnosing. The snap
# is the deliverable; these logs are here so a failure can be read
# afterwards, and the renaming above is not the last thing that could
# ever make one of them unuploadable.
continue-on-error: true
if: always()
with:
name: snap-launchpad-logs-${{ matrix.arch }}
@@ -4002,6 +4062,7 @@ jobs:
set -euo pipefail
cd exapp
docker buildx build --push \
--provenance=mode=max \
-t ghcr.io/wekan/wekan-exapp:v${VERSION} \
-t ghcr.io/wekan/wekan-exapp:latest \
--build-arg WEKAN_VERSION=${VERSION} .
@@ -4089,9 +4150,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-*
+1154 -1
View File
File diff suppressed because it is too large. Load diff
+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.72
ARG VERSION=10.78
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.72.0"
appVersion: "v10.78.0"
files:
userUploads:
- README.md
+32 -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
@@ -301,6 +303,7 @@ if "%choice%"=="3" call :rel_run "releases/snap-push-to-store.sh" "Path to the .
if "%choice%"=="4" call :rel_run "releases/release-snap.sh" "WeKan version, e.g. 10.50"
if "%choice%"=="5" call :rel_run "releases/snap-store-revisions.sh" ""
if "%choice%"=="6" call :rel_run "releases/snap-store-release-revision-to-channels.sh" "Snap Store revision number"
if "%choice%"=="7" call :rel_run "releases/snap-release-all-channels.sh" "Version, or empty for the newest"
if "%choice%"=="7" call :rel_run "releases/snap-edge.sh" ""
if "%choice%"=="8" call :rel_run "releases/snap-stable.sh" ""
if "%choice%"=="9" call :rel_run "releases/snapcraft-help.sh" ""
@@ -558,6 +561,7 @@ echo snap-push-to-store Push one .snap to the Snap Store ^<P
echo release-snap Release the snap for one version ^<WeKan version, e.g. 10.50^>
echo snap-store-revisions List the newest Snap Store revisions
echo snap-store-release-revision-to-channels Release one store revision to edge, beta and candidate ^<Snap Store revision number^>
echo snap-release-all-channels Release every snap, every architecture, to all four channels ^<Version, or empty for the newest^>
echo snap-edge Switch the installed snap to the edge channel
echo snap-stable Switch the installed snap to the stable channel
echo snapcraft-help snapcraft help topics
@@ -640,6 +644,7 @@ if /I "%K%"=="snap-push-to-store" (set "CMD=bash releases/snap-push-to-store.sh"
if /I "%K%"=="release-snap" (set "CMD=bash releases/release-snap.sh" ^& goto cli_go)
if /I "%K%"=="snap-store-revisions" (set "CMD=bash releases/snap-store-revisions.sh" ^& goto cli_go)
if /I "%K%"=="snap-store-release-revision-to-channels" (set "CMD=bash releases/snap-store-release-revision-to-channels.sh" ^& goto cli_go)
if /I "%K%"=="snap-release-all-channels" (set "CMD=bash releases/snap-release-all-channels.sh" ^& goto cli_go)
if /I "%K%"=="snap-edge" (set "CMD=bash releases/snap-edge.sh" ^& goto cli_go)
if /I "%K%"=="snap-stable" (set "CMD=bash releases/snap-stable.sh" ^& goto cli_go)
if /I "%K%"=="snapcraft-help" (set "CMD=bash releases/snapcraft-help.sh" ^& goto cli_go)
@@ -674,6 +679,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 +811,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
+158 -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: '
@@ -1775,6 +1873,7 @@ RELEASE_SCRIPTS=( "Release|Release ALL platforms: push CHANGELOG, trigger releas
"Snap|Release the snap for one version|releases/release-snap.sh|WeKan version, e.g. 10.50||"
"Snap|List the newest Snap Store revisions|releases/snap-store-revisions.sh|||"
"Snap|Release one store revision to edge, beta and candidate|releases/snap-store-release-revision-to-channels.sh|Snap Store revision number||"
"Snap|Release every snap, every architecture, to all four channels|releases/snap-release-all-channels.sh|Version, or empty for the newest||snap-release-all-channels"
"Snap|Switch the installed snap to the edge channel|releases/snap-edge.sh|||"
"Snap|Switch the installed snap to the stable channel|releases/snap-stable.sh|||"
"Snap|snapcraft help topics|releases/snapcraft-help.sh|||"
@@ -1813,6 +1912,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 +2122,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 +2265,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`
@@ -9,9 +9,13 @@ and how the one-time MongoDB 3 → FerretDB v1 migration works.
- **New installs use FerretDB v1 (SQLite) by default on every CPU architecture.**
FerretDB v1 is the wekan/FerretDB fork with an embedded pure-Go SQLite backend
that speaks the MongoDB wire protocol, so WeKan talks to it exactly like MongoDB.
- **Six architectures are built as snaps**: amd64, arm64, armhf, ppc64el, riscv64
and s390x — exactly `snapcraft.yaml`'s `build-for:` list. i386 and armv7 are
release bundles but **not** snaps, for reasons that are not oversights; see
[CPU architecture names](#cpu-architecture-names-and-what-they-mean).
- **MongoDB 7 *server* binaries (mongod) are included for amd64 and arm64 only.**
The other architectures (s390x, riscv64, ppc64le) have no MongoDB server — they
run FerretDB v1 only.
The other architectures (armhf, ppc64el, riscv64, s390x) have no MongoDB server
— they run FerretDB v1 only.
- **Every Snap platform includes the FerretDB v1 server and the MongoDB Database
Tools** (`mongodump`, `mongorestore`, `mongofiles`, `mongoexport`, …), on every
architecture.
@@ -30,13 +34,20 @@ and how the one-time MongoDB 3 → FerretDB v1 migration works.
## Per-architecture matrix
| CPU arch | MongoDB 7 server (mongod) | MongoDB Database Tools | FerretDB v1 server | MongoDB 3 → FerretDB migration | Default DB (new install) |
|----------|:------------------------:|:----------------------:|:------------------:|:------------------------------:|:------------------------:|
| amd64 | ✅ | ✅ | ✅ | ✅ (if MongoDB 3 data present) | FerretDB v1 |
| arm64 | ✅ | ✅ | ✅ | ❌ | FerretDB v1 |
| s390x | ❌ | ✅ | ✅ | ❌ | FerretDB v1 |
| ppc64le | ❌ | ✅ | ✅ | ❌ | FerretDB v1 |
| riscv64 | ❌ | ✅ | ✅ | ❌ | FerretDB v1 |
| Snap arch | Bundle | MongoDB 7 server (mongod) | MongoDB Database Tools | FerretDB v1 server | MongoDB 3 → FerretDB migration | Default DB (new install) |
|-----------|--------|:------------------------:|:----------------------:|:------------------:|:------------------------------:|:------------------------:|
| amd64 | `amd64` | ✅ | ✅ | ✅ | ✅ (if MongoDB 3 data present) | FerretDB v1 |
| arm64 | `arm64` | ✅ | ✅ | ✅ | ❌ | FerretDB v1 |
| armhf | `armhf` | ❌ | ✅ | ✅ | ❌ | FerretDB v1 |
| ppc64el | `ppc64le` | ❌ | ✅ | ✅ | ❌ | FerretDB v1 |
| riscv64 | `riscv64` | ❌ | ✅ | ✅ | ❌ | FerretDB v1 |
| s390x | `s390x` | ❌ | ✅ | ✅ | ❌ | FerretDB v1 |
These six are exactly the `build-for:` entries in `snapcraft.yaml`. The **Bundle**
column is the release-asset name at
[github.com/wekan/wekan/releases](https://github.com/wekan/wekan/releases), which
is not always the Snap Store's name for the same hardware — see
[CPU architecture names](#cpu-architecture-names-and-what-they-mean).
- **MongoDB 7 server (mongod)**: amd64/arm64 only (MongoDB ships no server for the others).
- **MongoDB Database Tools**: every arch — WeKan builds them for all platforms from
@@ -47,6 +58,60 @@ and how the one-time MongoDB 3 → FerretDB v1 migration works.
All architectures default new installs to FerretDB v1 with `DDP_TRANSPORT=sockjs`
and polling reactivity.
## CPU architecture names and what they mean
An architecture can go by one name in the release bundles and another in the Snap
Store, and two names that look like variants of each other can be different
builds. Nothing warns about either: an unrecognised `--arch` is simply an
architecture the store has never heard of, so a wrong name looks like it worked.
The mapping is kept in one place, `models/lib/snapArchitectures.js`, and is unit
tested (`tests/snapArchitectures.test.cjs`).
### ppc64le and ppc64el are the same hardware
64-bit little-endian POWER. The bundles name it the way Node.js and the kernel do
(`ppc64le`); the Snap Store names it the way Debian does (`ppc64el`). It is the
**only** architecture whose two names differ, and a test asserts that stays true.
### armhf and armv7 are the same CPU family, built to different baselines
This one is not a naming difference, and getting it wrong ships a snap that
crashes. [wekan/node-patches](https://github.com/wekan/node-patches) builds two
32-bit ARM Node.js binaries:
| Bundle | Built for | Runs on |
|--------|-----------|---------|
| `armhf` | hard-float, **VFPv3-D16**, no NEON assumed — the Debian armhf baseline | **any** ARMv7-A board |
| `armv7` | hard-float, **NEON**, ARMv7-A tuned | only boards that have NEON (an ODroid-U3, say) |
The Snap Store has **one** 32-bit ARM architecture, `armhf`, and it serves every
such device. It must therefore carry the **baseline** build: the NEON one would
be an illegal instruction on any armhf board without NEON. So the armhf snap uses
the `armhf` bundle, and **`armv7` has no snap** — it ships as a bundle only. A
separate snap name or track would be the only way to offer the NEON build, which
is not worth a second listing for one board family.
### i386 has no snap, and cannot have a new one
core24 is Ubuntu 24.04, which has **no i386 port**, so there is no i386 core24
base snap. `build-on: i386` is not merely unsupported — it is a **parse error**,
and because snapcraft parses the whole file first it failed *every* architecture's
build, not only i386's. This is not something a patch set can fix the way
[node-patches](https://github.com/wekan/node-patches) fixes a missing Node.js
build: node-patches patches source so a binary *can* be built, whereas here the
**base snap itself does not exist** for the architecture. The last base with a
real i386 port was `core18`, which is end-of-life.
The Snap Store still shows an i386 column for **wekan-ondra** (revision 70,
version `0.X-ci`) because the store keeps whatever was ever uploaded. It is years
stale, nothing new can be built for it, and re-releasing it would only re-publish
`0.X-ci`. **i386 users are served by the .deb and the AppImage.**
### loong64, win64, win32, mac-x64, mac-arm64
Bundle platforms with no Snap Store architecture at all. They are not snaps and
are not expected to be.
## New installs
On a fresh install, every architecture starts on **FerretDB v1 (SQLite)** with
@@ -151,18 +216,82 @@ bundle the snap's `wekan` part downloads, and staged to `$SNAP/ferretdb`
`<tool>-<arch>` from [wekan/mongo-tools-patches](https://github.com/wekan/mongo-tools-patches/releases),
and `mongosh-<arch>` from [wekan/mongosh](https://github.com/wekan/mongosh/releases).
## Snap channels
## Snaps and channels
The default `snapcraft.yaml` is built on `base: core24`, which allows the
**candidate** channel (core26 does not). The automated release workflow publishes
it to the **candidate**, **beta**, and **edge** channels; the **stable** channel is
published manually later, once it is proven.
`snapcraft-core26.yaml` is the same WeKan on the newer `base: core26`
(`build-base: devel`, `grade: devel`), kept for testing the next base; it can only
go to Beta/Edge until core26 is a released stable base.
There are **three snaps** of the same application, and each should reach **all
four channels on every architecture it is built for**:
| Snap | What it is |
|------|------------|
| [`wekan`](https://snapcraft.io/wekan) | the default |
| [`wekan-ondra`](https://snapcraft.io/wekan-ondra) | same WeKan, separate listing |
| [`wekan-gantt-gpl`](https://snapcraft.io/wekan-gantt-gpl) | same WeKan, separate listing |
**All four channels — Stable, Candidate, Beta and Edge.** The default
`snapcraft.yaml` is built on `base: core24`, a *released* base, so the snap carries
`grade: stable` and may be published to **stable** as well as the other three.
That is what makes the full set possible: a `grade: devel` snap is refused by
stable and candidate outright.
`snapcraft-core26.yaml` is the same WeKan on `base: core26`, kept for testing the
next base and **not** built by the release: core26 still requires
`build-base: devel` and therefore `grade: devel`, so it can only reach beta and
edge. That is the whole reason the release builds core24.
### Publishing to every channel
A revision is **per architecture** — the store's own listing shows `wekan` at
revision 3601 on amd64 and 3600 on arm64 for the same version 10.76 — so one
revision number can never be released to every architecture. Use:
```bash
releases/snap-release-all-channels.sh --dry-run # see the plan first
releases/snap-release-all-channels.sh 10.76 # pin the version
releases/snap-release-all-channels.sh # or the newest of each
```
It resolves the revision per (snap, architecture) from the store and releases it
to `stable,candidate,beta,edge` in **one** call, so a revision reaches all four
or none. A (snap, architecture) with no revision is reported and skipped — the
three snaps genuinely have different architecture sets today. It reads the
architecture table from `models/lib/snapArchitectures.js`, so there is no second
copy of it in shell.
**Pinning the version matters.** Without one, the newest revision of each
architecture is promoted — and edge is often ahead of stable, so a bare run
publishes edge builds to stable users. Pass the version, or `--dry-run` first.
### What each snap currently has, and what is still to add
As of WeKan 10.76 the store holds:
| Snap | amd64 | arm64 | armhf | ppc64el | riscv64 | s390x |
|------|:-----:|:-----:|:-----:|:-------:|:-------:|:-----:|
| `wekan` | ✅ | ✅ | — | ✅ | — | ✅ |
| `wekan-ondra` | ✅ | ✅ | ⚠️ | — | — | — |
| `wekan-gantt-gpl` | ✅ | ✅ | — | — | — | — |
- **—** the architecture is built by `snapcraft.yaml` but that snap has no
revision for it yet. Uploading one is all that is needed; the release script
then puts it on all four channels.
- **⚠️** `wekan-ondra`'s armhf is stuck at version **0.22**, and its i386 column at
**0.X-ci**, on all four channels — fossils the store kept from years ago.
Promoting channels cannot fix these: there is no newer revision to promote.
armhf needs a build uploaded for that snap; i386 cannot be built at all (see
[i386](#i386-has-no-snap-and-cannot-have-a-new-one)).
Publishing the **stable** channel is safe for these because the snap performs its
**automatic migrations** on start — the schema upgrades and the one-time
MongoDB 3 → FerretDB v1 migration on amd64 — so an existing installation moving
from an older revision to this one is upgraded in place rather than needing a
manual step.
## Related
- FerretDB v1 fork: https://github.com/wekan/FerretDB
- Snap install: [Install.md](Install.md)
- Snap settings keys: [Supported-settings-keys.md](Supported-settings-keys.md)
- The architecture table in code: `models/lib/snapArchitectures.js`
(tested by `tests/snapArchitectures.test.cjs`)
- Publish every snap to every channel: `releases/snap-release-all-channels.sh`
- Node.js builds per platform: [wekan/node-patches](https://github.com/wekan/node-patches)
@@ -10,7 +10,7 @@ This is without container (without Docker or Snap).
Right click and download files 1-4:
1. [wekan-10.72-win64.zip](https://github.com/wekan/wekan/releases/download/v10.72/wekan-10.72-win64.zip) or newest available at https://github.com/wekan/wekan/releases/
1. [wekan-10.78-win64.zip](https://github.com/wekan/wekan/releases/download/v10.78/wekan-10.78-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.72-win64.zip` , inside it is directory `bundle`, to it copy other files:
7. Unzip `wekan-10.78-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: '' };
+222
View File
@@ -0,0 +1,222 @@
'use strict';
// ============================================================================
// Snap names, channels, and the two names every architecture has
// ----------------------------------------------------------------------------
// WeKan publishes three snaps of the same application - `wekan`, and the
// `wekan-ondra` and `wekan-gantt-gpl` variants - and each must reach every
// architecture on every channel.
//
// Two things make this less obvious than "three names times eight arches".
//
// 1. ppc64le and ppc64el are the SAME hardware under two names. The release
// bundles at https://github.com/wekan/wekan are named the way Node.js and the
// kernel name things (`ppc64le`); the Snap Store names it the way Debian does
// (`ppc64el`). Nothing warns when the wrong one is used - an unrecognised
// --arch is simply an architecture the store has never heard of - so the
// rename is written down once, here.
//
// 2. armhf and armv7 are NOT two names for one thing, and must never be mapped
// onto each other. They are the same CPU family built to different
// baselines: node-patches builds `armhf` to the Debian armhf baseline
// (hard-float, VFPv3-D16, assuming NO NEON) so it runs on any ARMv7-A, and
// `armv7` with NEON for boards that have it. The Snap Store has ONE 32-bit
// ARM architecture serving every such device, so it must carry the BASELINE
// build; the NEON one would be an illegal instruction on a board without
// NEON. armv7 therefore has no snap - see NOT_SNAP_ARCHITECTURES below.
//
// The list is therefore the six architectures snapcraft.yaml actually declares a
// `build-for` for, not the eight the release bundles cover. Both directions are
// unit tested (tests/snapArchitectures.test.cjs).
//
// Pure and dependency-free: the shell scripts read it through node, so there is
// no second copy of this table in bash.
// ============================================================================
// The three snaps. Same application, three store listings.
const SNAP_NAMES = ['wekan', 'wekan-ondra', 'wekan-gantt-gpl'];
// Every channel a revision is released to. `stable` is included deliberately:
// the old helper released to `edge,beta,candidate` only, so a build reached
// three of the four and somebody had to remember the fourth by hand.
const SNAP_CHANNELS = ['stable', 'candidate', 'beta', 'edge'];
// One row per architecture the Snap Store gets, matching snapcraft.yaml's
// `build-for` list exactly. `bundle` is null where the release bundle goes by
// the same name - all but one - so the single rename stands out.
const ARCHITECTURES = [
{ snap: 'amd64', bundle: null },
{ snap: 'arm64', bundle: null },
{ snap: 'armhf', bundle: null }, // 32-bit Raspberry Pi OS
{ snap: 'ppc64el', bundle: 'ppc64le' }, // the store spells POWER the Debian way
{ snap: 'riscv64', bundle: null },
{ snap: 's390x', bundle: null },
];
// Architectures WeKan SHIPS but does not BUILD a snap for, and why.
//
// "Does not build" is not the same as "the store has no column": the store keeps
// whatever was ever uploaded, so an architecture can appear there with an
// ancient revision on every channel long after it stopped being buildable.
// That is what makes these worth writing down rather than inferring from the
// store's own listing.
// Written down because "it is missing" and "it cannot be there" look identical
// from the outside, and somebody re-adding one of these would be repeating a
// mistake that has already been made.
const NOT_SNAP_ARCHITECTURES = {
i386:
'core24 (Ubuntu 24.04) has no i386 port, so snapcraft rejects `build-on: i386` - ' +
'and because that is a PARSE error it failed EVERY architecture\'s snap build, not ' +
'only i386\'s. The STORE still holds old i386 revisions (wekan-ondra has one, at ' +
'version 0.X-ci, sitting on all four channels), so the column exists there and is ' +
'years stale; nothing new can be built for it, and re-releasing what is there would ' +
'only re-publish 0.X-ci. i386 users are served by the .deb and the AppImage.',
armv7:
'the same CPU family as armhf, tuned differently: node-patches builds armhf to the ' +
'Debian armhf baseline (hard-float, VFPv3-D16, NO NEON assumed) so it runs on any ' +
'ARMv7-A, and armv7 with NEON for boards that have it (an ODroid-U3, say). The Snap ' +
'Store has ONE 32-bit ARM architecture and it serves every such device, so it must ' +
'carry the BASELINE build - shipping the NEON one would be an illegal instruction on ' +
'any armhf board without NEON. armv7 ships as a bundle only.',
};
/** Every Snap Store architecture, in a stable order. */
function snapArchitectures() {
return ARCHITECTURES.map(a => a.snap);
}
/** Every release-bundle architecture name, in the same order. */
function bundleArchitectures() {
return ARCHITECTURES.map(a => a.bundle || a.snap);
}
/**
* The Snap Store's name for a bundle architecture.
*
* Accepts either spelling, so a caller that already has the store's name is not
* punished for it - `snapArchOf('ppc64el')` is `ppc64el`. Unknown input returns
* '' rather than being passed through: an architecture nobody recognises must
* not reach `snapcraft release`, where it would be a silent no-op.
*
* @param {string} name
* @return {string} the Snap Store architecture, or '' when it is not one
*/
function snapArchOf(name) {
if (typeof name !== 'string' || !name) return '';
const key = name.trim().toLowerCase();
const row = ARCHITECTURES.find(a => a.snap === key || a.bundle === key);
if (row) return row.snap;
// Names the same hardware also goes by upstream. armv7/armv7l is NOT here:
// it is a different CPU from armhf, not another spelling of it.
const ALIASES = { x64: 'amd64', 'x86_64': 'amd64', aarch64: 'arm64' };
return ALIASES[key] || '';
}
/**
* The release bundle's name for an architecture. Same tolerance in reverse.
* @param {string} name
* @return {string} the bundle architecture, or '' when it is not one
*/
function bundleArchOf(name) {
const snap = snapArchOf(name);
if (!snap) return '';
const row = ARCHITECTURES.find(a => a.snap === snap);
return row.bundle || row.snap;
}
/**
* Every (snap, architecture) pair that has to be released, in a stable order.
* The channels are not part of the pair: one `snapcraft release` call takes them
* all at once, so a revision reaches all four or none.
* @return {Array<{snap: string, arch: string, bundleArch: string}>}
*/
function releaseTargets() {
const targets = [];
SNAP_NAMES.forEach(snap => {
ARCHITECTURES.forEach(a => {
targets.push({ snap, arch: a.snap, bundleArch: a.bundle || a.snap });
});
});
return targets;
}
/**
* Pick the revision to release for one architecture out of `snapcraft
* list-revisions` rows.
*
* Revisions are PER ARCHITECTURE - amd64 revision 42 and arm64 revision 42 are
* different uploads - which is why one revision number can never be released to
* every architecture at once, and why this exists at all.
*
* The highest revision wins, not the first row: the store's ordering is not
* something to depend on. When `version` is given, only that version's rows are
* considered, so a release publishes what it built rather than whatever was
* uploaded last.
*
* @param {Array<{revision: number, arch: string, version: string}>} rows
* @param {string} arch a Snap Store architecture
* @param {string} [version]
* @return {number|null}
*/
function pickRevision(rows, arch, version) {
if (!Array.isArray(rows)) return null;
const want = snapArchOf(arch);
if (!want) return null;
const candidates = rows.filter(r => {
if (!r || snapArchOf(r.arch) !== want) return false;
if (!Number.isFinite(Number(r.revision))) return false;
if (version && String(r.version) !== String(version)) return false;
return true;
});
if (!candidates.length) return null;
return candidates.reduce((best, r) => Math.max(best, Number(r.revision)), -Infinity);
}
/**
* Parse `snapcraft list-revisions` output.
*
* Its columns are `Rev. Uploaded Arch Version Channels`, the header is skipped,
* and a revision with no channels has an empty last column - so the parse keys
* on the leading revision number and takes the next three fields, rather than
* counting columns from the right.
*
* @param {string} text
* @return {Array<{revision: number, uploaded: string, arch: string, version: string, channels: string}>}
*/
function parseRevisions(text) {
if (typeof text !== 'string') return [];
return text
.split('\n')
.map(line => line.trim())
.filter(line => /^\d+\s/.test(line))
.map(line => {
const [revision, uploaded, arch, version, ...rest] = line.split(/\s+/);
return {
revision: Number(revision),
uploaded,
arch,
version,
channels: rest.join(' '),
};
})
.filter(r => r.arch && r.version);
}
module.exports = {
SNAP_NAMES,
SNAP_CHANNELS,
ARCHITECTURES,
NOT_SNAP_ARCHITECTURES,
snapArchitectures,
bundleArchitectures,
snapArchOf,
bundleArchOf,
releaseTargets,
pickRevision,
parseRevisions,
};
+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.72.0",
"version": "v10.78.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "wekan",
"version": "v10.72.0",
"version": "v10.78.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.72.0",
"version": "v10.78.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.72</title>
<title>WeKan REST API v10.78</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.72<!-- -->)</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.78<!-- -->)</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.72","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.78","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.72
version: v10.78
description: |
The REST API allows you to control and extend Wekan with ease.
+9
View File
@@ -89,7 +89,16 @@ echo " OK: No Go buildinfo binaries found"
'
echo "=== Step 3: Checks passed - building and pushing multi-arch image to registries ==="
# --provenance=mode=max: full build provenance (SLSA), matching release-all.yml.
# The local --load build above gets none - the docker exporter cannot carry
# attestations - so only this pushing build has it.
#
# The comment is HERE and not among the arguments below: a `#` after a line
# continuation comments out the REST OF THE JOINED LINE, so the command becomes
# `buildx build` with every argument swallowed. `bash -n` accepts it, because it
# is valid syntax - it is just a different command.
${DOCKER} buildx build \
--provenance=mode=max \
--platform linux/amd64,linux/arm64 \
-t wekanteam/wekan:v${VERSION} \
-t wekanteam/wekan:latest \
+78
View File
@@ -0,0 +1,78 @@
#!/bin/bash
#
# ferretdb-latest-tag.sh - which version `latest` is right now.
#
# WeKan fetches FerretDB from .../releases/latest/download/ferretdb-<arch>, so a
# security fix published in wekan/FerretDB reaches the next bundle without a
# commit here. The cost of that is that the URL does not say WHICH version was
# fetched, and the release notes have to say it: "which FerretDB did v10.77
# ship" must be answerable a year later, when `latest` is something else.
#
# The extra-architecture job already asked the API and recorded the answer. The
# amd64, arm64, win64, win32, mac-arm64 and mac-x64 jobs passed the literal
# string `latest` instead, so six rows of the v10.77 table - including amd64,
# the platform most people download - had "latest" in the Version column, which
# is the one question the column exists to answer.
#
# Usage: ferretdb-latest-tag.sh
#
# Prints the tag (e.g. v1.48.0) and exits 0. Prints NOTHING and still exits 0
# when it cannot find out: this is for a release note, and no caller should fail
# a build over it. Callers write "${TAG:-latest}", which degrades to exactly
# what they had before.
#
# The answer is cached for the life of the job, so six callers in one job make
# one request rather than six.
set -uo pipefail
cache="${FERRETDB_TAG_CACHE:-${RUNNER_TEMP:-${TMPDIR:-/tmp}}/ferretdb-latest-tag}"
if [ -s "$cache" ]; then
cat "$cache"
exit 0
fi
api="https://api.github.com/repos/wekan/FerretDB/releases/latest"
# -f so an HTTP error is a failure rather than an error page parsed as JSON.
# The token is used when there is one - an unauthenticated runner shares a
# 60/hour limit with every other job on that IP, and this is the failure that
# put "latest" in the table.
auth=()
if [ -n "${GH_TOKEN:-${GITHUB_TOKEN:-}}" ]; then
auth=(-H "Authorization: Bearer ${GH_TOKEN:-$GITHUB_TOKEN}")
fi
body="$(curl -fsSL --retry 3 --retry-delay 2 "${auth[@]}" "$api" 2>/dev/null || true)"
[ -n "$body" ] || exit 0
# Prefer python3 - it is on every runner that builds a bundle - and fall back to
# sed, so a container without it still answers instead of silently saying
# "latest".
tag=""
if command -v python3 >/dev/null 2>&1; then
tag="$(printf '%s' "$body" \
| python3 -c 'import json,sys
try:
print(json.load(sys.stdin).get("tag_name", ""))
except Exception:
pass' 2>/dev/null || true)"
fi
if [ -z "$tag" ]; then
tag="$(printf '%s' "$body" \
| sed -n 's/.*"tag_name"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p' \
| head -1)"
fi
# A tag, not a sentence. Anything else means the shape of the answer changed,
# and printing it into a markdown table cell would be worse than printing
# nothing - the caller's ${TAG:-latest} is the honest fallback.
case "$tag" in
v[0-9]*) ;;
*) exit 0 ;;
esac
mkdir -p "$(dirname "$cache")" 2>/dev/null || true
printf '%s' "$tag" > "$cache" 2>/dev/null || true
printf '%s' "$tag"
-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"
+21 -7
View File
@@ -14,7 +14,11 @@ set -euo pipefail
files=("$@")
if [ ${#files[@]} -eq 0 ]; then
shopt -s nullglob globstar
files=(provenance/**/*.tsv provenance/*.tsv)
# ONE pattern. With globstar, `**/` matches ZERO or more directories, so
# provenance/**/*.tsv ALREADY covers provenance/*.tsv - and listing both
# matched every top-level file twice, which is why the v10.77 release notes
# printed every row of the table twice.
files=(provenance/**/*.tsv)
fi
rows=""
@@ -24,8 +28,18 @@ for f in "${files[@]}"; do
"
done
# Drop blank lines, then sort by bundle and binary.
rows="$(printf '%s' "$rows" | awk 'NF' | sort -t"$(printf '\t')" -k1,1 -k2,2)"
# Drop blank lines, drop rows that are duplicates of a whole other row, then
# sort by bundle and binary.
#
# Deduplicated on the WHOLE line, not on (bundle, binary): a row is (bundle,
# binary, source, version, checked, sha, url), so an identical line is the same
# FACT recorded twice - by a doubled glob, a retried step, a job that ran again
# - and nothing distinguishes the copies. Two rows that share a bundle and a
# binary but differ anywhere else are NOT that; they are a real disagreement
# about which Node.js went into a bundle, and `sort -u -k1,1 -k2,2` would hide
# one of the two at random. Better shown twice and noticed.
rows="$(printf '%s' "$rows" | awk 'NF && !seen[$0]++' \
| sort -t"$(printf '\t')" -k1,1 -k2,2)"
if [ -z "$rows" ]; then
# Not fatal. The bundles are what the release is; a missing provenance
@@ -41,10 +55,10 @@ echo
echo "Each bundle carries a Node.js, a FerretDB and the MongoDB Database Tools."
echo "Which source has a given CPU varies from release to release - nodejs.org"
echo "builds some architectures, unofficial-builds others, and the"
echo "[wekan/node](https://github.com/wekan/node) fork the ones neither of them"
echo "does - and not every source publishes a checksum. This is what went into"
echo "this release, and which downloads were checked against a published"
echo "SHA256."
echo "[wekan/node-patches](https://github.com/wekan/node-patches) build the ones"
echo "neither of them does - and not every source publishes a checksum. This is"
echo "what went into this release, and which downloads were checked against a"
echo "published SHA256."
echo
echo "| Bundle | Binary | From | Version | Checked | SHA256 |"
echo "| --- | --- | --- | --- | --- | --- |"
+167
View File
@@ -0,0 +1,167 @@
#!/bin/bash
#
# snap-release-all-channels.sh - put every snap, on every architecture, on every
# channel.
#
# WeKan publishes THREE snaps of the same application - wekan, wekan-ondra and
# wekan-gantt-gpl - across SIX Snap Store architectures, and each has to reach
# FOUR channels. That is 72 (snap, architecture, channel) combinations, and the
# helper this replaces released one snap, one revision, to three channels:
#
# snapcraft release wekan $1 edge,beta,candidate
#
# which is wrong three ways at once. It names only `wekan`. It takes ONE revision
# number, and revisions are PER ARCHITECTURE - amd64 revision 42 and arm64
# revision 42 are different uploads - so a single number can only ever be right
# for one of them. And it leaves out `stable`, so somebody had to remember the
# fourth channel by hand, every time.
#
# This resolves the revision per (snap, architecture) from the store itself, so
# the caller never types a revision number, and releases it to all four channels
# in one call - a revision reaches all of them or none.
#
# SIX ARCHITECTURES, NOT EIGHT. The release bundles cover eight; snapcraft.yaml
# declares a `build-for` for six, and the two missing ones are missing for
# reasons, not by oversight:
#
# i386 core24 has no i386 port. `build-on: i386` is a PARSE error, which
# failed EVERY architecture's snap build, not only i386's. Served by
# the .deb and the AppImage instead.
# armv7 the Snap Store has ONE 32-bit ARM architecture, `armhf`, and that is
# the 32-bit Raspberry Pi OS build. armv7 is the ODroid-U3 - a DIFFERENT
# CPU with its own bundle. Publishing it as armhf would hand Raspberry
# Pi users the ODroid build, so it ships as a bundle only.
#
# ppc64le AND ppc64el ARE THE SAME HARDWARE. The bundles at
# https://github.com/wekan/wekan name it the way Node.js and the kernel do
# (ppc64le); the Snap Store names it the way Debian does (ppc64el). Nothing warns
# when the wrong one is used - an unrecognised --arch is simply an architecture
# the store has never heard of - so the mapping lives in ONE place,
# models/lib/snapArchitectures.js, and this script reads it from there rather
# than keeping a second copy in bash.
#
# Usage:
# releases/snap-release-all-channels.sh # newest revision of each
# releases/snap-release-all-channels.sh 10.75 # only that version's
# releases/snap-release-all-channels.sh --dry-run # print, do not release
# releases/snap-release-all-channels.sh --snap wekan # one snap only
# releases/snap-release-all-channels.sh --arch ppc64le # one architecture (either spelling)
#
# Needs `snapcraft` on PATH and a logged-in store session (`snapcraft login`).
set -uo pipefail
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
version=""
dry_run=0
only_snap=""
only_arch=""
while [ $# -gt 0 ]; do
case "$1" in
--dry-run) dry_run=1; shift ;;
--snap) only_snap="${2:-}"; shift 2 ;;
--arch) only_arch="${2:-}"; shift 2 ;;
-h|--help) sed -n '2,40p' "${BASH_SOURCE[0]}"; exit 0 ;;
-*) echo "unknown option: $1" >&2; exit 2 ;;
*) version="$1"; shift ;;
esac
done
node_bin="$(command -v node || true)"
if [ -z "$node_bin" ]; then
echo "snap-release-all-channels.sh: node is required (it reads the architecture table" >&2
echo " from models/lib/snapArchitectures.js so there is only one copy of it)." >&2
exit 1
fi
if [ "$dry_run" -eq 0 ] && ! command -v snapcraft >/dev/null 2>&1; then
echo "snap-release-all-channels.sh: snapcraft is not on PATH." >&2
echo " Install it and run 'snapcraft login', or pass --dry-run to see the plan." >&2
exit 1
fi
# The single source of truth, read once.
snaps="$("$node_bin" -e 'process.stdout.write(require("'"$repo_root"'/models/lib/snapArchitectures").SNAP_NAMES.join(" "))')"
arches="$("$node_bin" -e 'process.stdout.write(require("'"$repo_root"'/models/lib/snapArchitectures").snapArchitectures().join(" "))')"
channels="$("$node_bin" -e 'process.stdout.write(require("'"$repo_root"'/models/lib/snapArchitectures").SNAP_CHANNELS.join(","))')"
# Accept either spelling of --arch, and refuse one that is neither.
if [ -n "$only_arch" ]; then
resolved="$("$node_bin" -e 'process.stdout.write(require("'"$repo_root"'/models/lib/snapArchitectures").snapArchOf(process.argv[1]))' "$only_arch")"
if [ -z "$resolved" ]; then
echo "snap-release-all-channels.sh: '$only_arch' is not an architecture WeKan builds." >&2
echo " Known: $arches (and their bundle names ppc64le, armv7)." >&2
exit 2
fi
only_arch="$resolved"
fi
echo "Channels: $channels"
echo "Snaps: ${only_snap:-$snaps}"
echo "Architectures: ${only_arch:-$arches}"
echo "Version: ${version:-<newest uploaded>}"
# Say what is NOT being published, every run. "It is missing" and "it cannot
# be there" look identical from the outside, and this is the difference.
"$node_bin" -e 'const n = require(process.argv[1] + "/models/lib/snapArchitectures").NOT_SNAP_ARCHITECTURES;'\
'Object.entries(n).forEach(([a, why]) => console.log("Not a snap: " + a + " - " + why));' "$repo_root"
[ "$dry_run" -eq 1 ] && echo "DRY RUN - nothing will be released."
echo
released=0
skipped=0
failed=0
for snap in $snaps; do
[ -n "$only_snap" ] && [ "$snap" != "$only_snap" ] && continue
# One store round-trip per snap, not one per architecture.
revisions="$(snapcraft list-revisions "$snap" 2>/dev/null)"
if [ -z "$revisions" ]; then
echo " $snap: could not list revisions (not logged in, or no such snap) - skipping"
skipped=$((skipped + 1))
continue
fi
for arch in $arches; do
[ -n "$only_arch" ] && [ "$arch" != "$only_arch" ] && continue
revision="$(printf '%s' "$revisions" | "$node_bin" -e '
const { parseRevisions, pickRevision } = require("'"$repo_root"'/models/lib/snapArchitectures");
let text = "";
process.stdin.on("data", d => { text += d; });
process.stdin.on("end", () => {
const rev = pickRevision(parseRevisions(text), process.argv[1], process.argv[2] || undefined);
process.stdout.write(rev === null ? "" : String(rev));
});
' "$arch" "$version")"
if [ -z "$revision" ]; then
# Not every architecture is built for every release, and a snap that
# has none of an architecture is not an error - it is a fact about
# what was uploaded. Say so and carry on.
echo " $snap $arch: no revision${version:+ for $version} - skipping"
skipped=$((skipped + 1))
continue
fi
echo " $snap $arch: revision $revision -> $channels"
if [ "$dry_run" -eq 1 ]; then
released=$((released + 1))
continue
fi
if snapcraft release "$snap" "$revision" "$channels"; then
released=$((released + 1))
else
# One architecture failing must not stop the other seventeen.
echo " $snap $arch: RELEASE FAILED" >&2
failed=$((failed + 1))
fi
done
done
echo
echo "===== released $released, skipped $skipped, failed $failed ====="
[ "$failed" -eq 0 ]
@@ -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 = 1072,
appVersion = 1078,
# Increment this for every release.
appMarketingVersion = (defaultText = "10.72~2026-08-08"),
appMarketingVersion = (defaultText = "10.78~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();
}
+21 -5
View File
@@ -19,7 +19,7 @@
# base: core24 (not core26) · no build-base: devel · grade: stable
# ─────────────────────────────────────────────────────────────────────────────
name: wekan
version: '10.72'
version: '10.78'
base: core24
confinement: strict
grade: stable
@@ -201,9 +201,25 @@ parts:
# against the noble archive: libssl3t64 is published for amd64, arm64,
# armhf, i386, ppc64el, riscv64 and s390x, and libgoogle-perftools4t64
# for every one of those except i386 - which builds no snap.
#
# libcurl4 was the SAME transition, left behind when those two were
# renamed, and it failed the v10.77 armhf snap the same way and for the
# same reason:
# Stage package not found in part 'mongodb': libcurl4.
# while s390x, ppc64el and riscv64 all built on attempt 1. Checked
# against the noble indices the same way: `libcurl4` is not a real
# package on ANY architecture - the binary is `libcurl4t64` everywhere -
# and on amd64/arm64 it carries `Provides: libcurl4 (= 8.5.0-2ubuntu10)`
# so the old name resolves, while the armhf libcurl4t64 has no Provides
# line at all. That is what makes this class of bug reach exactly one
# architecture and look like flakiness on it.
#
# Every other stage-package below was checked against the noble armhf
# main+universe indices at the same time; libcurl4 was the only one that
# did not resolve.
stage-packages:
- libssl3t64
- libcurl4
- libcurl4t64
- libstemmer0d
- zlib1g
- libsnappy1v5
@@ -353,18 +369,18 @@ parts:
armhf) WEKAN_ARCH="armhf" ;;
*) echo "Unsupported architecture: ${CRAFT_ARCH_BUILD_FOR}"; exit 1 ;;
esac
WEKAN_ZIP="wekan-10.72-${WEKAN_ARCH}.zip"
WEKAN_ZIP="wekan-10.78-${WEKAN_ARCH}.zip"
echo "Cleaning environment first"
rm -rf .build
mkdir .build
cd .build
#wget https://github.com/wekan/wekan/releases/download/v10.72/wekan-10.72-amd64.zip
#wget https://github.com/wekan/wekan/releases/download/v10.78/wekan-10.78-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.72/${WEKAN_ZIP}"
"https://github.com/wekan/wekan/releases/download/v10.78/${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`);
+26 -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',
@@ -212,6 +208,7 @@ test('every script in releases/ is reachable from BOTH menus', () => {
'require-binaries.sh': 'release-workflow: called per build job',
'record-provenance.sh': 'release-workflow: called per build job',
'provenance-table.sh': 'release-workflow: called by the release job',
'ferretdb-latest-tag.sh': 'release-workflow: a lookup the build jobs call so\n the provenance table can name the version `latest` resolved to - it prints\n a tag and downloads nothing',
'repack-bundle-for-arch.sh': 'release-workflow: needs ./bundle, docker and '
+ 'QEMU already set up by the build job around it',
'expected-assets.sh': 'release-workflow: prints what a complete release '
@@ -241,7 +238,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`);
+6 -1
View File
@@ -335,7 +335,12 @@ test('entries are grouped by area, and no summary repeats its group', () => {
if (line === '<details>') { inEntry = true; }
if (line === '</details>') { inEntry = false; continue; }
if (inEntry && !line.startsWith('<summary>')) continue;
if (/^(This release|and ) .*:$/.test(line)) { group = null; continue; }
// `(This release|and )` with a space INSIDE the alternative and another one
// after it needs "and updates" - two spaces - so every `and ...:` header
// silently failed to match and never reset the group. Entries under a later
// subsection were then still attributed to the last group line above them,
// which is exactly the mistake this loop exists to catch.
if (/^(This release|and) .*:$/.test(line)) { group = null; continue; }
if (!inEntry) {
const m = GROUP.exec(line);
if (m) { group = m[1]; continue; }
+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`);
+160
View File
@@ -0,0 +1,160 @@
'use strict';
// Every image WeKan pushes carries full build provenance, and no build site
// quietly stops doing it.
//
// BuildKit attaches MINIMAL provenance on its own, which is why quay.io shows an
// `unknown on unknown` row beside each real platform - the OCI marker for "not a
// runnable image", so `docker pull` never selects it. Minimal is a build id and
// a timestamp. `mode=max` records what actually answers a supply-chain question:
// which commit, which base image, which build arguments.
//
// The hazard is not getting it wrong once - it is that WeKan pushes images from
// FOUR places, and a fifth added later would silently fall back to the default.
// So this checks every buildx/build-push-action call site in the repository,
// found by searching rather than from a list.
//
// Run: node tests/dockerProvenance.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');
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
// Where images are built. Searched for, not listed, so a new one is found.
function buildSites() {
const files = [];
const walk = dir => {
fs.readdirSync(path.join(repoRoot, dir), { withFileTypes: true }).forEach(e => {
const rel = `${dir}/${e.name}`;
if (e.isDirectory()) return;
if (!/\.(ya?ml|sh)$/.test(e.name)) return;
const src = read(rel);
if (/buildx build|build-push-action/.test(src)) files.push(rel);
});
};
walk('.github/workflows');
walk('releases');
return files;
}
// One entry per `docker buildx build` invocation: the whole command, joined.
function buildxCommands(src) {
const out = [];
const lines = src.split('\n');
for (let i = 0; i < lines.length; i++) {
// The COMMAND, not a comment that mentions it.
if (!/buildx build/.test(lines[i])) continue;
if (/^\s*#/.test(lines[i])) continue;
let cmd = lines[i];
let j = i;
while (/\\\s*$/.test(lines[j]) && j + 1 < lines.length) {
j += 1;
cmd += ' ' + lines[j].trim();
}
out.push(cmd);
}
return out;
}
test('every pushing buildx build asks for full provenance', () => {
buildSites().forEach(file => {
buildxCommands(read(file)).forEach(cmd => {
// Only a build that PUSHES can carry an attestation. The local --load
// build is a check, and the docker exporter cannot carry one at all.
if (!/--push/.test(cmd)) return;
assert.ok(/--provenance=mode=max/.test(cmd),
`${file}: a pushing buildx build without --provenance=mode=max:\n ${cmd.slice(0, 120)}`);
});
});
});
test('negative: the local --load build does NOT ask for it', () => {
// The docker exporter cannot carry attestations; asking would fail the build.
const cmds = buildxCommands(read('releases/docker-build.sh'));
const load = cmds.filter(c => /--load/.test(c));
assert.ok(load.length >= 1, 'the check build is still there');
load.forEach(c => assert.ok(!/--provenance/.test(c),
'the --load build must not ask for provenance'));
});
test('every build-push-action sets provenance: mode=max', () => {
buildSites().filter(f => f.endsWith('.yml') || f.endsWith('.yaml')).forEach(file => {
const src = read(file);
if (!/build-push-action/.test(src)) return;
assert.ok(/provenance:\s*mode=max/.test(src),
`${file}: build-push-action without provenance: mode=max`);
});
});
test('THE TRAP: no comment sits among a continued command\'s arguments', () => {
// A `#` after a line continuation comments out the REST OF THE JOINED LINE, so
// docker buildx build \
// # explain the next flag
// --provenance=mode=max \
// --platform ... \
// becomes `docker buildx build` with EVERY argument swallowed. `bash -n`
// accepts it - it is valid syntax, just a different command - and this was
// written exactly that way once before the test existed.
// Every build site, not only .sh: a YAML `run:` block is a shell script, and
// that is exactly where this was written the wrong way the first time.
buildSites().forEach(file => {
const lines = read(file).split('\n');
for (let i = 0; i < lines.length - 1; i++) {
if (!/\\\s*$/.test(lines[i])) continue;
// A continuation INSIDE a comment is not a command: a comment block that
// quotes a multi-line shell example ends its lines with `\` too, and every
// line of it starts with `#`. Only a continued COMMAND can be broken this
// way.
if (/^\s*#/.test(lines[i])) continue;
assert.ok(!/^\s*#/.test(lines[i + 1]),
`${file}:${i + 2}: a comment after a line continuation swallows the rest of the command`);
}
});
});
test('the arguments really do survive the joined command', () => {
// The property the trap above destroys, asserted directly: the pushing build
// still carries its platforms and its tags.
const cmd = buildxCommands(read('releases/docker-build.sh')).find(c => /--push/.test(c));
assert.ok(cmd, 'the pushing build is found');
assert.ok(/--platform linux\/amd64,linux\/arm64/.test(cmd), 'it still names its platforms');
assert.ok(/-t wekanteam\/wekan:/.test(cmd), 'and its tags');
assert.ok(/--provenance=mode=max/.test(cmd));
});
test('SBOM stays off, deliberately', () => {
// It is opt-in, and it is not free: an SBOM enumerates every OS package and
// npm dependency, so the attestation grows from ~10 KiB to megabytes per
// platform. Turning it on is a decision, not a default to drift into - this
// fails if one appears without the comment being revisited.
buildSites().forEach(file => {
const src = read(file);
assert.ok(!/--sbom=true|sbom:\s*true/.test(src),
`${file}: SBOM was enabled - see tests/dockerProvenance.test.cjs about the size`);
});
});
test('all four pushing sites are found, so none is missed', () => {
const sites = buildSites();
const pushing = sites.reduce((n, f) => {
const src = read(f);
const buildx = buildxCommands(src).filter(c => /--push/.test(c)).length;
const action = /build-push-action/.test(src) ? 1 : 0;
return n + buildx + action;
}, 0);
assert.strictEqual(pushing, 4,
`expected 4 pushing build sites, found ${pushing} - if that is a real change, ` +
'give the new one --provenance=mode=max and update this count');
});
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
//
+148
View File
@@ -0,0 +1,148 @@
'use strict';
// Every bundled binary is fetched as the NEWEST one, everywhere it is fetched.
//
// WeKan ships binaries other projects publish - FerretDB, the MongoDB Database
// Tools, Node.js - and fetches each from a release URL. There are many such
// URLs, across workflows, release scripts and compose files, and they must all
// agree: one that names a fixed version stops receiving security fixes silently,
// and nothing about the build fails when it does.
//
// That is not hypothetical. A Quay scan of the v10.77 image reported Go
// advisories - `stdlib 1.25.9` wanting 1.25.11, `golang.org/x/sys v0.38.0`
// wanting 0.44.0 - in the FerretDB binary baked into the bundle. The source was
// already fixed and v1.48.0 was already published carrying `go1.25.11` and
// `x/sys v0.46.0`; the image had simply captured an older `latest` at build
// time. Had any of these URLs been pinned instead, the rebuild would not have
// fixed it either.
//
// Run: node tests/newestBundledBinaries.test.cjs
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
// The repositories WeKan takes binaries from. Node.js is deliberately absent:
// its version is RESOLVED per architecture by releases/resolve-node-source.sh,
// which is asked about the MAJOR and answers with that CPU's newest build from
// whichever of the three sources has one - a different mechanism with the same
// effect, and it has its own tests.
const UPSTREAM = ['wekan/FerretDB', 'wekan/mongo-tools-patches'];
// Files that fetch things. Searched for rather than listed.
function sourceFiles() {
const out = [];
const skip = new Set(['node_modules', '.git', '.tools', '.meteor', 'public']);
const walk = dir => {
for (const e of fs.readdirSync(path.join(repoRoot, dir), { withFileTypes: true })) {
if (skip.has(e.name)) continue;
const rel = dir === '.' ? e.name : `${dir}/${e.name}`;
if (e.isDirectory()) walk(rel);
else if (/\.(sh|ya?ml)$|^Dockerfile/.test(e.name)) out.push(rel);
}
};
walk('.');
return out;
}
// Every release URL for the upstreams above, with the file it came from.
function releaseUrls() {
const found = [];
sourceFiles().forEach(file => {
const src = fs.readFileSync(path.join(repoRoot, file), 'utf8');
src.split('\n').forEach((line, i) => {
UPSTREAM.forEach(repo => {
const re = new RegExp(`${repo}/releases/([^"'\\s)]*)`, 'g');
let m;
while ((m = re.exec(line)) !== null) {
found.push({ file, line: i + 1, repo, rest: m[1], text: line.trim() });
}
});
});
});
return found;
}
test('there ARE such URLs, so this test is checking something', () => {
const urls = releaseUrls();
assert.ok(urls.length >= 5, `expected several release URLs, found ${urls.length}`);
});
test('every FerretDB and mongo-tools URL asks for the newest build', () => {
releaseUrls().forEach(u => {
// Three acceptable shapes:
// latest/download/<asset> - the newest release's asset
// ${VAR}/... - an override whose DEFAULT is latest, checked
// separately below
// prose in a comment - a link to the releases page, not a fetch
// `latest/download/<asset>` is a fetch; a bare `latest` is the API
// endpoint that ASKS which version latest is - which release-all.yml does
// to record it in the provenance table. Both are the newest, neither pins.
const isLatest = u.rest === 'latest' || u.rest.startsWith('latest/');
const isOverridable = /^\$\{?\$?\{?[A-Z_]/.test(u.rest) || /\$\{?\$?\{?[A-Z_]+\}?/.test(u.rest);
const isProse = u.text.trim().startsWith('#') || /^\)?[,.]?$/.test(u.rest);
assert.ok(isLatest || isOverridable || isProse,
`${u.file}:${u.line} fetches a FIXED version - it will stop getting ` +
`security fixes and nothing will fail:\n ${u.text.slice(0, 110)}`);
});
});
test('the overridable one defaults to latest', () => {
// docker-compose lets an operator pin a FerretDB for their own reasons
// (FERRETDB_RELEASE=download/v1.24.2). The DEFAULT must still be newest, or
// every operator who does not set it is silently frozen.
const composes = sourceFiles().filter(f => /^docker-compose.*\.ya?ml$/.test(f));
assert.ok(composes.length >= 1, 'the compose files are found');
composes.forEach(file => {
const src = fs.readFileSync(path.join(repoRoot, file), 'utf8');
if (!/FERRETDB_RELEASE/.test(src)) return;
assert.ok(/FERRETDB_RELEASE:\s*\$\{FERRETDB_RELEASE:-latest\/download\}/.test(src),
`${file}: FERRETDB_RELEASE must default to latest/download`);
});
});
test('negative: no URL names a version number directly', () => {
// The shape that would silently freeze a component: .../releases/download/v1.2.3/...
releaseUrls().forEach(u => {
if (u.text.trim().startsWith('#')) return; // a comment may show an example
assert.ok(!/^download\/v[0-9]/.test(u.rest),
`${u.file}:${u.line} pins a version:\n ${u.text.slice(0, 110)}`);
});
});
test('Node.js is resolved per architecture, not fetched from a fixed URL', () => {
// A different mechanism with the same effect: resolve-node-source.sh is asked
// about the MAJOR and answers with that CPU's newest build from whichever of
// nodejs.org, unofficial-builds or node-patches has one.
const resolver = 'releases/resolve-node-source.sh';
assert.ok(fs.existsSync(path.join(repoRoot, resolver)), `${resolver} exists`);
const src = fs.readFileSync(path.join(repoRoot, resolver), 'utf8');
assert.ok(/nodejs\.org/.test(src) && /unofficial-builds/.test(src) && /node-patches/.test(src),
'it still knows all three sources');
});
test('the provenance table is what makes a `latest` build reproducible after the fact', () => {
// `latest` means rebuilding an old release would embed a different FerretDB
// than it shipped with. That is a deliberate trade - security fixes arrive
// without a commit - and what makes it safe is that every release RECORDS the
// versions and SHA256s it actually shipped.
const rec = 'releases/record-provenance.sh';
assert.ok(fs.existsSync(path.join(repoRoot, rec)),
`${rec} must exist: it is what answers "which FerretDB did v10.77 ship?"`);
const changelog = fs.readFileSync(path.join(repoRoot, 'CHANGELOG.md'), 'utf8');
assert.ok(/\| Platform \| Binary \| From \| Version \| SHA256 \|/.test(changelog),
'and the CHANGELOG carries the table it produces');
});
console.log(`\n${passed} tests passed`);
+162
View File
@@ -0,0 +1,162 @@
'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.
//
// WeKan had one, in tests/releaseNodeSources.test.cjs:
//
// new RegExp(`^\\s*${p.replace('-', '-')}\\)\\s+nodename=`, 'm')
//
// It reads as "escape the hyphen before putting this into a regex" and does
// nothing at all, so the value went in raw. A hyphen happens to be harmless
// outside a character class, which is why it never failed - but the guard it
// looked like was not there, and a platform name with a `.` or a `+` in it would
// have matched the wrong row or thrown. The usual cause of the shape is a
// mistyped backslash escape: '\"' is just '"', so replace(/"/g, '\"') is an
// identity replacement where '\\"' was meant.
//
// Code scanning finds these days later, on a push, in a web UI. This finds them
// in fifteen seconds, in the same run as everything else.
//
// Run: node tests/noIdentityReplacement.test.cjs
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const ROOT = path.join(__dirname, '..');
let passed = 0;
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
// WeKan's own JavaScript. node_modules is other people's code, .meteor/.build
// are build outputs, and .tools holds separate repositories.
const SKIP = new Set(['node_modules', '.git', '.meteor', '.build', '.tools',
'log', 'test-results', 'dist', 'coverage']);
const EXTENSIONS = ['.js', '.cjs', '.mjs', '.jsx', '.ts'];
function sources(dir = ROOT, out = []) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
if (SKIP.has(entry.name)) continue;
const full = path.join(dir, entry.name);
if (entry.isDirectory()) sources(full, out);
else if (EXTENSIONS.includes(path.extname(entry.name))) out.push(full);
}
return out;
}
// `.replace('x', 'x')` / `.replace("x", "x")` / `.replaceAll(...)`: the same
// STRING literal on both sides. Only string-to-string, because a regex first
// argument with the same text can still differ in meaning (flags, escapes), and
// this must not report something that is fine.
// The two quote styles are separate alternatives rather than a backreference:
// a `"` inside a single-quoted literal is perfectly legal, and a character class
// that excluded BOTH quote characters could not match `replace('"', '\"')` -
// which is CodeQL's own example of this mistake, and the one shape a guard for
// it must not miss.
const IDENTITY = new RegExp(
'\\.replace(?:All)?\\(\\s*' +
"(?:'((?:[^'\\\\]|\\\\.)*)'|\"((?:[^\"\\\\]|\\\\.)*)\")" +
'\\s*,\\s*' +
"(?:'((?:[^'\\\\]|\\\\.)*)'|\"((?:[^\"\\\\]|\\\\.)*)\")" +
'\\s*\\)',
'g',
);
// Whichever of the two alternatives matched.
const sides = m => [m[1] !== undefined ? m[1] : m[2], m[3] !== undefined ? m[3] : m[4]];
// The two sides are compared as VALUES, not as source text. That is the whole
// point of CodeQL's own example: `raw.replace(/"/g, '\"')` looks like it escapes
// a quote, but `\"` in a string literal is an identity escape - the value is
// just `"` - so it replaces a quote with a quote. Comparing the raw text would
// call that different and miss exactly the mistake this is for.
function value(literal) {
return literal.replace(/\\(u\{[0-9a-fA-F]+\}|u[0-9a-fA-F]{4}|x[0-9a-fA-F]{2}|.)/g, (all, esc) => {
switch (esc) {
case 'n': return '\n';
case 't': return '\t';
case 'r': return '\r';
case 'b': return '\b';
case 'f': return '\f';
case 'v': return '\v';
case '0': return '\0';
default:
// \x41, \u0041, \u{1F600} - resolve, so two spellings of one character
// are not called different.
if (/^x|^u/.test(esc)) {
const hex = esc.replace(/^u\{|\}$|^[ux]/g, '');
return String.fromCodePoint(parseInt(hex, 16));
}
// Anything else after a backslash is that character: the identity escape.
return esc;
}
});
}
// Comments are stripped before scanning. This file and the one it was written
// for both QUOTE the bad line to explain it, and a guard that reports the
// sentence describing a bug as the bug is a guard that gets switched off.
function stripComments(text) {
return text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/^[^\n'"`]*\/\/.*$/gm, '');
}
console.log('noIdentityReplacement:');
test('no source file replaces a string with itself', () => {
const found = [];
for (const file of sources()) {
// This file holds deliberate examples of the shape, in the samples below.
if (path.resolve(file) === path.resolve(__filename)) continue;
const text = stripComments(fs.readFileSync(file, 'utf8'));
for (const m of text.matchAll(IDENTITY)) {
const [from, to] = sides(m);
if (value(from) !== value(to)) continue;
const line = text.slice(0, m.index).split('\n').length;
found.push(`${path.relative(ROOT, file)}:${line}: ${m[0]}`);
}
}
assert.deepStrictEqual(found, [],
'this replacement does nothing. If it was meant to escape something, the '
+ 'replacement needs the escape doubled ("\\\\\\"" not "\\\\""); if it was '
+ 'meant to be a no-op, delete it rather than leave a line that looks like a '
+ 'guard');
});
test('the pattern really does catch the shape CodeQL reported (negative)', () => {
// A guard for a class of typo is worth only what it detects, so check it
// against the exact line that was in the tree, and against the escape mistake
// CodeQL's own example gives.
const samples = [
`const re = new RegExp(\`^\\\\s*\${p.replace('-', '-')}\\\\)\`, 'm');`,
`var escaped = raw.replace('"', '\\"');`,
`s.replaceAll("ab", "ab")`,
];
for (const sample of samples) {
const hits = [...sample.matchAll(IDENTITY)]
.filter(m => { const [f, t] = sides(m); return value(f) === value(t); });
assert.strictEqual(hits.length, 1, `not detected: ${sample}`);
}
});
test('and does not report a replacement that changes something', () => {
// The other half: a guard that fires on working code gets switched off.
const samples = [
`s.replace('-', '_')`,
`s.replace(/-/g, '-')`, // regex source, not a string literal
`s.replace('a', 'b').replace('c', 'd')`,
`escapeRegExp = str => str.replace(/[.*+?^\${}()|[\\]\\\\]/g, '\\\\$&')`,
];
for (const sample of samples) {
const hits = [...sample.matchAll(IDENTITY)]
.filter(m => { const [f, t] = sides(m); return value(f) === value(t); });
assert.deepStrictEqual(hits.map(h => h[0]), [], `false positive: ${sample}`);
}
});
console.log(`\n${passed} tests passed`);
+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"
+336
View File
@@ -0,0 +1,336 @@
'use strict';
// The release notes must say what actually went into each bundle - once, for
// every platform, with a version rather than the word "latest".
//
// The v10.77 notes got all three wrong at the same time, and each failure was
// silent: nothing errored, the table was simply not what it claimed to be.
//
// Every row appeared TWICE. provenance-table.sh globbed
// `provenance/**/*.tsv provenance/*.tsv`, and with globstar `**/` matches ZERO
// or more directories - so the first pattern already covered the second and
// every file was read twice.
//
// amd64 was MISSING, the platform most people download. Its step runs
// `cd .build` before `mkdir -p provenance`, so the rows went to
// .build/provenance while upload-artifact looked at provenance/ - "No files
// were found with the provided path: provenance/". Every other build job
// records from the workspace root, which is why only amd64 was affected.
//
// Six platforms said Version "latest". amd64, arm64, win64, win32, mac-arm64
// and mac-x64 passed the literal string; only the extra-architecture job asked
// which version latest actually was. "Which FerretDB did v10.77 ship" is the
// one question that column exists to answer.
//
// Run: node tests/provenanceTable.test.cjs
const assert = require('assert');
const { execFileSync } = require('child_process');
const fs = require('fs');
const os = require('os');
const path = require('path');
const repoRoot = path.resolve(__dirname, '..');
const read = rel => fs.readFileSync(path.join(repoRoot, rel), 'utf8');
const TABLE = 'releases/provenance-table.sh';
const RECORD = 'releases/record-provenance.sh';
const RESOLVER = 'releases/ferretdb-latest-tag.sh';
const WORKFLOW = '.github/workflows/release-all.yml';
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
const TAB = '\t';
const row = (bundle, what, src, version, checked, sha, url) =>
[bundle, what, src, version, checked, sha, url].join(TAB);
// Run provenance-table.sh over a directory of .tsv files and return its output.
function tableFor(files) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wekan-prov-'));
try {
fs.mkdirSync(path.join(dir, 'provenance'), { recursive: true });
Object.entries(files).forEach(([name, lines]) => {
const p = path.join(dir, 'provenance', name);
fs.mkdirSync(path.dirname(p), { recursive: true });
fs.writeFileSync(p, lines.join('\n') + '\n');
});
return execFileSync('bash', [path.join(repoRoot, TABLE)], {
cwd: dir, encoding: 'utf8',
});
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
}
const bodyRows = out => out.split('\n')
.filter(l => l.startsWith('| ') && !/^\| (Bundle|-)/.test(l));
// ───────────────────────────────────────────── every row appears exactly once
test('THE BUG: each recorded row is printed once, not twice', () => {
const out = tableFor({
'amd64.tsv': [
row('amd64', 'Node.js', 'nodejs.org', 'v24.19.0', 'verified', 'aa11', 'https://nodejs.org/x'),
row('amd64', 'FerretDB', 'wekan/FerretDB', 'v1.48.0', 'verified', 'bb22', 'https://gh/f'),
],
's390x.tsv': [
row('s390x', 'Node.js', 'nodejs.org', 'v24.19.0', 'verified', 'cc33', 'https://nodejs.org/s'),
],
});
assert.strictEqual(bodyRows(out).length, 3,
'three rows recorded, so three rows printed');
});
test('...and the glob that read every file twice is still ONE pattern', () => {
// The row-counting test above passes either way now, because the dedup added
// with this fix would swallow a doubled glob - which is what defence in depth
// is for, and also why the cause needs its own guard or it can come back
// invisibly. With globstar, `provenance/**/*.tsv` already matches
// `provenance/*.tsv`; listing both reads every top-level file twice.
const src = read(TABLE);
const globLine = src.split('\n').find(l => /files=\(provenance/.test(l));
assert.ok(globLine, 'the default glob is still there');
const patterns = globLine.replace(/.*files=\(|\).*/g, '').trim().split(/\s+/);
assert.deepStrictEqual(patterns, ['provenance/**/*.tsv'],
'one pattern: `**/` matches zero or more directories, so it covers both');
assert.ok(/shopt -s nullglob globstar/.test(src),
'and globstar is on, or `**` is just `*` and nested files are missed');
});
test('a file in a SUBDIRECTORY is still found - the glob covers both', () => {
// download-artifact can land each artifact in its own directory. Collapsing to
// one glob must not lose those.
const out = tableFor({
'amd64.tsv': [row('amd64', 'Node.js', 'nodejs.org', 'v24.19.0', 'verified', 'aa11', 'u')],
'provenance-arm64/arm64.tsv': [
row('arm64', 'Node.js', 'nodejs.org', 'v24.19.0', 'verified', 'bb22', 'u'),
],
});
const rows = bodyRows(out);
assert.strictEqual(rows.length, 2);
assert.ok(rows.some(r => r.startsWith('| amd64 ')), 'the top-level file');
assert.ok(rows.some(r => r.startsWith('| arm64 ')), 'and the nested one');
});
test('the same fact recorded twice collapses to one row', () => {
// A retried step, or a job that ran again, records an identical line. It is
// one fact and nothing tells the copies apart.
const same = row('amd64', 'Node.js', 'nodejs.org', 'v24.19.0', 'verified', 'aa11', 'u');
const out = tableFor({ 'a.tsv': [same], 'b.tsv': [same] });
assert.strictEqual(bodyRows(out).length, 1);
});
test('NEGATIVE: a real disagreement is NOT collapsed', () => {
// Two rows sharing bundle and binary but differing elsewhere are not a
// duplicate - they are two answers to "which Node.js went into amd64", and
// hiding one at random is worse than showing both. This is why the dedup is
// on the whole line and not on the sort key.
const out = tableFor({
'a.tsv': [
row('amd64', 'Node.js', 'nodejs.org', 'v24.19.0', 'verified', 'aa11', 'u1'),
row('amd64', 'Node.js', 'wekan/node-patches', 'v24.19.1', 'verified', 'bb22', 'u2'),
],
});
assert.strictEqual(bodyRows(out).length, 2,
'both must survive, so the disagreement is visible');
});
test('rows are grouped by bundle, then by binary', () => {
const out = tableFor({
'x.tsv': [
row('s390x', 'Node.js', 'nodejs.org', 'v1', 'verified', 'a', 'u'),
row('amd64', 'Node.js', 'nodejs.org', 'v1', 'verified', 'b', 'u'),
row('amd64', 'FerretDB', 'wekan/FerretDB', 'v1', 'verified', 'c', 'u'),
],
});
const bundles = bodyRows(out).map(r => r.split('|')[1].trim());
assert.deepStrictEqual(bundles, ['amd64', 'amd64', 's390x']);
const binaries = bodyRows(out).slice(0, 2).map(r => r.split('|')[2].trim());
assert.deepStrictEqual(binaries, ['FerretDB', 'Node.js']);
});
test('a source with no checksum says so instead of showing a blank', () => {
const out = tableFor({
'x.tsv': [row('s390x', 'Node.js', 'nodejs.org', 'v1', 'no checksum published', '-', 'u')],
});
const r = bodyRows(out)[0];
assert.ok(/no checksum published/.test(r));
assert.ok(/\| — \|/.test(r), 'and an em dash, not an empty cell');
assert.ok(!/`-`/.test(r), 'not a literal dash in backticks');
});
test('no rows at all is a note, not a failed release', () => {
const out = tableFor({});
assert.ok(/no build job recorded/.test(out));
assert.ok(!/\| Bundle \|/.test(out), 'and no empty table');
});
test('the table names the right fork', () => {
// It said [wekan/node](https://github.com/wekan/node); the binaries come from
// wekan/node-patches, which is also what the rows themselves link to.
const src = read(TABLE);
assert.ok(/wekan\/node-patches/.test(src));
assert.ok(!/github\.com\/wekan\/node\)/.test(src),
'the old wrong link is gone');
});
// ─────────────────────────────────────── amd64 records where the upload looks
test('THE BUG: amd64 records into the workspace, not into .build', () => {
const src = read(WORKFLOW);
// The amd64 zip step, from its `cd .build` to the end of the run block.
const step = src.slice(src.indexOf('- name: Create amd64 zip'));
const upTo = step.slice(0, step.indexOf('- uses: actions/upload-artifact'));
assert.ok(/cd \.build/.test(upTo), 'the step still cds, which is what made this bite');
const calls = upTo.split('\n').filter(l => /PROVENANCE_FILE=/.test(l));
assert.strictEqual(calls.length, 2, 'Node.js and FerretDB are both recorded');
calls.forEach(l => assert.ok(/GITHUB_WORKSPACE/.test(l),
`a relative provenance path inside "cd .build" writes where nothing reads:\n ${l.trim()}`));
assert.ok(/mkdir -p "\$GITHUB_WORKSPACE\/provenance"/.test(upTo),
'and the directory is made there too');
});
test('every build job that records also uploads what it recorded', () => {
const src = read(WORKFLOW);
const recorded = [...src.matchAll(/PROVENANCE_FILE=\S*?provenance\/([a-z0-9-]+)\.tsv/g)]
.map(m => m[1]);
assert.ok(recorded.length >= 6, `expected several recording sites, found ${recorded.length}`);
new Set(recorded).forEach(arch => {
assert.ok(src.includes(`name: provenance-${arch}`),
`${arch} records provenance but no job uploads it`);
});
});
// ──────────────────────────────────────────── the version is a version
test('THE BUG: no site records the literal string "latest" as the version', () => {
const src = read(WORKFLOW);
assert.ok(!/'wekan\/FerretDB' 'latest'/.test(src),
'a Version column reading "latest" answers nothing a year later');
});
test('every FerretDB provenance call resolves the tag first', () => {
const src = read(WORKFLOW);
const lines = src.split('\n');
const calls = lines
.map((l, i) => ({ l, i }))
.filter(x => /'FerretDB'/.test(x.l) && /wekan\/FerretDB/.test(x.l));
assert.ok(calls.length >= 6, `expected the FerretDB rows, found ${calls.length}`);
calls.forEach(c => {
assert.ok(/\$\{FERRET_TAG:-latest\}/.test(c.l),
`${WORKFLOW}:${c.i + 1} does not use the resolved tag:\n ${c.l.trim()}`);
// ...and FERRET_TAG has to have been set just above it.
const above = lines.slice(Math.max(0, c.i - 4), c.i).join('\n');
assert.ok(/FERRET_TAG="\$\(bash \S*ferretdb-latest-tag\.sh\)"/.test(above),
`${WORKFLOW}:${c.i + 1} uses FERRET_TAG without resolving it above`);
});
});
test('there is ONE implementation of "which version is latest"', () => {
const src = read(WORKFLOW);
assert.ok(!/api\.github\.com\/repos\/wekan\/FerretDB/.test(src),
'the API call is inlined again; it belongs in the resolver only');
assert.ok(fs.existsSync(path.join(repoRoot, RESOLVER)), `${RESOLVER} exists`);
});
test('the resolver never fails its caller', () => {
// It feeds a release note. A rate-limited or offline runner must degrade to
// the old behaviour, not fail a build that produced a perfectly good bundle.
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wekan-ftag-'));
try {
const out = execFileSync('bash', [path.join(repoRoot, RESOLVER)], {
encoding: 'utf8',
env: {
...process.env,
FERRETDB_TAG_CACHE: path.join(dir, 'cache'),
http_proxy: 'http://127.0.0.1:9',
https_proxy: 'http://127.0.0.1:9',
HTTPS_PROXY: 'http://127.0.0.1:9',
},
});
assert.strictEqual(out.trim(), '', 'it prints nothing when it cannot find out');
} catch (e) {
assert.fail(`the resolver exited non-zero with no network: ${e.status}`);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('the resolver refuses to print anything that is not a tag', () => {
// Whatever it prints goes into a markdown cell. An error page, an HTML body
// or a rate-limit message must fall through to the caller's :-latest.
const src = read(RESOLVER);
assert.ok(/v\[0-9\]\*\)/.test(src), 'the shape of the answer is checked');
assert.ok(/GITHUB_TOKEN|GH_TOKEN/.test(src),
'and it authenticates, so a shared 60/hour limit is not what makes it fail');
});
// ─────────────────────────────────── the record script itself
test('a recorded row has the seven fields the table reads', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wekan-rec-'));
try {
const file = path.join(dir, 'p.tsv');
execFileSync('bash', [path.join(repoRoot, RECORD),
'amd64', 'Node.js', 'nodejs.org', 'v24.19.0', 'https://x', 'abc123'],
{ env: { ...process.env, PROVENANCE_FILE: file }, encoding: 'utf8' });
const fields = fs.readFileSync(file, 'utf8').trim().split(TAB);
assert.strictEqual(fields.length, 7);
assert.deepStrictEqual(fields.slice(0, 6),
['amd64', 'Node.js', 'nodejs.org', 'v24.19.0', 'verified', 'abc123']);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('an omitted checksum is recorded as unpublished, not as verified', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'wekan-rec-'));
try {
const file = path.join(dir, 'p.tsv');
execFileSync('bash', [path.join(repoRoot, RECORD),
'amd64', 'Node.js', 'nodejs.org', 'v24.19.0', 'https://x'],
{ env: { ...process.env, PROVENANCE_FILE: file }, encoding: 'utf8' });
const fields = fs.readFileSync(file, 'utf8').trim().split(TAB);
assert.strictEqual(fields[4], 'no checksum published');
assert.strictEqual(fields[5], '-');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
// ────────────────────────── a diagnostic upload cannot fail a good build
test('THE BUG: snapcraft build logs are renamed before they are uploaded', () => {
// snapcraft names a remote-build log after the recipe and an ISO timestamp:
// snapcraft-wekan-f82a93c2…_s390x_2026-08-09T11:39:05.txt
// and upload-artifact REFUSES a colon, because NTFS cannot hold one. That
// failed the whole job for s390x, ppc64el and riscv64 in v10.77 AFTER all
// three snaps had built and downloaded on attempt 1 - "Remote build s390x
// succeeded on attempt 1" is in the same log as "FAILED snap-launchpad
// s390x".
const src = read(WORKFLOW);
const step = src.slice(src.indexOf('mkdir -p snap-logs'));
const upTo = step.slice(0, step.indexOf('- name: Push'));
assert.ok(/\$\{base\/\/:\/-\}/.test(upTo),
'the colons in the log name must be replaced, or the upload refuses the file');
assert.ok(!/cp -f snapcraft-wekan-\*\.txt snap-logs\/\s/.test(upTo),
'the plain copy that kept the colons is gone');
});
test('and that upload can never fail the job it is diagnosing', () => {
const src = read(WORKFLOW);
const i = src.indexOf('name: snap-launchpad-logs-');
assert.ok(i > 0, 'the log upload step is still there');
// Look back at the step this `with:` belongs to.
const step = src.slice(src.lastIndexOf('- uses: actions/upload-artifact', i), i);
assert.ok(/continue-on-error:\s*true/.test(step),
'the snap is the deliverable; saving its log must not be able to fail it');
});
console.log(`\n${passed} tests passed`);
+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`);
+9 -1
View File
@@ -163,8 +163,16 @@ test('every platform WeKan builds has a row in the resolver mapping table', () =
// the build matrix with no row here cannot be resolved at all.
const PLATFORMS = ['x64', 'arm64', 'i386', 'armhf', 'armv7', 'ppc64le', 's390x',
'riscv64', 'loong64', 'win64', 'win32', 'mac-x64', 'mac-arm64'];
// The platform name goes into a RegExp, so it is escaped rather than
// interpolated raw. It used to read `p.replace('-', '-')`, which replaces a
// hyphen with a hyphen: a no-op that LOOKS like escaping, so nothing here was
// escaped and a platform name containing a regex metacharacter would have
// matched something else entirely (or thrown). GitHub CodeQL reports that
// shape as js/identity-replacement, and it is right - the usual cause is a
// mistyped backslash escape.
const escapeRegExp = str => str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
for (const p of PLATFORMS) {
const re = new RegExp(`^\\s*${p.replace('-', '-')}\\)\\s+nodename=`, 'm');
const re = new RegExp(`^\\s*${escapeRegExp(p)}\\)\\s+nodename=`, 'm');
assert.ok(re.test(resolver), `resolve-node-source.sh has no row for ${p}`);
}
// amd64 is WeKan's name for x64 and every caller uses it; it must map, not fail.
+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');
Loaded 100 of 104 files, more files were not shown because too many files have changed in this diff. Show more