mirror of
https://github.com/wekan/wekan.git
synced 2026-11-07 02:52:33 -05:00
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.
514 lines
19 KiB
JavaScript
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');
|
|
}
|
|
});
|
|
}
|