| CVE | Vulnerability name | Date | Responsible Security Disclosure by | Vulnerabilities |
|---|---|---|---|---|
|
-
|
RelicAvatarBleed |
2026-09-09 |
xet7
![]() Found and fixed during an internal security review. |
|
WeKan migrated its file storage from CollectionFS to ostrio:files. Some installs upgraded from
a version old enough to still carry avatar data in the legacy
cfs.avatars.filerecord/cfs_gridfs.avatars collections, and
serveLegacyAvatar() exists to read that data in place without migrating it:
// server/routes/avatarServer.js — before
async function serveLegacyAvatar(fileId, req, res) {
const legacy = await getOldAttachmentData(fileId, 'avatars'); // no user context at all
if (!legacy) return false;
const stream = await getOldAttachmentStream(fileId, 'avatars');
...
res.writeHead(200);
stream.pipe(res); // served to anyone
return true;
}
Two routes called it. On /cdn/storage/avatars/:fileName, it ran as a fallback when the id was
not a current Avatars document - BEFORE the route's own
getUserIdFromRequest/avatarIsOnAPublicBoard check further down, so a legacy id
bypassed even the public-board restriction current avatars get. On
/cfs/files/avatars/:fileName, it was the PRIMARY path, called with no credential check
whatsoever:
// server/routes/avatarServer.js — before
WebApp.handlers.use('/cfs/files/avatars/:fileName', async (req, res, next) => {
...
if (await serveLegacyAvatar(fileId, req, res)) return; // nothing gates this
... // redirect to the new URL format otherwise
});
getOldAttachmentData (models/lib/attachmentBackwardCompatibility.js) looks the
record up by _id directly, with no user context - the lookup always succeeds for an anonymous
caller when the record exists. So the exploit was one anonymous request, given only an old file-record id
(ids that were embedded in historical profile.avatarUrl values of the form
/cfs/files/avatars/<id>):
GET /cfs/files/avatars/<legacyFileRecordId>
returning the avatar bytes with a one-year public cache header, no login required - on any instance migrated from WeKan older than 6.x that still has legacy avatar rows.
The fix. A new isLegacyAvatarAuthorized(req) helper requires a resolved
getUserIdFromRequest(req) - unlike current avatars, a legacy record carries no owner/board
link that can be safely checked for the public-board exemption
avatarIsOnAPublicBoard gives current avatars, so this is a deliberately NARROWER rule rather
than reusing that exemption on an unverifiable claim. Both call sites now check it before reading the
legacy store: the /cdn/storage/avatars fallback returns 401 for an unauthenticated caller
without ever revealing whether the id exists at all, and the /cfs/files/avatars route skips
straight to its existing redirect (itself authorized, since it lands back on
/cdn/storage/avatars) rather than reading the legacy store for an anonymous caller - so an
anonymous visitor to an already-migrated, public-board avatar's old URL still sees it normally, and only
the un-migrated legacy read now requires a login.
A denied attempt on the /cdn/storage/avatars fallback is recorded through the shared security
log under a new authz.legacy-avatar/RelicAvatarBleed catalog key.
tests/attachmentAvatarSecurityAdvisories.test.cjs pins both call sites and, as a negative
case, that the redirect fallback still works for an anonymous caller.
Found and fixed during an internal security review, confirmed against the current source. Fixed at the upcoming WeKan release.