mirror of
https://github.com/wekan/wekan.git
synced 2026-11-07 11:02:33 -05:00
GHSA-8885-xrq4-j47h, reported by ccsimplyspolit, both findings confirmed
in the current code:
1. GET /api/boards/<board>/export?checklistId=<id> (and exportZip)
selected the scoped checklist and its items by id alone. A checklist
of another board - a private one too - was streamed with every item
and its creator, whose profile the anonymization map did not cover.
Both selectors are now bound to the exported board's cards, and a
checklist of another board is recorded in Admin Panel / Problems
(authz.export-checklist-scope).
2. The streaming CSV/TSV export asked neither Disable all export nor
Anonymize export users. Neither did, it turned out, Excel (board,
card, all boards), PDF (board and card), the chart exports, the
calendar feed or the external formats: only the WeKan JSON, ZIP and
Trello exports did, though the setting promises that "the server
rejects any export request" and names CSV and Excel.
- refuseDisabledExport() answers 403 before anything is written, and
all 13 HTTP export routes call it first; assertExportEnabled() still
guards the builders. Both record the attempt (authz.export-disabled,
never blocking the account: a page opened before the switch still
shows the menus).
- anonymizeExportIdentities() anonymizes what one exporter writes -
users, @mentions, requested-by / assigned-by, comments - with the map
build() uses, board members included. The CSV/TSV stream, Excel,
PDF, chart exports (forExport: the chart views keep names), the
calendar feed and every external format call it.
tests/crossBoardExportBleed.test.cjs drives the gate and the anonymizer
and scans the tree: no scoped checklist selected by id alone, no export
route without the gate, no exporter naming people without anonymizing.
All 7 fail on the code before. The new spec
security-cross-board-export.e2e.js replays the reporter's requests and
checks every export route while export is disabled and the CSV and
Kanboard exports while anonymizing; it passes with export-access and
scrum-export-anonymization against a rebuilt bundle.
Thanks to ccsimplyspolit and xet7 !
206 lines
10 KiB
JavaScript
206 lines
10 KiB
JavaScript
// Loads the plain records one board report chart needs and hands them to the
|
|
// pure calculations in models/lib/chartCalculations.js. Shared by the
|
|
// `boardChartData` Meteor method (server/publications/boards.js) and the
|
|
// chart PDF/Excel export routes (models/exportCharts.js) so the two answer
|
|
// with the same numbers. Callers are responsible for the authorization check
|
|
// (board.isVisibleBy) - this module only reads data.
|
|
import { ReactiveCache } from '/imports/reactiveCache';
|
|
import Cards from '/models/cards';
|
|
import Activities from '/models/activities';
|
|
|
|
const {
|
|
computeCumulativeFlow,
|
|
computeWipRun,
|
|
computeControlChart,
|
|
computeLeadCycleTime,
|
|
computeBurndown,
|
|
computeBurnup,
|
|
computeThroughput,
|
|
computeCompletionForecast,
|
|
computeFlowEfficiency,
|
|
computeActivityPulse,
|
|
computeDashboardGroups,
|
|
NO_ASSIGNEE_GROUP,
|
|
NO_LABEL_GROUP,
|
|
computeTimeByGroup,
|
|
computeTimeByCard,
|
|
computeCardsByAssigneeGroup,
|
|
computeRemainingTimeSum,
|
|
} = require('/models/lib/chartCalculations');
|
|
|
|
export async function loadBoardChartData(boardId, chartKey, options = {}) {
|
|
const board = await ReactiveCache.getBoard(boardId);
|
|
if (!board) return null;
|
|
|
|
const lists = await ReactiveCache.getLists({ boardId, archived: false }, { sort: { sort: 1 } });
|
|
const { FLOW_CHART_KEYS, computeFlowAnalytics } = require('/models/lib/flowAnalytics');
|
|
const advanced = FLOW_CHART_KEYS.includes(chartKey);
|
|
const cardFields = {
|
|
fields: {
|
|
title: 1, listId: 1, createdAt: 1, archivedAt: 1, archived: 1,
|
|
startAt: 1, endAt: 1, dueAt: 1, spentTime: 1, isOvertime: 1,
|
|
assignees: 1, members: 1, labelIds: 1,
|
|
...(advanced ? { boardId: 1, poker: 1, customFields: 1, cardDependencies: 1, deletedAt: 1 } : {}),
|
|
},
|
|
};
|
|
const rawCards = await Cards.find({ boardId }, cardFields).fetchAsync();
|
|
const { fieldReadContext } = require('/server/lib/adminFieldReadContext');
|
|
const { redact } = require('/models/lib/adminOnlyCustomFields');
|
|
const policy = fieldReadContext.getStore();
|
|
const allCards = policy ? redact(rawCards, policy.definitions, policy.adminBoards, boardId) : rawCards;
|
|
if (advanced) {
|
|
const ChangeHistory = require('/models/changeHistory').default;
|
|
const [events, history, fields] = await Promise.all([
|
|
['agingWip', 'blockerAnalysis'].includes(chartKey) ? Activities.find(
|
|
{ boardId, activityType: { $in: ['createCard', 'moveCard', 'moveCardBoard', 'archivedCard', 'restoredCard'] } },
|
|
{ fields: { cardId: 1, listId: 1, oldListId: 1, createdAt: 1, activityType: 1 } },
|
|
).fetchAsync() : [],
|
|
ChangeHistory.find(
|
|
{ boardId, entityType: 'card', group: { $in: ['position', 'dates', 'lifecycle', 'dependencies'] } },
|
|
{ fields: { entityType: 1, entityId: 1, group: 1, previousContent: 1, newContent: 1, createdAt: 1 } },
|
|
).fetchAsync(),
|
|
chartKey === 'sizeCycleTime' ? ReactiveCache.getCustomFields({ boardIds: boardId, type: 'number' }) : [],
|
|
]);
|
|
// Universal position history also covers restores. Its newer position
|
|
// wins over an earlier activity; same-list records do not reset age.
|
|
for (const row of history.filter(row => row.group === 'position')) {
|
|
events.push({ cardId: row.entityId, listId: row.newContent?.listId,
|
|
oldListId: row.previousContent?.listId, createdAt: row.createdAt, activityType: 'moveCard' });
|
|
}
|
|
const { withRemovedCards } = require('/models/lib/timeHistory');
|
|
return computeFlowAnalytics(chartKey, withRemovedCards(allCards, history, boardId), lists, events, fields, options, new Date(), history);
|
|
}
|
|
|
|
const firstCreatedAt = allCards.reduce(
|
|
(min, card) => (!min || card.createdAt < min ? card.createdAt : min), null);
|
|
const fromDate = firstCreatedAt || new Date();
|
|
const toDate = new Date();
|
|
|
|
if (chartKey === 'pulse') {
|
|
// #1292 ("GitHub Pulse-like graph"): total board activity per day over a
|
|
// fixed recent window, independent of allCards' fromDate (a quiet board
|
|
// with old cards should still show a 30-day-wide, mostly-zero chart
|
|
// rather than one card's creation date away).
|
|
const pulseToDate = new Date();
|
|
const pulseFromDate = new Date(pulseToDate);
|
|
pulseFromDate.setUTCDate(pulseFromDate.getUTCDate() - 29);
|
|
const pulseActivities = await Activities.find(
|
|
{ boardId, createdAt: { $gte: pulseFromDate, $lte: pulseToDate } },
|
|
{ fields: { createdAt: 1 } },
|
|
).fetchAsync();
|
|
return {
|
|
series: computeActivityPulse(pulseActivities, pulseFromDate, pulseToDate, 'day'),
|
|
};
|
|
}
|
|
|
|
if (chartKey === 'cumulativeFlow' || chartKey === 'wipRun') {
|
|
const events = await Activities.find(
|
|
{ boardId, activityType: { $in: ['createCard', 'moveCard', 'archivedCard', 'restoredCard'] } },
|
|
{ fields: { activityType: 1, cardId: 1, listId: 1, createdAt: 1 }, sort: { createdAt: 1 } },
|
|
).fetchAsync();
|
|
|
|
if (chartKey === 'cumulativeFlow') {
|
|
return {
|
|
lists: lists.map(list => ({ _id: list._id, title: list.title })),
|
|
series: computeCumulativeFlow(lists, events, fromDate, toDate),
|
|
};
|
|
}
|
|
const wipListIds = lists.length > 2 ? lists.slice(1, -1).map(l => l._id) : lists.map(l => l._id);
|
|
const wipLimit = lists.length > 2
|
|
? lists.slice(1, -1).reduce((sum, list) =>
|
|
(list.wipLimit && list.wipLimit.enabled) ? sum + (list.wipLimit.value || 0) : sum, 0) || null
|
|
: null;
|
|
return { series: computeWipRun(wipListIds, events, fromDate, toDate, wipLimit) };
|
|
}
|
|
|
|
if (chartKey === 'controlChart') return { points: computeControlChart(allCards) };
|
|
if (chartKey === 'leadTime' || chartKey === 'cycleTime') return { points: computeLeadCycleTime(allCards) };
|
|
if (chartKey === 'burndown') return { series: computeBurndown(allCards, fromDate, toDate) };
|
|
if (chartKey === 'burnup') return { series: computeBurnup(allCards, fromDate, toDate) };
|
|
if (chartKey === 'throughputHistogram') {
|
|
const series = computeThroughput(allCards, 'week');
|
|
// #1476 ("completion estimates based on velocity"): a forward-looking
|
|
// projection alongside the histogram itself - at the recent weekly
|
|
// completion rate, when the cards still open would be done.
|
|
return { series, forecast: computeCompletionForecast(allCards, series, 7, 4) };
|
|
}
|
|
if (chartKey === 'flowEfficiency') return { points: computeFlowEfficiency(allCards) };
|
|
|
|
if (chartKey === 'gantt') {
|
|
return {
|
|
cards: allCards
|
|
.filter(card => card.startAt || card.dueAt || card.endAt || card.createdAt)
|
|
.sort((a, b) => new Date(a.startAt || a.createdAt) - new Date(b.startAt || b.createdAt))
|
|
.map(card => ({
|
|
title: card.title, startAt: card.startAt || null, dueAt: card.dueAt || null, endAt: card.endAt || null,
|
|
})),
|
|
};
|
|
}
|
|
|
|
if (chartKey === 'dashboard' || chartKey === 'time' || chartKey === 'groupByAssignee') {
|
|
const usersById = {};
|
|
const adjustmentsHistory = chartKey === 'time'
|
|
? await require('/models/changeHistory').default.find({ boardId, entityType: 'card',
|
|
$or: [{ 'newContent.field': 'spentTime' }, { 'previousContent.field': 'spentTime' }, { group: 'lifecycle' }] }).fetchAsync() : [];
|
|
const userIds = new Set(adjustmentsHistory.map(row => row.userId).filter(Boolean));
|
|
allCards.forEach(card => (card.assignees || []).forEach(id => userIds.add(id)));
|
|
await Promise.all([...userIds].map(async id => {
|
|
usersById[id] = await ReactiveCache.getUser({ _id: id });
|
|
}));
|
|
// A chart EXPORT (PDF / Excel) follows Admin Panel / Anonymize export
|
|
// users like every other export; the chart views keep the names
|
|
// (CrossBoardExportBleed - only the JSON export anonymized).
|
|
if (options.forExport) {
|
|
await require('/models/lib/importExportSecurity').anonymizeExportIdentities({
|
|
boardId, users: Object.values(usersById).filter(Boolean), cards: allCards });
|
|
}
|
|
const nameOf = id => (usersById[id] && (usersById[id].profile?.fullname || usersById[id].username)) || id;
|
|
|
|
if (chartKey === 'time') {
|
|
// #812 ("reporting total hours by resource and task type"): who logged
|
|
// how many hours, and which cards those hours went to. Matches the
|
|
// Time view's own "archived: false" summary above it.
|
|
const activeCards = allCards.filter(card => !card.archived);
|
|
const { timeAdjustments, withRemovedCards } = require('/models/lib/timeHistory');
|
|
const titles = new Map(withRemovedCards(allCards, adjustmentsHistory, boardId).map(card => [card._id, card.title]));
|
|
return {
|
|
adjustments: timeAdjustments(adjustmentsHistory, nameOf, id => titles.get(id) || id),
|
|
byAssignee: computeTimeByGroup(activeCards, card =>
|
|
(card.assignees || []).map(id => ({ key: id, label: nameOf(id) })), NO_ASSIGNEE_GROUP),
|
|
byCard: computeTimeByCard(activeCards),
|
|
// #1121 ("show the SUM of remaining time until due date"): the same
|
|
// activeCards set as above (archived cards excluded); a card also
|
|
// needs an unset completion date and a dueAt to count - see
|
|
// computeRemainingTimeSum's own comment for why an overdue card
|
|
// still counts, as a negative amount.
|
|
remaining: computeRemainingTimeSum(activeCards),
|
|
};
|
|
}
|
|
|
|
if (chartKey === 'groupByAssignee') {
|
|
// #4688 ("grouping cards by assignee"): the same "which cards count
|
|
// under which assignee" fold the Dashboard/Time views already use,
|
|
// but returning the cards themselves rather than a count/hours total,
|
|
// for a team-meeting-friendly read-only overview. Archived cards are
|
|
// excluded, matching the Time view's own "archived: false" summary.
|
|
const activeCards = allCards.filter(card => !card.archived);
|
|
return {
|
|
groups: computeCardsByAssigneeGroup(activeCards, card =>
|
|
(card.assignees || []).map(id => ({ key: id, label: nameOf(id) })), NO_ASSIGNEE_GROUP),
|
|
};
|
|
}
|
|
|
|
const labelById = Object.fromEntries((board.labels || []).map(l => [l._id, l.name || l.color]));
|
|
return {
|
|
byAssignee: computeDashboardGroups(allCards, card =>
|
|
(card.assignees || []).map(id => ({ key: id, label: nameOf(id) })), NO_ASSIGNEE_GROUP),
|
|
byLabel: computeDashboardGroups(allCards, card =>
|
|
(card.labelIds || []).map(id => ({ key: id, label: labelById[id] || id })), NO_LABEL_GROUP),
|
|
byList: computeDashboardGroups(allCards, card =>
|
|
[{ key: card.listId, label: (lists.find(l => l._id === card.listId) || {}).title || card.listId }]),
|
|
};
|
|
}
|
|
|
|
return null;
|
|
}
|