Hall of Fame image from https://openclipart.org/detail/120343/trophy
Back to Hall of Fame Contents Back to Wekan Website

Contents / PathBleed

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.
  • PathBleed — an avatar's owner could write the ON-DISK PATH of their own avatar, because the guard attachments have was never copied to avatars (CWE-22 Path Traversal, CWE-73 External Control of File Name or Path)
  • Board export then read that path and embedded the bytes as base64 in profile.avatarFile, so any authenticated user who can own an avatar and export a board could read any file the WeKan process can read
  • Affected server/permissions/avatars.js (the write) and models/exporter.js (the read); attachments were already guarded, which is what made the gap invisible
  • Fixed at upcoming WeKan release


Details

PathBleed — the guard attachments had, avatars did not (CWE-22, CWE-73)

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.

Why that was a file read: board export

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 fix: both halves, and each rule in one place

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.