Fix SECURITY ISSUE 4: Members can forge others’ votes (Low). Bonus: Similar fixes to planning poker too done by xet7.

Thanks to Siam Thanat Hack (STH) and xet7 !
This commit is contained in:
Lauri Ojansivu 2025-11-02 11:12:41 +02:00
parent 4aaeec9515
commit 0a1a075f31
6 changed files with 505 additions and 42 deletions

View file

@ -515,18 +515,29 @@ Cards.attachSchema(
}),
);
// Centralized update policy for Cards
// Security: deny any direct client updates to 'vote' fields; require membership otherwise
canUpdateCard = function(userId, doc, fields) {
if (!userId) return false;
const fieldNames = fields || [];
// Block direct updates to voting fields; voting must go through Meteor method 'cards.vote'
if (_.some(fieldNames, f => typeof f === 'string' && (f === 'vote' || f.indexOf('vote.') === 0))) {
return false;
}
// Block direct updates to poker fields; poker must go through Meteor methods
if (_.some(fieldNames, f => typeof f === 'string' && (f === 'poker' || f.indexOf('poker.') === 0))) {
return false;
}
return allowIsBoardMember(userId, ReactiveCache.getBoard(doc.boardId));
};
Cards.allow({
insert(userId, doc) {
return allowIsBoardMember(userId, ReactiveCache.getBoard(doc.boardId));
},
update(userId, doc, fields) {
// Allow board members or logged in users if only vote get's changed
return (
allowIsBoardMember(userId, ReactiveCache.getBoard(doc.boardId)) ||
(_.isEqual(fields, ['vote', 'modifiedAt', 'dateLastActivity']) &&
!!userId)
);
return canUpdateCard(userId, doc, fields);
},
remove(userId, doc) {
return allowIsBoardMember(userId, ReactiveCache.getBoard(doc.boardId));
@ -3105,6 +3116,273 @@ const addCronJob = _.debounce(
if (Meteor.isServer) {
Meteor.methods({
// Secure poker voting: only the caller's userId is modified
'cards.pokerVote'(cardId, state) {
check(cardId, String);
if (state !== undefined && state !== null) check(state, String);
if (!this.userId) throw new Meteor.Error('not-authorized');
const card = ReactiveCache.getCard(cardId) || Cards.findOne(cardId);
if (!card) throw new Meteor.Error('not-found');
const board = ReactiveCache.getBoard(card.boardId) || Boards.findOne(card.boardId);
if (!board) throw new Meteor.Error('not-found');
const isMember = allowIsBoardMember(this.userId, board);
const allowNBM = !!(card.poker && card.poker.allowNonBoardMembers);
if (!(isMember || allowNBM /* && board.permission === 'public' */)) {
throw new Meteor.Error('not-authorized');
}
let mod = card.setPoker(this.userId, state);
if (!mod || typeof mod !== 'object') mod = {};
mod.$set = Object.assign({}, mod.$set, { modifiedAt: new Date(), dateLastActivity: new Date() });
return Cards.update({ _id: cardId }, mod);
},
// Configure planning poker on a card (members only)
'cards.setPokerQuestion'(cardId, question, allowNonBoardMembers) {
check(cardId, String);
check(question, Boolean);
check(allowNonBoardMembers, Boolean);
if (!this.userId) throw new Meteor.Error('not-authorized');
const card = ReactiveCache.getCard(cardId) || Cards.findOne(cardId);
if (!card) throw new Meteor.Error('not-found');
const board = ReactiveCache.getBoard(card.boardId) || Boards.findOne(card.boardId);
if (!allowIsBoardMember(this.userId, board)) throw new Meteor.Error('not-authorized');
const modifier = {
$set: {
poker: {
question,
allowNonBoardMembers,
one: [], two: [], three: [], five: [], eight: [], thirteen: [], twenty: [], forty: [], oneHundred: [], unsure: [],
},
modifiedAt: new Date(),
dateLastActivity: new Date(),
},
};
return Cards.update({ _id: cardId }, modifier);
},
'cards.setPokerEnd'(cardId, end) {
check(cardId, String);
check(end, Date);
if (!this.userId) throw new Meteor.Error('not-authorized');
const card = ReactiveCache.getCard(cardId) || Cards.findOne(cardId);
if (!card) throw new Meteor.Error('not-found');
const board = ReactiveCache.getBoard(card.boardId) || Boards.findOne(card.boardId);
if (!allowIsBoardMember(this.userId, board)) throw new Meteor.Error('not-authorized');
const modifier = {
$set: { 'poker.end': end, modifiedAt: new Date(), dateLastActivity: new Date() },
};
return Cards.update({ _id: cardId }, modifier);
},
'cards.unsetPokerEnd'(cardId) {
check(cardId, String);
if (!this.userId) throw new Meteor.Error('not-authorized');
const card = ReactiveCache.getCard(cardId) || Cards.findOne(cardId);
if (!card) throw new Meteor.Error('not-found');
const board = ReactiveCache.getBoard(card.boardId) || Boards.findOne(card.boardId);
if (!allowIsBoardMember(this.userId, board)) throw new Meteor.Error('not-authorized');
const modifier = {
$unset: { 'poker.end': '' },
$set: { modifiedAt: new Date(), dateLastActivity: new Date() },
};
return Cards.update({ _id: cardId }, modifier);
},
'cards.unsetPoker'(cardId) {
check(cardId, String);
if (!this.userId) throw new Meteor.Error('not-authorized');
const card = ReactiveCache.getCard(cardId) || Cards.findOne(cardId);
if (!card) throw new Meteor.Error('not-found');
const board = ReactiveCache.getBoard(card.boardId) || Boards.findOne(card.boardId);
if (!allowIsBoardMember(this.userId, board)) throw new Meteor.Error('not-authorized');
const modifier = {
$unset: { poker: '' },
$set: { modifiedAt: new Date(), dateLastActivity: new Date() },
};
return Cards.update({ _id: cardId }, modifier);
},
'cards.setPokerEstimation'(cardId, estimation) {
check(cardId, String);
check(estimation, Number);
if (!this.userId) throw new Meteor.Error('not-authorized');
const card = ReactiveCache.getCard(cardId) || Cards.findOne(cardId);
if (!card) throw new Meteor.Error('not-found');
const board = ReactiveCache.getBoard(card.boardId) || Boards.findOne(card.boardId);
if (!allowIsBoardMember(this.userId, board)) throw new Meteor.Error('not-authorized');
const modifier = {
$set: { 'poker.estimation': estimation, modifiedAt: new Date(), dateLastActivity: new Date() },
};
return Cards.update({ _id: cardId }, modifier);
},
'cards.unsetPokerEstimation'(cardId) {
check(cardId, String);
if (!this.userId) throw new Meteor.Error('not-authorized');
const card = ReactiveCache.getCard(cardId) || Cards.findOne(cardId);
if (!card) throw new Meteor.Error('not-found');
const board = ReactiveCache.getBoard(card.boardId) || Boards.findOne(card.boardId);
if (!allowIsBoardMember(this.userId, board)) throw new Meteor.Error('not-authorized');
const modifier = {
$unset: { 'poker.estimation': '' },
$set: { modifiedAt: new Date(), dateLastActivity: new Date() },
};
return Cards.update({ _id: cardId }, modifier);
},
'cards.replayPoker'(cardId) {
check(cardId, String);
if (!this.userId) throw new Meteor.Error('not-authorized');
const card = ReactiveCache.getCard(cardId) || Cards.findOne(cardId);
if (!card) throw new Meteor.Error('not-found');
const board = ReactiveCache.getBoard(card.boardId) || Boards.findOne(card.boardId);
if (!allowIsBoardMember(this.userId, board)) throw new Meteor.Error('not-authorized');
// Reset all poker votes arrays
const modifier = {
$set: {
'poker.one': [], 'poker.two': [], 'poker.three': [], 'poker.five': [], 'poker.eight': [], 'poker.thirteen': [], 'poker.twenty': [], 'poker.forty': [], 'poker.oneHundred': [], 'poker.unsure': [],
modifiedAt: new Date(),
dateLastActivity: new Date(),
},
$unset: { 'poker.end': '' },
};
return Cards.update({ _id: cardId }, modifier);
},
// Configure voting on a card (members only)
'cards.setVoteQuestion'(cardId, question, publicVote, allowNonBoardMembers) {
check(cardId, String);
check(question, String);
check(publicVote, Boolean);
check(allowNonBoardMembers, Boolean);
if (!this.userId) throw new Meteor.Error('not-authorized');
const card = ReactiveCache.getCard(cardId) || Cards.findOne(cardId);
if (!card) throw new Meteor.Error('not-found');
const board = ReactiveCache.getBoard(card.boardId) || Boards.findOne(card.boardId);
if (!allowIsBoardMember(this.userId, board)) throw new Meteor.Error('not-authorized');
const modifier = {
$set: {
vote: {
question,
public: publicVote,
allowNonBoardMembers,
positive: [],
negative: [],
},
modifiedAt: new Date(),
dateLastActivity: new Date(),
},
};
return Cards.update({ _id: cardId }, modifier);
},
'cards.setVoteEnd'(cardId, end) {
check(cardId, String);
check(end, Date);
if (!this.userId) throw new Meteor.Error('not-authorized');
const card = ReactiveCache.getCard(cardId) || Cards.findOne(cardId);
if (!card) throw new Meteor.Error('not-found');
const board = ReactiveCache.getBoard(card.boardId) || Boards.findOne(card.boardId);
if (!allowIsBoardMember(this.userId, board)) throw new Meteor.Error('not-authorized');
const modifier = {
$set: { 'vote.end': end, modifiedAt: new Date(), dateLastActivity: new Date() },
};
return Cards.update({ _id: cardId }, modifier);
},
'cards.unsetVoteEnd'(cardId) {
check(cardId, String);
if (!this.userId) throw new Meteor.Error('not-authorized');
const card = ReactiveCache.getCard(cardId) || Cards.findOne(cardId);
if (!card) throw new Meteor.Error('not-found');
const board = ReactiveCache.getBoard(card.boardId) || Boards.findOne(card.boardId);
if (!allowIsBoardMember(this.userId, board)) throw new Meteor.Error('not-authorized');
const modifier = {
$unset: { 'vote.end': '' },
$set: { modifiedAt: new Date(), dateLastActivity: new Date() },
};
return Cards.update({ _id: cardId }, modifier);
},
'cards.unsetVote'(cardId) {
check(cardId, String);
if (!this.userId) throw new Meteor.Error('not-authorized');
const card = ReactiveCache.getCard(cardId) || Cards.findOne(cardId);
if (!card) throw new Meteor.Error('not-found');
const board = ReactiveCache.getBoard(card.boardId) || Boards.findOne(card.boardId);
if (!allowIsBoardMember(this.userId, board)) throw new Meteor.Error('not-authorized');
const modifier = {
$unset: { vote: '' },
$set: { modifiedAt: new Date(), dateLastActivity: new Date() },
};
return Cards.update({ _id: cardId }, modifier);
},
// Secure voting: only the caller can set/unset their vote; non-members can vote only when allowed
'cards.vote'(cardId, forIt) {
check(cardId, String);
// forIt may be true (upvote), false (downvote), or null/undefined (clear)
if (forIt !== undefined && forIt !== null) check(forIt, Boolean);
if (!this.userId) throw new Meteor.Error('not-authorized');
const card = ReactiveCache.getCard(cardId) || Cards.findOne(cardId);
if (!card) throw new Meteor.Error('not-found');
const board = ReactiveCache.getBoard(card.boardId) || Boards.findOne(card.boardId);
if (!board) throw new Meteor.Error('not-found');
const isMember = allowIsBoardMember(this.userId, board);
const allowNBM = !!(card.vote && card.vote.allowNonBoardMembers);
if (!(isMember || allowNBM /* && board.permission === 'public' */)) {
throw new Meteor.Error('not-authorized');
}
// Only modify the caller's own userId in vote arrays
let modifier;
if (forIt === true) {
modifier = {
$pull: { 'vote.negative': this.userId },
$addToSet: { 'vote.positive': this.userId },
$set: { modifiedAt: new Date(), dateLastActivity: new Date() },
};
} else if (forIt === false) {
modifier = {
$pull: { 'vote.positive': this.userId },
$addToSet: { 'vote.negative': this.userId },
$set: { modifiedAt: new Date(), dateLastActivity: new Date() },
};
} else {
// Clear vote
modifier = {
$pull: { 'vote.positive': this.userId, 'vote.negative': this.userId },
$set: { modifiedAt: new Date(), dateLastActivity: new Date() },
};
}
return Cards.update({ _id: cardId }, modifier);
},
/** copies a card
* <li> this method is needed on the server because attachments can only be copied on the server (access to file system)
* @param card id to copy