Files
wekan/models/exportExcelCard.js
T
Lauri Ojansivu 1669a1af19 Fix 7 security issues reported by meifukun.
- Avatar localization redirect SSRF: fetch avatars via fetchSafe (DNS-pinned,
  redirects rejected) instead of native redirect:follow, and validate
  profile.avatarUrl scheme (http/https/data:image/local) at write time.
- Trello import source.url stored XSS: only render/store an http(s) activity
  source URL, so a javascript: board url can no longer execute on click.
- Trello LIVE import readable SSRF: gate every attachment/background/avatar
  download with validateAttachmentUrl, matching the offline import path.
- CAS login global user-data race: store validated CAS user data per credential
  token instead of a module-global _userData (fixes account-takeover race).
- Metrics X-Forwarded-For whitelist bypass: only trust XFF behind
  METRICS_TRUST_PROXY (parsed spoof-resistantly from the right); else use the
  socket peer address.
- Stale impersonation export bypass: remove the "|| impersonateDone" export
  authorization bypass; require real board visibility (canExport) only.
- Invitation code brute force: generate invitation codes with a 128-bit CSPRNG
  (crypto.randomBytes) instead of a 6-digit Math.random value, and rate-limit
  createUser (DDPRateLimiter).

The OIDC shared serviceData race from the same report was already fixed (#4897,
the per-login objects are callout-local). Source-guard regression test added
(tests/securityMeifukun.test.cjs) and wired into test:unit:node.

Thanks to meifukun and xet7 !
2026-07-19 12:22:56 +03:00

134 lines
4.8 KiB
JavaScript
Raw 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))) {
if (impersonateDone) {
await ImpersonatedUsers.insertAsync({
adminId,
boardId,
reason: 'exportExcelCard',
});
}
await exporter.build(res);
} else {
res.end(TAPi18n.__('user-can-not-export-card-to-excel'));
}
},
);
});