| CVE | Vulnerability name | Date | Responsible Security Disclosure by | Vulnerabilities |
|---|---|---|---|---|
|
GHSA-6p5m-f9p2-wqm5
|
PassBleed |
2026-08-11 |
TWPaMWang and xet7
![]() Coordinated disclosure via GitHub Security Advisory GHSA-6p5m-f9p2-wqm5. |
|
WeKan can export a single card as an Excel workbook:
GET /api/boards/:boardId/lists/:listId/cards/:cardId/exportExcel
Three identifiers, all supplied by the caller. The handler used the first one
to decide whether the request was allowed, and the third one to decide what to
read — and nothing anywhere confirmed that the card in :cardId
was actually on the board in :boardId.
The authorisation gate is canExport():
// models/server/ExporterExcelCard.js — before
async canExport(user) {
const board = await ReactiveCache.getBoard(this._boardId);
return board && board.isVisibleBy(user);
}
Its input is this._boardId. this._cardId never
participates. It answers "may this user see that board?" and cannot
answer "may this user see that card?", because it is never told which
card is being asked for.
The data lookup was then a bare primary key:
// models/server/ExporterExcelCard.js — before
const card = await ReactiveCache.getCard(this._cardId);
No boardId, no listId. A card id belonging to an
entirely different, entirely private board resolved perfectly well.
Because the pass is self-service. POST /api/boards checks only
that the caller is logged in and takes permission straight from
the request body, so any registered user can create their own board with
permission: "public". Name that board in :boardId
and the check passes every time — on the public branch it skips
authentication altogether. :listId was never used in a query at
all, so it could be any string.
What came back was not a hint that a card exists. It was the card: title, full
description, members and assignees, every comment with its author, checklists
and checklist items, subtask titles, and attachment metadata. And because
image attachments are read through getReadStream() and embedded
into the workbook with workbook.addImage, the attachment
bytes came with it — functionally an arbitrary private attachment
download.
The same board could be reused indefinitely while :cardId was
substituted, so this was a scriptable bulk-collection primitive rather than a
one-off disclosure. The REST API is on by default in the shipped Docker
configuration, so the route is reachable on most containerised deployments.
The structurally identical PDF route,
/api/boards/:boardId/lists/:listId/cards/:cardId/exportPDF, has
always resolved its card correctly:
// models/server/ExporterCardPDF.js
const card = await ReactiveCache.getCard({
_id: this._cardId,
boardId: this._boardId,
listId: this._listId,
});
A cross-board id resolves to null there and the handler answers
404. That control in the same repository is what shows the Excel exporter's
omission was a defect rather than a decision, and it is exactly what the
Excel exporter now does.
Constraining the query matters more than adding a check after it. The exporter fans out on the same card id — checklists, checklist items, subtasks, comments, attachments — and none of those carry a board constraint of their own. When the card itself cannot resolve outside the authorised board, every one of them becomes safe by construction.
The route binds the two identifiers as well, before either branch builds. That is deliberate duplication: the check is cheapest to state where both identifiers arrive together, and it covers the public-board branch, which skips authentication entirely. A card that is not on the named board is a 404 rather than a 403, so the difference does not tell an unauthorised caller whether a given card id exists.
Reported by TWPaMWang via
GitHub Security Advisory
GHSA-6p5m-f9p2-wqm5,
by source review of v10.77, with the full source-to-sink chain and
the PDF exporter identified as the control. Fixed at the
upcoming WeKan release.