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

Contents / PassBleed

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.
  • PassBleed — the single-card Excel export authorised against one identifier and read its data using another, so the board id became a reusable access pass and the card id alone decided what came back (CWE-639, CVSS 6.5)
  • Any authenticated user could mint their own public board, name it as :boardId, and export any card from any private board on the instance — including the raw bytes of its image attachments
  • Affected models/server/ExporterExcelCard.js and its route in models/exportExcelCard.js; the PDF exporter for the identical route shape had always been correct
  • Fixed at upcoming WeKan release


Details

PassBleed — authorised on one object, read from another (CWE-639)

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.

Why that turns into bulk exfiltration

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 fix was already in the codebase, one file away

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.