| CVE | Vulnerability name | Date | Responsible Security Disclosure by | Vulnerabilities |
|---|---|---|---|---|
|
GHSA-4mxf-m8pq-xc9p
|
PathBleed |
2026-08-09 |
Alpastx and xet7
![]() Coordinated disclosure via GitHub Security Advisory GHSA-4mxf-m8pq-xc9p. |
|
WeKan stores an uploaded file with ostrio:files, which keeps the location of
the bytes on disk in the document itself, as
versions.<name>.path. That is server-managed metadata: the server writes
it when it stores the file, and a client has no reason ever to set or change it.
Attachments knew that. Their allow rule refused an insert carrying
versions.*.path or versions.*.storage, refused any update touching
the versions subtree, and restricted updates to a whitelist of fields.
Avatars did not. The whole avatar update rule was one line:
// server/permissions/avatars.js — before
Avatars.allow({
async insert(userId, doc) { ... },
update: isOwner, // <-- being the owner let you write ANY field
remove: isOwner,
fetch: ['userId'],
});
Being the owner of your own avatar is a reasonable thing to require. It is not a reasonable
thing to be sufficient: isOwner says WHOSE document may be changed and nothing
about WHICH FIELDS, so the owner could set versions.original.path to any path
they liked.
For every board member whose avatar is a local WeKan file, board export embeds the picture
itself, so a board round-trips through export and import with everyone's avatar intact,
without reaching out to an identity provider (which is also what makes it work inside a
Sandstorm grain). It does that by opening
versions.original.path and base64-encoding what it finds:
// models/exporter.js — before
const readStream = fs.createReadStream(doc.versions.original.path);
So the attack was three ordinary steps: upload an avatar, update your own avatar document to
point versions.original.path at /etc/passwd (or at anything else
the WeKan process can read, including everything under WRITABLE_PATH), then
export any board you are a member of and decode users[].profile.avatarFile out
of the JSON. Arbitrary file read as the WeKan OS user, available to any authenticated
account. The CDN download path was separately guarded — export was not.
The reason avatars had no guard is that attachments' guard was written INSIDE
server/permissions/attachments.js. A rule that exists in one file protects one
collection. Both halves of the fix are therefore shared modules that both callers import.
The write. models/lib/fileVersionFields.js holds the three checks —
does this insert carry server-managed version metadata, does this update touch the
versions subtree at all, does it stay inside the field whitelist — and
both server/permissions/avatars.js and
server/permissions/attachments.js import them. Avatars keep the ownership check
and add the field checks on top. The update test is deliberately broader than the insert
test: an update arrives as a list of changed field names, and naming
versions.original carries the path along with the rest of the sub-object, so it
is the same attack as spelling out versions.original.path.
The read. Blocking the write is not enough on its own, because a path can be poisoned
other ways — a document written before this fix, a restored backup, a bad migration.
So nothing is read from a stored path unless the path RESOLVES to somewhere inside WeKan's
own storage (models/lib/storagePathContainment.js). Resolving is the point:
/data/files/avatars/../../etc/passwd is not inside
/data/files/avatars, and comparing the relative path also means
/data/files/avatars-evil is not inside it either, which a plain
startsWith would have accepted. The check sits at
getBase64Data — the one place export turns a stored path into bytes, for
attachments and avatars alike — as well as at both avatar call sites.
The DOWNLOAD path in models/lib/fileStoreStrategy.js had 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 now ask the same question. That is the same class of mistake the
advisory is about, removed rather than repeated.
One place was still reading a stored path with nothing but an existsSync: the
STREAMING attachment export. That is not the reported hole — the attachment allow rule
refuses a client-supplied path — but a path is only as trustworthy as every way it
could have been written, so it takes the same check. There is now no read of a stored path
in the export that is not contained.
tests/avatarVersionPathTraversal.test.cjs pins the payload being refused on
insert and on update, that an ordinary upload and a rename are still allowed, that
..-escapes are resolved rather than string-matched, that a sibling directory
sharing the root's prefix is not inside it, that the storage root itself is not a file to
read, and that no unguarded read of a stored version path remains in the exporter.
Reported by Alpastx via GitHub Security
Advisory
GHSA-4mxf-m8pq-xc9p,
confirmed against v10.73.0. Fixed at the
upcoming WeKan release.