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

Contents / PortraitBleed

CVE Vulnerability name Date Responsible Security Disclosure by Vulnerabilities
-

PortraitBleed

2026-09-09 xet7

Found and fixed during an internal security review.
  • PortraitBleed — unlike Attachments, Avatars never set a protected callback, so ostrio:files' own library-native download route (whose _checkAccess defaults to allowing everything when protected is unset) served any avatar to any anonymous caller, entirely bypassing WeKan's own isAuthorizedForAvatar check (CWE-862 Missing Authorization, CWE-284 Improper Access Control)
  • Avatars.protected now mirrors Attachments.protected: an authenticated caller may always view an avatar; an anonymous one only when the avatar's owner is a member of a public board
  • Fixed at upcoming WeKan release


Details

PortraitBleed — the one callback Attachments had that Avatars did not (CWE-862, CWE-284)

ostrio:files' own download route decides whether to serve a file through a callback named protected. Its default, when a collection never sets one, is permissive:

// ostrio:files (conceptually) — _checkAccess
if (this.protected) {
  // only then does anything get checked
}
return true; // no `protected` set at all ⇒ every request passes
    

models/attachments.server.js sets one:

Attachments.protected = async function (fileObj) {
  if (!fileObj) return false;
  const board = await Boards.findOneAsync(fileObj.meta.boardId);
  if (!board) return false;
  if (board.isPublic()) return true;
  return board.hasMember(this.userId);
};
    

models/avatars.server.js had no equivalent at all. The library's own globally-registered download middleware for the three-segment URL /cdn/storage/avatars/{id}/original/{name} ran ahead of - and independently of - WeKan's own authorized route in server/routes/avatarServer.js and its isAuthorizedForAvatar check in server/routes/universalFileServer.js. Both of WeKan's own checks exist; neither one is what a request to that URL actually goes through, because the library's own middleware answers first and never calls next().

So the exploit was one anonymous request, given only an avatar's id:

GET /cdn/storage/avatars/<avatarId>/original/<avatarId>.jpg
    

Any visitor holding an avatar id - from a rendered avatar on a public board page, a miniprofile payload, or simple enumeration - could download the full-size avatar of any user on the instance, private-board-only members included, with a one-year public cache header on top.

The fix. Avatars.protected is added, mirroring Attachments.protected's shape as closely as the two collections' semantics allow: an authenticated caller (this.userId) may always view an avatar, matching how avatars have always been treated as visible to any logged-in user. An anonymous caller is allowed only when the avatar's owner is a member of a board whose permission is public - the same rule avatarIsOnAPublicBoard already applies for WeKan's own route, so an avatar shown on a public board keeps working for a visitor who is not signed in, and nothing else does.

A denied anonymous request is recorded through the shared security log under a new authz.avatar-protected/PortraitBleed catalog key, so Admin Panel → Problems shows an attempt happened - logged only on the deny branch, never on an ordinary authenticated view. tests/attachmentAvatarSecurityAdvisories.test.cjs pins the callback's shape and that it is logged.

Found and fixed during an internal security review, confirmed against the current source. Fixed at the upcoming WeKan release.