Files
wekan/models/export.js
T
Lauri Ojansivu 6678c92e08 Security log: wire upload / metrics-XFF / export-authz remediation points.
Record more runtime rejections into the eventlog (Admin Panel Problems):
- fileValidation.js: log every upload rejection (dangerous MIME content ->
  xss.mime, mime not on the allow-list -> file.mime, over size limit ->
  file.size).
- metrics.js: log spoofing.xff when a /metrics request carrying an
  X-Forwarded-For header is denied (likely forged-whitelisted-IP attempt).
- export.js: log authz.export at all four export-authorization denial paths.
All calls are best-effort/guarded (server-only require, try/catch) so logging
never breaks the guard. Source-guard tests added.
2026-07-19 13:59:11 +03:00

514 lines
19 KiB
JavaScript

import { ReactiveCache } from '/imports/reactiveCache';
import { Exporter } from './exporter';
import { buildExternalExport, EXTERNAL_EXPORT_FORMATS } from './lib/externalExporters';
import { Meteor } from 'meteor/meteor';
import ImpersonatedUsers from '/models/impersonatedUsers';
if (Meteor.isServer) {
const { WebApp } = require('meteor/webapp');
const { sendJsonResult } = require('/server/apiMiddleware');
const { Authentication } = require('/server/authentication');
// Record an export authorization denial to the Admin Panel security log
// (best-effort, never breaks the response). See docs/Security/Remediation/WeKan.md.
function logExportDenied() {
try {
require('/server/lib/securityLog').record({
key: 'authz.export', action: 'blocked', source: 'export',
detail: 'export denied (no board visibility)',
});
} catch (e) { /* logging must never break the response */ }
}
// Stream a full board export straight to the response (a card/attachment at a
// time) instead of buffering the whole board object — see Exporter.buildStream.
async function streamJsonBoardExport(res, exporter) {
res.statusCode = 200;
res.setHeader('Cache-Control', 'no-store');
res.setHeader('Pragma', 'no-cache');
res.setHeader('Content-Type', 'application/json');
try {
await exporter.buildStream(res);
res.end();
} catch (error) {
if (process.env.DEBUG === 'true') console.error('board export stream failed:', error);
// If we have not flushed any body yet the headers are still mutable, so we
// can return a clean JSON error; otherwise the partial body is already on
// the wire and all we can do is end it.
if (!res.headersSent) sendJsonResult(res, { code: 500, data: { error: 'Export failed' } });
else try { res.end(); } catch (_) {}
}
}
// Build a Kanboard-style export object from a WeKan board: lists -> columns,
// swimlanes -> swimlanes, cards -> tasks (this is the inverse of the Kanboard
// importer, so a board round-trips through this format).
async function buildKanboardExport(boardId) {
const board = await ReactiveCache.getBoard(boardId);
const lists = await ReactiveCache.getLists({ boardId, archived: false }, { sort: { sort: 1 } });
const swimlanes = await ReactiveCache.getSwimlanes({ boardId, archived: false }, { sort: { sort: 1 } });
const cards = await ReactiveCache.getCards({ boardId, archived: false }, { sort: { sort: 1 } });
const listById = {};
lists.forEach(l => { listById[l._id] = l.title; });
const swById = {};
swimlanes.forEach(s => { swById[s._id] = s.title; });
const labelById = {};
(board.labels || []).forEach(l => { labelById[l._id] = l.name; });
return {
board: { name: board.title },
columns: lists.map(l => ({ title: l.title })),
swimlanes: swimlanes.map(s => ({ name: s.title })),
tasks: cards.map(c => ({
title: c.title,
description: c.description || '',
column_name: listById[c.listId] || '',
swimlane_name: swById[c.swimlaneId] || 'Default',
date_due: c.dueAt ? new Date(c.dueAt).toISOString() : undefined,
tags: (c.labelIds || []).map(id => labelById[id]).filter(Boolean),
})),
};
}
// todo XXX once we have a real API in place, move that route there
// todo XXX also share the route definition between the client and the server
// so that we could use something like
// `ApiRoutes.path('boards/export', boardId)``
// on the client instead of copy/pasting the route path manually between the
// client and the server.
/**
* @operation exportJson
* @tag Boards
*
* @summary This route is used to export the board to a json file format.
*
* @description If user is already logged-in, pass loginToken as param
* "authToken": '/api/boards/:boardId/export?authToken=:token'
*
* See https://blog.kayla.com.au/server-side-route-authentication-in-meteor/
* for detailed explanations
*
* @param {string} boardId the ID of the board we are exporting
* @param {string} authToken the loginToken
*/
WebApp.handlers.get('/api/boards/:boardId/export', async function (req, res) {
const boardId = req.params.boardId;
let user = null;
let impersonateDone = false;
let adminId = null;
// First check if board exists and is public to avoid unnecessary authentication
const board = await ReactiveCache.getBoard(boardId);
if (!board) {
sendJsonResult(res, { code: 404, data: { error: 'Not found' } });
return;
}
// #5870: ?attachments=false exports the board without the base64 attachment
// file data, so very large boards can be exported without overflowing the
// JSON serializer. Default (omitted/any other value) keeps the full export.
const exportOptions = {
excludeAttachments: req.query.attachments === 'false',
};
// If board is public, skip expensive authentication operations
if (board.isPublic()) {
// Public boards don't require authentication - skip hash operations
const exporter = new Exporter(boardId, undefined, exportOptions);
await streamJsonBoardExport(res, exporter);
return;
}
// Only perform expensive authentication for private boards
const loginToken = req.query.authToken;
if (loginToken) {
// Validate token length to prevent resource abuse
if (loginToken.length > 10000) {
if (process.env.DEBUG === 'true') {
console.warn('Suspiciously long auth token received, rejecting to prevent resource abuse');
}
sendJsonResult(res, { code: 400, data: { error: 'Bad request' } });
return;
}
const hashToken = Accounts._hashLoginToken(loginToken);
user = await ReactiveCache.getUser({
'services.resume.loginTokens.hashedToken': hashToken,
});
adminId = user._id.toString();
impersonateDone = await ReactiveCache.getImpersonatedUser({ adminId: adminId });
} else if (!Meteor.settings.public.sandstorm) {
try {
// Any logged-in user may request an export; board-level access is
// enforced below by exporter.canExport() (board.isVisibleBy).
Authentication.checkLoggedIn(req.userId);
} catch (error) {
sendJsonResult(res, { code: error.statusCode || 403, data: { error: (error && error.reason) || 'Forbidden' } });
return;
}
user = await ReactiveCache.getUser({ _id: req.userId });
}
// Anonymized user placeholders ("user1"/"käyttäjä1") follow the exporting
// user's language.
exportOptions.userLanguage = (user && user.profile && user.profile.language) || 'en';
const exporter = new Exporter(boardId, undefined, exportOptions);
if (await exporter.canExport(user)) {
if (impersonateDone && adminId) {
await ImpersonatedUsers.insertAsync({
adminId: adminId,
boardId: boardId,
reason: 'exportJSON',
});
}
await streamJsonBoardExport(res, exporter);
} else {
// we could send an explicit error message, but on the other hand the only
// way to get there is by hacking the UI so let's keep it raw.
logExportDenied();
sendJsonResult(res, { code: 403, data: { error: 'Forbidden' } });
}
});
/**
* @operation exportKanboard
* @tag Boards
* @summary Export the board as a Kanboard-style JSON (columns + tasks).
* @description Pass the loginToken as the `authToken` query param for private
* boards: `/api/boards/:boardId/export/kanboard?authToken=:token`.
* @param {string} boardId the ID of the board we are exporting
* @param {string} authToken the loginToken
*/
WebApp.handlers.get('/api/boards/:boardId/export/kanboard', async function (req, res) {
const boardId = req.params.boardId;
const board = await ReactiveCache.getBoard(boardId);
if (!board) {
sendJsonResult(res, { code: 404, data: { error: 'Not found' } });
return;
}
if (board.isPublic()) {
sendJsonResult(res, { code: 200, data: await buildKanboardExport(boardId) });
return;
}
let user = null;
const loginToken = req.query.authToken;
if (loginToken) {
if (loginToken.length > 10000) {
sendJsonResult(res, { code: 400, data: { error: 'Bad request' } });
return;
}
const hashToken = Accounts._hashLoginToken(loginToken);
user = await ReactiveCache.getUser({
'services.resume.loginTokens.hashedToken': hashToken,
});
} else if (!Meteor.settings.public.sandstorm) {
try {
// Any logged-in user may request an export; board-level access is
// enforced below by exporter.canExport() (board.isVisibleBy).
Authentication.checkLoggedIn(req.userId);
} catch (error) {
sendJsonResult(res, { code: error.statusCode || 403, data: { error: (error && error.reason) || 'Forbidden' } });
return;
}
user = await ReactiveCache.getUser({ _id: req.userId });
}
const exporter = new Exporter(boardId);
if (await exporter.canExport(user)) {
sendJsonResult(res, { code: 200, data: await buildKanboardExport(boardId) });
} else {
logExportDenied();
sendJsonResult(res, { code: 403, data: { error: 'Forbidden' } });
}
});
// Generalized export to other tools: NextCloud Deck, OpenProject, GitHub,
// GitLab, Gitea, Forgejo. One shared auth handler, one route per format.
async function serveExternalExport(req, res, format) {
const boardId = req.params.boardId;
const board = await ReactiveCache.getBoard(boardId);
if (!board) {
sendJsonResult(res, { code: 404, data: { error: 'Not found' } });
return;
}
const respond = async () =>
sendJsonResult(res, { code: 200, data: await buildExternalExport(boardId, format) });
if (board.isPublic()) {
await respond();
return;
}
let user = null;
const loginToken = req.query.authToken;
if (loginToken) {
if (loginToken.length > 10000) {
sendJsonResult(res, { code: 400, data: { error: 'Bad request' } });
return;
}
const hashToken = Accounts._hashLoginToken(loginToken);
user = await ReactiveCache.getUser({
'services.resume.loginTokens.hashedToken': hashToken,
});
} else if (!Meteor.settings.public.sandstorm) {
try {
// Any logged-in user may request an export; board-level access is
// enforced below by exporter.canExport() (board.isVisibleBy).
Authentication.checkLoggedIn(req.userId);
} catch (error) {
sendJsonResult(res, { code: error.statusCode || 403, data: { error: (error && error.reason) || 'Forbidden' } });
return;
}
user = await ReactiveCache.getUser({ _id: req.userId });
}
const exporter = new Exporter(boardId);
if (await exporter.canExport(user)) {
await respond();
} else {
logExportDenied();
sendJsonResult(res, { code: 403, data: { error: 'Forbidden' } });
}
}
EXTERNAL_EXPORT_FORMATS.forEach(format => {
/**
* @operation exportExternal
* @tag Boards
* @summary Export the board as a NextCloud Deck / OpenProject / GitHub /
* GitLab / Gitea / Forgejo style JSON.
* @param {string} boardId the ID of the board we are exporting
* @param {string} authToken the loginToken
*/
WebApp.handlers.get(`/api/boards/:boardId/export/${format}`, async function (req, res) {
await serveExternalExport(req, res, format);
});
});
// todo XXX once we have a real API in place, move that route there
// todo XXX also share the route definition between the client and the server
// so that we could use something like
// `ApiRoutes.path('boards/export', boardId)``
// on the client instead of copy/pasting the route path manually between the
// client and the server.
/**
* @operation exportJson
* @tag Boards
*
* @summary This route is used to export a attachement to a json file format.
*
* @description If user is already logged-in, pass loginToken as param
* "authToken": '/api/boards/:boardId/attachments/:attachmentId/export?authToken=:token'
*
*
* @param {string} boardId the ID of the board we are exporting
* @param {string} attachmentId the ID of the attachment we are exporting
* @param {string} authToken the loginToken
*/
WebApp.handlers.get(
'/api/boards/:boardId/attachments/:attachmentId/export',
async function (req, res) {
const boardId = req.params.boardId;
const attachmentId = req.params.attachmentId;
let user = null;
let impersonateDone = false;
let adminId = null;
// First check if board exists and is public to avoid unnecessary authentication
const board = await ReactiveCache.getBoard(boardId);
if (!board) {
sendJsonResult(res, { code: 404, data: { error: 'Not found' } });
return;
}
// If board is public, skip expensive authentication operations
if (board.isPublic()) {
// Public boards don't require authentication - skip hash operations
const exporter = new Exporter(boardId, attachmentId);
sendJsonResult(res, {
code: 200,
data: await exporter.build(),
});
return;
}
// Only perform expensive authentication for private boards
const loginToken = req.query.authToken;
if (loginToken) {
// Validate token length to prevent resource abuse
if (loginToken.length > 10000) {
if (process.env.DEBUG === 'true') {
console.warn('Suspiciously long auth token received, rejecting to prevent resource abuse');
}
sendJsonResult(res, { code: 400, data: { error: 'Bad request' } });
return;
}
const hashToken = Accounts._hashLoginToken(loginToken);
user = await ReactiveCache.getUser({
'services.resume.loginTokens.hashedToken': hashToken,
});
adminId = user._id.toString();
impersonateDone = await ReactiveCache.getImpersonatedUser({ adminId: adminId });
} else if (!Meteor.settings.public.sandstorm) {
try {
// Any logged-in user may request an export; board-level access is
// enforced below by exporter.canExport() (board.isVisibleBy).
Authentication.checkLoggedIn(req.userId);
} catch (error) {
sendJsonResult(res, { code: error.statusCode || 403, data: { error: (error && error.reason) || 'Forbidden' } });
return;
}
user = await ReactiveCache.getUser({ _id: req.userId, isAdmin: true });
}
const exporter = new Exporter(boardId, attachmentId);
if (await exporter.canExport(user)) {
if (impersonateDone) {
await ImpersonatedUsers.insertAsync({
adminId: adminId,
boardId: boardId,
attachmentId: attachmentId,
reason: 'exportJSONattachment',
});
}
sendJsonResult(res, {
code: 200,
data: await exporter.build(),
});
} else {
// we could send an explicit error message, but on the other hand the only
// way to get there is by hacking the UI so let's keep it raw.
logExportDenied();
sendJsonResult(res, { code: 403, data: { error: 'Forbidden' } });
}
},
);
/**
* @operation exportCSV/TSV
* @tag Boards
*
* @summary This route is used to export the board to a CSV or TSV file format.
*
* @description If user is already logged-in, pass loginToken as param
*
* See https://blog.kayla.com.au/server-side-route-authentication-in-meteor/
* for detailed explanations
*
* @param {string} boardId the ID of the board we are exporting
* @param {string} authToken the loginToken
* @param {string} delimiter delimiter to use while building export. Default is comma ','
*/
WebApp.handlers.get('/api/boards/:boardId/export/csv', async function (req, res) {
const boardId = req.params.boardId;
let user = null;
let impersonateDone = false;
let adminId = null;
// First check if board exists and is public to avoid unnecessary authentication
const board = await ReactiveCache.getBoard(boardId);
if (!board) {
res.writeHead(404);
res.end('Board not found');
return;
}
// If board is public, skip expensive authentication operations
if (board.isPublic()) {
// Public boards don't require authentication - skip hash operations
const exporter = new Exporter(boardId);
if( req.query.delimiter == "\t" ) {
// TSV file
res.writeHead(200, {
'Content-Type': 'text/tsv',
});
}
else {
// CSV file (comma or semicolon)
res.writeHead(200, {
'Content-Type': 'text/csv; charset=utf-8',
});
// Adding UTF8 BOM to quick fix MS Excel issue
// use Uint8Array to prevent from converting bytes to string
res.write(new Uint8Array([0xEF, 0xBB, 0xBF]));
}
await exporter.buildCsvStream(res, req.query.delimiter, 'en');
res.end();
return;
}
// Only perform expensive authentication for private boards
const loginToken = req.query.authToken;
if (loginToken) {
// Validate token length to prevent resource abuse
if (loginToken.length > 10000) {
if (process.env.DEBUG === 'true') {
console.warn('Suspiciously long auth token received, rejecting to prevent resource abuse');
}
res.writeHead(400);
res.end('Invalid token');
return;
}
const hashToken = Accounts._hashLoginToken(loginToken);
user = await ReactiveCache.getUser({
'services.resume.loginTokens.hashedToken': hashToken,
});
adminId = user._id.toString();
impersonateDone = await ReactiveCache.getImpersonatedUser({ adminId: adminId });
} else if (!Meteor.settings.public.sandstorm) {
try {
// Any logged-in user may request an export; board-level access is
// enforced below by exporter.canExport() (board.isVisibleBy).
Authentication.checkLoggedIn(req.userId);
} catch (error) {
sendJsonResult(res, { code: error.statusCode || 403, data: { error: (error && error.reason) || 'Forbidden' } });
return;
}
user = await ReactiveCache.getUser({
_id: req.userId,
isAdmin: true,
});
}
const exporter = new Exporter(boardId);
if (await exporter.canExport(user)) {
if (impersonateDone) {
let exportType = 'exportCSV';
if( req.query.delimiter == "\t" ) {
exportType = 'exportTSV';
}
await ImpersonatedUsers.insertAsync({
adminId: adminId,
boardId: boardId,
reason: exportType,
});
}
let userLanguage = 'en';
if (user && user.profile) {
userLanguage = user.profile.language
}
if( req.query.delimiter == "\t" ) {
// TSV file
res.writeHead(200, {
'Content-Type': 'text/tsv',
});
}
else {
// CSV file (comma or semicolon)
res.writeHead(200, {
'Content-Type': 'text/csv; charset=utf-8',
});
// Adding UTF8 BOM to quick fix MS Excel issue
// use Uint8Array to prevent from converting bytes to string
res.write(new Uint8Array([0xEF, 0xBB, 0xBF]));
}
await exporter.buildCsvStream(res, req.query.delimiter, userLanguage);
res.end();
} else {
res.writeHead(403);
res.end('Permission Error');
}
});
}