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

Contents / PurgeBleed

CVE Vulnerability name Date Responsible Security Disclosure by Vulnerabilities
GHSA-8cqr-x6m5-v4w6

PurgeBleed

2026-08-15 ybsun0215 and xet7

Coordinated disclosure via GitHub Security Advisory GHSA-8cqr-x6m5-v4w6.
  • PurgeBleed — the single-card DELETE authorised the caller on the board in :boardId and then fetched the card by :cardId ALONE, so the child-removal it runs first erased the checklists, checklist items, comments, activity history and subcard tree of any card in the instance (CWE-639 Authorization Bypass Through User-Controlled Key, CWE-862 Missing Authorization)
  • The destruction happens BEFORE the card is removed, so the unconstrained lookup is the whole vulnerability: cardRemover erases everything keyed on whatever card id came back. The removal that follows uses a triple key — {_id, listId, boardId} — so a foreign card was never actually removed, which is why the card SHELL survived and made the damage easy to miss
  • The endpoint answered HTTP 200 with the card id either way, so nothing in the response said that a caller had just destroyed the contents of a card on a board they cannot read. Deletion is irreversible, and the attack is repeatable for every foreign card id an attacker learns
  • Any authenticated user with write access to ONE board — their own is enough — could reach every board on the instance. All deployments with WITH_API=true are affected
  • The bulk endpoint DELETE .../cards/bulk had always constrained its lookup with getCard({ _id, boardId }). The single-card path is the sibling that was missed, the same shape as PassBleed (GHSA-6p5m-f9p2-wqm5) and the REST-side sibling of GHSA-gm7v-pc38-53jr. It uses the constrained lookup now, so a card outside the authorised board simply does not resolve
  • Fixed at upcoming WeKan release


Details

PurgeBleed — authorised on the board, acting on any card (CWE-639, CWE-862)

WeKan can delete a single card over the REST API:

DELETE /api/boards/:boardId/lists/:listId/cards/:cardId
    

Three identifiers, all supplied by the caller. The handler used the first to decide whether the request was allowed, and the third to decide what to act on:

// server/models/cards.js — before
await Authentication.checkBoardWriteAccess(req.userId, paramBoardId); // authorises the URL's board
const card = await ReactiveCache.getCard(paramCardId);                // ANY board's card
if (card) {
  await cardRemover(req.body.authorId, card);                         // destroys the children
}
await Cards.direct.removeAsync({                                       // triple key: no match
  _id: paramCardId, listId: paramListId, boardId: paramBoardId,
});
    

cardRemover removes strictly by card id, with no board constraint at all:

await ChecklistItems.direct.removeAsync({ cardId: doc._id });
await Checklists.direct.removeAsync({ cardId: doc._id });
await CardComments.direct.removeAsync({ cardId: doc._id });
await Activities.direct.removeAsync({ cardId: doc._id });
await Cards.removeAsync({ parentId: doc._id });   // subcard cascade
    

So the order is what makes this severe. The children are destroyed against the card the bare lookup returned; the card itself is then removed against a selector that a foreign card can never match. The victim keeps an empty card and a 200, and the attacker keeps their access.

The fix constrains the lookup the way the bulk endpoint always did, so the card must be on the board the caller was authorised for:

// server/models/cards.js — after
const card = await ReactiveCache.getCard({
  _id: paramCardId,
  boardId: paramBoardId,
});
    

A card on another board is not found, cardRemover is not reached, and nothing is destroyed. tests/restApiIdorBatch.test.cjs pins the constrained lookup, that the bare one does not come back, and that the bulk endpoint still has its own.



Back to Hall of Fame Contents Back to Wekan Website