Files
wekan/models/exportExcelCard.js
Lauri Ojansivu 08ae61161c Fixed TokenBleed: unauthenticated login-token minting via un-awaited auth check in
`POST /api/createtoken/:userId`](https://github.com/wekan/wekan/blob/main/CHANGELOG.md)
(CWE-863, CWE-287). `Authentication.checkUserId` in `server/authentication.js` is an
`async` function, so its 401 (undefined `userId`) and 403 (not an admin) throws become
rejected promises rather than synchronous exceptions. The REST handlers in
`server/models/users.js` and `server/models/boards.js` called it without `await` inside a
plain synchronous `try/catch`, which cannot catch a rejected promise, so the failed check
never stopped execution. `POST /api/createtoken/:userId` then went on to mint and return a
usable login token for any user ID in the URL — including an admin — with no credentials at
all (unauthenticated account takeover). The same detached-rejection bypass also affected
`GET /api/users`, `GET /api/users/:userId`, `PUT /api/users/:userId`, `POST /api/users/`,
`DELETE /api/users/:userId`, `POST /api/deletetoken`, `GET /api/boards`,
`GET /api/boards_count`, `DELETE /api/boards/:boardId`, `GET /api/users/:userId/boards` and
`POST /api/boards/:boardId/copy`. Fixed by awaiting every async `Authentication` check (and
making the two non-`async` handlers `async`) so a failed check rejects before any privileged
code runs. The same un-awaited pattern in the board/card/Excel/PDF export handlers
(`models/export.js`, `models/exportExcel.js`, `models/exportExcelCard.js`, `models/exportPDF.js`,
which were backstopped by `exporter.canExport()`) and in the checklist-create handler
(`server/models/checklists.js`) was given the same await pass. Affected Wekan v9.35 and earlier.

Thanks to Zion Boggan, xet7 and Claude.
2026-06-10 19:39:05 +03:00

134 lines
4.9 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Meteor } from 'meteor/meteor';
import { ReactiveCache } from '/imports/reactiveCache';
import { TAPi18n } from '/imports/i18n';
import { runOnServer } from './runOnServer';
import ImpersonatedUsers from '/models/impersonatedUsers';
runOnServer(function() {
const { ExporterExcelCard, ALL_FIELDS } = require('./server/ExporterExcelCard');
const { WebApp } = require('meteor/webapp');
const { Authentication } = require('/server/authentication');
/**
* @operation exportExcelCard
* @tag Cards
*
* @summary Export a single card to Excel (.xlsx), formatted for DIN A4 Portrait printing.
*
* @description If user is already logged-in, pass loginToken as param
* "authToken": '/api/boards/:boardId/lists/:listId/cards/:cardId/exportExcel?authToken=:token'
*
* Optional query param "fields" is a comma-separated list of sections to include.
* Valid values: people, board-info, dates, description, checklists, subtasks, comments
* Omitting "fields" includes all sections.
*
* @param {string} boardId the ID of the board
* @param {string} listId the ID of the list
* @param {string} cardId the ID of the card to export
* @param {string} authToken the loginToken
* @param {string} fields comma-separated list of sections to include
*/
WebApp.handlers.get(
'/api/boards/:boardId/lists/:listId/cards/:cardId/exportExcel',
async function (req, res) {
const boardId = req.params.boardId;
const paramListId = req.params.listId;
const paramCardId = req.params.cardId;
let user = null;
let impersonateDone = false;
let adminId = null;
const board = await ReactiveCache.getBoard(boardId);
if (!board) {
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Board not found');
return;
}
// Public boards skip authentication
if (board.isPublic()) {
const fieldsParam = req.query.fields;
const fields = fieldsParam
? fieldsParam.split(',').map(f => f.trim()).filter(f => ALL_FIELDS.includes(f))
: null;
const exporter = new ExporterExcelCard(boardId, paramListId, paramCardId, 'en', fields);
await exporter.build(res);
return;
}
// Authenticate for private boards
const loginToken = req.query.authToken;
if (loginToken) {
if (loginToken.length > 10000) {
res.writeHead(400, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Invalid token');
return;
}
const hashToken = Accounts._hashLoginToken(loginToken);
user = await ReactiveCache.getUser({
'services.resume.loginTokens.hashedToken': hashToken,
});
if (!user) {
res.writeHead(401, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Invalid token');
return;
}
adminId = user._id.toString();
impersonateDone = await ReactiveCache.getImpersonatedUser({ adminId });
} else if (!Meteor.settings.public.sandstorm) {
try {
await Authentication.checkUserId(req.userId);
} catch (error) {
res.writeHead(error.statusCode || 403, { 'Content-Type': 'text/plain; charset=utf-8' });
res.end('Unauthorized');
return;
}
user = await ReactiveCache.getUser({
_id: req.userId,
isAdmin: true,
});
}
// Determine language: prefer the active UI language sent by the client
// (?lang=fi), then the profile setting, then English. The client-side
// param is necessary because WeKan may use the browser locale without
// ever writing it to profile.language.
let userLanguage =
req.query.lang ||
(user && user.profile && user.profile.language) ||
'en';
// Ensure the chosen language bundle is loaded into i18next.
try {
await TAPi18n.loadLanguage(userLanguage);
} catch (_) {
// Unknown / unsupported language – fall back to English silently.
userLanguage = 'en';
}
const dateFormat = (user && user.profile && user.profile.dateFormat) || 'YYYY-MM-DD';
// Parse optional ?fields=people,dates,... query param
const fieldsParam = req.query.fields;
const fields = fieldsParam
? fieldsParam.split(',').map(f => f.trim()).filter(f => ALL_FIELDS.includes(f))
: null;
const exporter = new ExporterExcelCard(boardId, paramListId, paramCardId, userLanguage, fields, dateFormat);
if ((await exporter.canExport(user)) || impersonateDone) {
if (impersonateDone) {
await ImpersonatedUsers.insertAsync({
adminId,
boardId,
reason: 'exportExcelCard',
});
}
await exporter.build(res);
} else {
res.end(TAPi18n.__('user-can-not-export-card-to-excel'));
}
},
);
});