| CVE | Vulnerability name | Date | Responsible Security Disclosure by | Vulnerabilities |
|---|---|---|---|---|
|
-
|
PortraitBleed |
2026-09-09 |
xet7
![]() Found and fixed during an internal security review. |
|
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.