Wekan
Open source kanban board application built with Meteor
Alternative to: trello
v10.57
2026-08-03v10.57 2026-08-03 WeKan ® release
In short: two reported bypasses of the SSRF guard are closed, and they are the same mistake in its two halves. FollowBleed — the import downloads validated the URL and then fetched it with something that follows redirects, so a public URL could answer 302 to 127.0.0.1 and that body became the imported attachment; fetchSafe now validates and pins every hop, not only the one the caller passed. TransitBleed — the shared block-list read an IPv6 address by its spelling, so 6to4, NAT64 and Teredo addresses carried an internal IPv4 destination straight through it; an address is now expanded to its bytes and every embedded IPv4 is re-checked. Below that, the platform documentation is arranged by what each platform is: every page lives under OS, HW, Container, Cloud, Package, Source or SaaS, with the links that had to follow the move, logos stored beside their page instead of fetched from somebody else’s server, two new platform pages, and a developer-tooling fix.
This release fixes the following CRITICAL SECURITY ISSUES:
The SSRF guard - what it checks, and what it was deciding from.
Import downloads validate every redirect hop instead of only the URL they were given. Thanks to RandomGenerator and xet7.
FollowBleed is a bypass of the fix
that closed LiveBleed /
CVE-2026-30844. The live Trello import did validate the attachment URL with
validateAttachmentUrl() — and then downloaded it with the platform fetch(),
which follows redirects. So the guard only ever saw the request, and the
target gets to answer:
- the attacker puts
http://<public-host>/attachment.txton a Trello card validateAttachmentUrl()resolves it, sees a public IP, allows it- that host answers
302 Location: http://127.0.0.1:18080/secret fetch()follows, and the loopback body is stored as the imported attachment, readable back through WeKan
That is non-blind SSRF against loopback services, internal admin panels, cloud metadata and anything else reachable from the container — the exact thing the validation was added to stop, reached through the response instead of the request.
A guard on the URL alone cannot hold, so fetchSafe() guards every hop.
maxRedirects (default 0) is how many redirects a caller is willing to follow.
0 keeps the old behaviour of refusing any 3xx outright, which is right for
outgoing webhooks and avatar downloads, because a legitimate one never
redirects. A caller that must follow one passes a small number, and each hop
goes through the same protocol allowlist, blocked-range check and DNS pinning as
the original URL before a packet is sent to it. Credentials are dropped on a
cross-origin redirect, so following Trello’s 302 to S3 cannot hand the API key
and token to whoever the redirect names.
Refusing every redirect was not an option: Trello’s own attachment endpoint answers with a 302 to a signed S3 URL, so that would have meant importing no attachments at all.
The offline importers had the same hole and were not in the report. They
handed the validated URL to Attachments.loadAsync(), and Meteor-Files
downloads with the platform fetch() too, so a pasted Trello or WeKan board
export reached 127.0.0.1 by exactly the same 302. They download through the
guard now, and store the bytes with the same call they already used for an
attachment that arrived inline.
tests/followbleed.test.cjs replays the reported attack against a stubbed
transport and asserts the second hop is never sent, then pins the rest: a
redirect to a hostname resolving to a private IP, to metadata, to a non-http
scheme, a relative Location, the chain limit, credential stripping across
origins, 303/307 method handling — and that a legitimate public-to-public
redirect is still followed with each hop pinned, because the import has to keep
working.
IPv6 addresses are classified by their bytes, not by how they are spelled. Thanks to tonghuaroot and xet7.
TransitBleed. isIpBlocked() is
the one block-list behind both halves of the SSRF defence — the input-time
validator and the delivery-time guard — and its IPv6 half classified an address
by its spelling: startsWith('::ffff:'), startsWith('2001:db8'), and the
first hextet parsed out of the string.
IPv6 has several standard ways to write “this packet goes to an IPv4 address”,
and none of them looks like ::ffff::
2002:a9fe:a9fe::— 6to4 (RFC 3056) →169.254.169.25464:ff9b::c0a8:101— NAT64 (RFC 6052) →192.168.1.12001:0:…— Teredo (RFC 4380), the IPv4 stored as the complement of the low 32 bits0:0:0:0:0:ffff:7f00:1— IPv4-mapped, merely spelled out →127.0.0.1
On a host with a 6to4 relay or a NAT64 gateway — ordinary in cloud and
Kubernetes networks — the packet arrives at that IPv4 address. So
http://[2002:a9fe:a9fe::]/latest/meta-data/ read cloud metadata straight
through the guard whose whole job was to stop it.
An address is expanded to its 16 bytes once and every check reads those
bytes, so notation cannot change the answer, and every transition form has its
embedded IPv4 extracted and re-checked with the IPv4 rules: 6to4, NAT64 (the
well-known prefix and the RFC 8215 local-use one), Teredo through both its
server and its obfuscated client address, IPv4-mapped, IPv4-translated,
IPv4-compatible, and ISATAP under any routing prefix rather than only the
link-local one. The deprecated fec0::/10 site-local range is blocked too.
A transition address wrapping a public IPv4 is still allowed, and
tests/transitbleed.test.cjs pins that as carefully as it pins the bypasses: a
guard that blocks everything is a guard somebody switches off.
and has the following developer-tooling fix:
The Sandstorm bridge guard reads the page at the path it moved to. Thanks to xet7.
The documentation reorganisation turned
docs/Platforms/FOSS/Container/Sandstorm from a page into a directory, and this
test reads that page at run time to pin what it documents. So it did not merely
go stale: fs.readFileSync on a directory throws EISDIR, and the suite died
before its assertions ran.
It reads the directory’s landing page, README.md, which is where the
migration-bridge documentation ended up. Both assertions are unchanged and both
pass.
and reorganises the documentation:
The Platforms docs - how the pages are arranged, and what points at them.
Every platform page sits under what it is - an OS, container, cloud, hardware or package. Thanks to xet7.
docs/Platforms grew a page at a time, so what a reader met was a flat list:
Android.md, Debian.md, FreeBSD.md and SmartOS.md next to Snap/,
Docker/ and Sandstorm/, next to ppc.md, s390x.md and RaspberryPi/,
with Propietary/Cloud/ holding both rented machines and one-click hosting
services. Nothing said which was which, and the list only ever got longer.
The two halves keep their names and gain a middle layer that says what a thing
is. FOSS has OS, HW, Container, Cloud, Package and Source;
Propietary has OS, HW, Cloud and SaaS. So Debian is an OS, Snap and
Sandstorm are containers, Raspberry Pi is hardware, OpenShift and Helm are
cloud, and PikaPods, Cloudron, Scalingo, Heroku and Uberspace are SaaS rather
than being filed beside AWS and OVH.
A directory’s index page is README.md, which is what GitHub renders when
somebody opens the directory, so a link points at the directory and never spells
out README.md. Docker/Docker.md, Snap/Snap.md, Sandstorm/Sandstorm.md
and the other pages named after their own directory became that README.md.
Cloud/OpenShift/ is the one exception, because it already had one.
The old FOSS/Platforms.md index is gone, the directory tree being the index
now, and the pages that linked to it point at docs/Platforms instead.
The links follow the move, including the ones inside the pages that moved. Thanks to xet7.
Two different things break when a documentation tree is rearranged. A link whose
target moved is the obvious one. The other is a link inside a page that
itself moved: Snap/CentOS-7.md went one directory deeper, so all ten of
its links out to Webserver/, Login/, Backup/ and Email/ needed another
../ even though nothing they point at had moved at all. Both kinds were
resolved from where each page used to live, across 213 files.
Not every reference is a markdown link, and those were followed too: the Snap
settings comments in the eight docker-compose*.yml files,
sandstorm-pkgdef.capnp, sandstorm-src/start.js,
server/methods/sandstormMigration.js, snap-src/bin/config,
releases/version.sh and the two ferretdb start-wekan scripts.
One of them is not a comment. tests/sandstormMigrationBridge.test.cjs reads
the Sandstorm page at run time to pin what it documents, so this move would
have failed the test suite rather than merely leaving a dead link behind.
Doc links written from the repository root resolve from the page holding them. Thanks to xet7.
43 links in five files named a path from the repository root, models/users.js
or client/lib/localStorageValidator.js, which resolves to nothing from the
page holding it. They carry the ../ that gets there now:
docs/DeveloperDocs/Directory-Structure.md has 9, where the other link on
each of those same lines was already correct, and the four
docs/Security/PerUserDataAudit2025-12-23/ files have 34.
Only paths that exist in the WeKan repository itself were repointed. FerretDB,
node, wekan-gantt-gpl and wekan-ondra sit inside the working copy but are
separate git repositories, so nothing resolves into them.
Third-party assets - what a reader’s browser fetches when a page opens.
Platform logos are stored beside their page instead of fetched from another host. Thanks to xet7.
A logo loaded from somebody else’s server hands that server the IP address and user agent of everyone who opens the page. The deploy buttons were doing exactly that, from cdn.zenith.hosting, cdn.scalingo.com and www.herokucdn.com, as was the Sandstorm badge from img.shields.io and the MacStadium and WeKan logos from wekan.fi.
Each is stored next to the page that shows it now, named after its platform:
zenith.svg, scalingo.svg, heroku.png, sandstorm.svg,
MacStadium-developerlogo.png and wekan-logo.svg. PikaPods needed no
download at all, because pikapods.svg was already sitting in its own directory
unused while the page fetched the same image over the network.
Screenshots are left as they are. This is about the logos, which are small, never change, and are fetched on every single visit to the page.
One was beyond saving: the chat badge at vanila.io answers with an HTML page
rather than an image, so it is left alone rather than replaced by a copy of
something that is already broken.
and adds the following new platform pages:
Packager.io and Zenith Hosting have a page of their own. Thanks to xet7.
Packager.io at FOSS/Package/ records the DEB/RPM build at
packager.io and says plainly that it does
not work yet, which is the useful part: the link exists, and a link that exists
invites the assumption that what is behind it works.
Zenith Hosting at Propietary/SaaS/ is one-click managed WeKan with
storage, backups, email and a free subdomain, and a share of every subscription
goes back to WeKan.
- The Zenith Hosting page reads as prose instead of a two-item list. Thanks to xet7.
Thanks to above GitHub users for their contributions and translators for their translations.