mirror of
https://github.com/wekan/wekan.git
synced 2025-12-16 07:20:12 +01:00
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:
parent
4aaeec9515
commit
0a1a075f31
6 changed files with 505 additions and 42 deletions
16
SECURITY.md
16
SECURITY.md
|
|
@ -192,6 +192,22 @@ Meteor.startup(() => {
|
|||
- Attempts to update forbidden fields from the client are denied.
|
||||
- Admin operations like managing org/team membership or toggling flags must use server methods that check permissions.
|
||||
|
||||
## Voting: integrity and authorization
|
||||
|
||||
- Client updates to card `vote` fields are blocked to prevent forged votes and inconsistent policy enforcement.
|
||||
- Voting is performed via a server method that enforces:
|
||||
- Authentication and board membership, or an explicit per-card flag allowing non-members to vote.
|
||||
- Only the caller's own userId is added/removed from `vote.positive`/`vote.negative`.
|
||||
- This prevents members from fabricating other users' votes and ensures non-members cannot vote unless explicitly allowed.
|
||||
|
||||
## Planning Poker: integrity and authorization
|
||||
|
||||
- Client updates to card `poker` fields are blocked. All poker actions go through server methods that enforce:
|
||||
- Authentication and board membership for configuration and results.
|
||||
- For casting a poker vote, either board membership or an explicit per-card flag allowing non-members to participate.
|
||||
- Only the caller's own userId is added/removed from the selected estimation bucket (e.g., one, two, five, etc.).
|
||||
- Methods cover setting/unsetting poker question/end, casting votes, replaying, and setting final estimation.
|
||||
|
||||
## Brute force login protection
|
||||
|
||||
- https://github.com/wekan/wekan/commit/23e5e1e3bd081699ce39ce5887db7e612616014d
|
||||
|
|
|
|||
|
|
@ -430,56 +430,57 @@ BlazeComponent.extendComponent({
|
|||
) {
|
||||
newState = forIt;
|
||||
}
|
||||
this.data().setVote(Meteor.userId(), newState);
|
||||
// Use secure server method; direct client updates to vote are blocked
|
||||
Meteor.call('cards.vote', this.data()._id, newState);
|
||||
},
|
||||
'click .js-poker'(e) {
|
||||
let newState = null;
|
||||
if ($(e.target).hasClass('js-poker-vote-one')) {
|
||||
newState = 'one';
|
||||
this.data().setPoker(Meteor.userId(), newState);
|
||||
Meteor.call('cards.pokerVote', this.data()._id, newState);
|
||||
}
|
||||
if ($(e.target).hasClass('js-poker-vote-two')) {
|
||||
newState = 'two';
|
||||
this.data().setPoker(Meteor.userId(), newState);
|
||||
Meteor.call('cards.pokerVote', this.data()._id, newState);
|
||||
}
|
||||
if ($(e.target).hasClass('js-poker-vote-three')) {
|
||||
newState = 'three';
|
||||
this.data().setPoker(Meteor.userId(), newState);
|
||||
Meteor.call('cards.pokerVote', this.data()._id, newState);
|
||||
}
|
||||
if ($(e.target).hasClass('js-poker-vote-five')) {
|
||||
newState = 'five';
|
||||
this.data().setPoker(Meteor.userId(), newState);
|
||||
Meteor.call('cards.pokerVote', this.data()._id, newState);
|
||||
}
|
||||
if ($(e.target).hasClass('js-poker-vote-eight')) {
|
||||
newState = 'eight';
|
||||
this.data().setPoker(Meteor.userId(), newState);
|
||||
Meteor.call('cards.pokerVote', this.data()._id, newState);
|
||||
}
|
||||
if ($(e.target).hasClass('js-poker-vote-thirteen')) {
|
||||
newState = 'thirteen';
|
||||
this.data().setPoker(Meteor.userId(), newState);
|
||||
Meteor.call('cards.pokerVote', this.data()._id, newState);
|
||||
}
|
||||
if ($(e.target).hasClass('js-poker-vote-twenty')) {
|
||||
newState = 'twenty';
|
||||
this.data().setPoker(Meteor.userId(), newState);
|
||||
Meteor.call('cards.pokerVote', this.data()._id, newState);
|
||||
}
|
||||
if ($(e.target).hasClass('js-poker-vote-forty')) {
|
||||
newState = 'forty';
|
||||
this.data().setPoker(Meteor.userId(), newState);
|
||||
Meteor.call('cards.pokerVote', this.data()._id, newState);
|
||||
}
|
||||
if ($(e.target).hasClass('js-poker-vote-one-hundred')) {
|
||||
newState = 'oneHundred';
|
||||
this.data().setPoker(Meteor.userId(), newState);
|
||||
Meteor.call('cards.pokerVote', this.data()._id, newState);
|
||||
}
|
||||
if ($(e.target).hasClass('js-poker-vote-unsure')) {
|
||||
newState = 'unsure';
|
||||
this.data().setPoker(Meteor.userId(), newState);
|
||||
Meteor.call('cards.pokerVote', this.data()._id, newState);
|
||||
}
|
||||
},
|
||||
'click .js-poker-finish'(e) {
|
||||
if ($(e.target).hasClass('js-poker-finish')) {
|
||||
e.preventDefault();
|
||||
const now = formatDateTime(new Date());
|
||||
this.data().setPokerEnd(now);
|
||||
const now = new Date();
|
||||
Meteor.call('cards.setPokerEnd', this.data()._id, now);
|
||||
}
|
||||
},
|
||||
|
||||
|
|
@ -487,9 +488,9 @@ BlazeComponent.extendComponent({
|
|||
if ($(e.target).hasClass('js-poker-replay')) {
|
||||
e.preventDefault();
|
||||
this.currentCard = this.currentData();
|
||||
this.currentCard.replayPoker();
|
||||
this.data().unsetPokerEnd();
|
||||
this.data().unsetPokerEstimation();
|
||||
Meteor.call('cards.replayPoker', this.currentCard._id);
|
||||
Meteor.call('cards.unsetPokerEnd', this.currentCard._id);
|
||||
Meteor.call('cards.unsetPokerEstimation', this.currentCard._id);
|
||||
}
|
||||
},
|
||||
'click .js-poker-estimation'(event) {
|
||||
|
|
@ -500,9 +501,9 @@ BlazeComponent.extendComponent({
|
|||
this.find('#pokerEstimation').value = '';
|
||||
|
||||
if (ruleTitle) {
|
||||
this.data().setPokerEstimation(parseInt(ruleTitle, 10));
|
||||
Meteor.call('cards.setPokerEstimation', this.data()._id, parseInt(ruleTitle, 10));
|
||||
} else {
|
||||
this.data().setPokerEstimation('');
|
||||
Meteor.call('cards.unsetPokerEstimation', this.data()._id);
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
@ -1105,20 +1106,15 @@ BlazeComponent.extendComponent({
|
|||
'is-checked',
|
||||
);
|
||||
const endString = this.currentCard.getVoteEnd();
|
||||
|
||||
this.currentCard.setVoteQuestion(
|
||||
voteQuestion,
|
||||
publicVote,
|
||||
allowNonBoardMembers,
|
||||
);
|
||||
Meteor.call('cards.setVoteQuestion', this.currentCard._id, voteQuestion, publicVote, allowNonBoardMembers);
|
||||
if (endString) {
|
||||
this.currentCard.setVoteEnd(endString);
|
||||
Meteor.call('cards.setVoteEnd', this.currentCard._id, endString);
|
||||
}
|
||||
Popup.back();
|
||||
},
|
||||
'click .js-remove-vote': Popup.afterConfirm('deleteVote', () => {
|
||||
event.preventDefault();
|
||||
this.currentCard.unsetVote();
|
||||
Meteor.call('cards.unsetVote', this.currentCard._id);
|
||||
Popup.back();
|
||||
}),
|
||||
'click a.js-toggle-vote-public'(event) {
|
||||
|
|
@ -1317,10 +1313,10 @@ BlazeComponent.extendComponent({
|
|||
];
|
||||
}
|
||||
_storeDate(newDate) {
|
||||
this.card.setVoteEnd(newDate);
|
||||
Meteor.call('cards.setVoteEnd', this.card._id, newDate);
|
||||
}
|
||||
_deleteDate() {
|
||||
this.card.unsetVoteEnd();
|
||||
Meteor.call('cards.unsetVoteEnd', this.card._id);
|
||||
}
|
||||
}.register('editVoteEndDatePopup'));
|
||||
|
||||
|
|
@ -1342,17 +1338,14 @@ BlazeComponent.extendComponent({
|
|||
);
|
||||
const endString = this.currentCard.getPokerEnd();
|
||||
|
||||
this.currentCard.setPokerQuestion(
|
||||
pokerQuestion,
|
||||
allowNonBoardMembers,
|
||||
);
|
||||
Meteor.call('cards.setPokerQuestion', this.currentCard._id, pokerQuestion, allowNonBoardMembers);
|
||||
if (endString) {
|
||||
this.currentCard.setPokerEnd(endString);
|
||||
Meteor.call('cards.setPokerEnd', this.currentCard._id, new Date(endString));
|
||||
}
|
||||
Popup.back();
|
||||
},
|
||||
'click .js-remove-poker': Popup.afterConfirm('deletePoker', (event) => {
|
||||
this.currentCard.unsetPoker();
|
||||
Meteor.call('cards.unsetPoker', this.currentCard._id);
|
||||
Popup.back();
|
||||
}),
|
||||
'click a.js-toggle-poker-allow-non-members'(event) {
|
||||
|
|
@ -1573,10 +1566,10 @@ BlazeComponent.extendComponent({
|
|||
];
|
||||
}
|
||||
_storeDate(newDate) {
|
||||
this.card.setPokerEnd(newDate);
|
||||
Meteor.call('cards.setPokerEnd', this.card._id, newDate);
|
||||
}
|
||||
_deleteDate() {
|
||||
this.card.unsetPokerEnd();
|
||||
Meteor.call('cards.unsetPokerEnd', this.card._id);
|
||||
}
|
||||
}.register('editPokerEndDatePopup'));
|
||||
|
||||
|
|
|
|||
290
models/cards.js
290
models/cards.js
|
|
@ -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
|
||||
|
|
|
|||
118
server/lib/tests/cards.methods.tests.js
Normal file
118
server/lib/tests/cards.methods.tests.js
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
/* eslint-env mocha */
|
||||
import { expect } from 'chai';
|
||||
import sinon from 'sinon';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
import '/models/cards';
|
||||
|
||||
// Helpers to access method handlers
|
||||
const voteHandler = () => Meteor.server.method_handlers['cards.vote'];
|
||||
const pokerVoteHandler = () => Meteor.server.method_handlers['cards.pokerVote'];
|
||||
|
||||
// Preserve originals to restore after stubbing
|
||||
const origGetCard = ReactiveCache.getCard;
|
||||
const origGetBoard = ReactiveCache.getBoard;
|
||||
|
||||
describe('cards methods security', function() {
|
||||
let updateStub;
|
||||
|
||||
beforeEach(function() {
|
||||
// Stub collection update to capture modifiers
|
||||
updateStub = sinon.stub(Cards, 'update').returns(1);
|
||||
});
|
||||
|
||||
afterEach(function() {
|
||||
if (updateStub) updateStub.restore();
|
||||
ReactiveCache.getCard = origGetCard;
|
||||
ReactiveCache.getBoard = origGetBoard;
|
||||
});
|
||||
|
||||
describe('cards.vote', function() {
|
||||
it('denies non-member when allowNonBoardMembers=false', function() {
|
||||
const cardId = 'card1';
|
||||
const callerId = 'user-nonmember';
|
||||
const board = { hasMember: id => id === 'someone-else' };
|
||||
const card = { _id: cardId, boardId: 'board1', vote: { allowNonBoardMembers: false } };
|
||||
|
||||
ReactiveCache.getCard = () => card;
|
||||
ReactiveCache.getBoard = () => board;
|
||||
|
||||
const callMethod = () => voteHandler().call({ userId: callerId }, cardId, true);
|
||||
expect(callMethod).to.throw();
|
||||
expect(updateStub.called).to.equal(false);
|
||||
});
|
||||
|
||||
it('allows non-member only for own userId when allowNonBoardMembers=true', function() {
|
||||
const cardId = 'card2';
|
||||
const callerId = 'user-guest';
|
||||
const board = { hasMember: id => id === 'someone-else' };
|
||||
const card = { _id: cardId, boardId: 'board2', vote: { allowNonBoardMembers: true } };
|
||||
|
||||
ReactiveCache.getCard = () => card;
|
||||
ReactiveCache.getBoard = () => board;
|
||||
|
||||
voteHandler().call({ userId: callerId }, cardId, true);
|
||||
|
||||
expect(updateStub.calledOnce).to.equal(true);
|
||||
const [, modifier] = updateStub.getCall(0).args;
|
||||
expect(modifier.$addToSet['vote.positive']).to.equal(callerId);
|
||||
expect(modifier.$pull['vote.negative']).to.equal(callerId);
|
||||
expect(modifier.$set.modifiedAt).to.be.instanceOf(Date);
|
||||
expect(modifier.$set.dateLastActivity).to.be.instanceOf(Date);
|
||||
});
|
||||
|
||||
it('ensures member votes only affect caller userId', function() {
|
||||
const cardId = 'card3';
|
||||
const callerId = 'member1';
|
||||
const otherId = 'member2';
|
||||
const board = { hasMember: id => (id === callerId || id === otherId) };
|
||||
const card = { _id: cardId, boardId: 'board3', vote: { allowNonBoardMembers: false } };
|
||||
|
||||
ReactiveCache.getCard = () => card;
|
||||
ReactiveCache.getBoard = () => board;
|
||||
|
||||
voteHandler().call({ userId: callerId }, cardId, true);
|
||||
|
||||
expect(updateStub.calledOnce).to.equal(true);
|
||||
const [, modifier] = updateStub.getCall(0).args;
|
||||
// Only callerId present in modifier
|
||||
expect(modifier.$addToSet['vote.positive']).to.equal(callerId);
|
||||
expect(modifier.$pull['vote.negative']).to.equal(callerId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cards.pokerVote', function() {
|
||||
it('denies non-member when allowNonBoardMembers=false', function() {
|
||||
const cardId = 'card4';
|
||||
const callerId = 'nm';
|
||||
const board = { hasMember: id => id === 'someone-else' };
|
||||
const card = { _id: cardId, boardId: 'board4', poker: { allowNonBoardMembers: false } };
|
||||
|
||||
ReactiveCache.getCard = () => card;
|
||||
ReactiveCache.getBoard = () => board;
|
||||
|
||||
const callMethod = () => pokerVoteHandler().call({ userId: callerId }, cardId, 'five');
|
||||
expect(callMethod).to.throw();
|
||||
expect(updateStub.called).to.equal(false);
|
||||
});
|
||||
|
||||
it('allows non-member only for own userId when allowNonBoardMembers=true', function() {
|
||||
const cardId = 'card5';
|
||||
const callerId = 'guest';
|
||||
const board = { hasMember: id => id === 'someone-else' };
|
||||
const card = { _id: cardId, boardId: 'board5', poker: { allowNonBoardMembers: true } };
|
||||
|
||||
ReactiveCache.getCard = () => card;
|
||||
ReactiveCache.getBoard = () => board;
|
||||
|
||||
pokerVoteHandler().call({ userId: callerId }, cardId, 'eight');
|
||||
|
||||
expect(updateStub.calledOnce).to.equal(true);
|
||||
const [, modifier] = updateStub.getCall(0).args;
|
||||
expect(modifier.$addToSet['poker.eight']).to.equal(callerId);
|
||||
// Ensure removal from other buckets includes callerId
|
||||
expect(modifier.$pull['poker.one']).to.equal(callerId);
|
||||
expect(modifier.$set.modifiedAt).to.be.instanceOf(Date);
|
||||
expect(modifier.$set.dateLastActivity).to.be.instanceOf(Date);
|
||||
});
|
||||
});
|
||||
});
|
||||
56
server/lib/tests/cards.security.tests.js
Normal file
56
server/lib/tests/cards.security.tests.js
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
/* eslint-env mocha */
|
||||
import { expect } from 'chai';
|
||||
import '../utils';
|
||||
import '/models/cards';
|
||||
|
||||
// Unit tests for canUpdateCard policy (deny direct vote updates)
|
||||
describe('cards security', function() {
|
||||
describe(canUpdateCard.name, function() {
|
||||
const userId = 'user1';
|
||||
const board = {
|
||||
hasMember: (id) => id === userId,
|
||||
};
|
||||
const doc = { boardId: 'board1' };
|
||||
|
||||
// Patch ReactiveCache.getBoard for this unit test scope if not defined
|
||||
const origGetBoard = ReactiveCache && ReactiveCache.getBoard;
|
||||
before(function() {
|
||||
if (typeof ReactiveCache === 'object') {
|
||||
ReactiveCache.getBoard = () => board;
|
||||
}
|
||||
});
|
||||
after(function() {
|
||||
if (typeof ReactiveCache === 'object') {
|
||||
ReactiveCache.getBoard = origGetBoard;
|
||||
}
|
||||
});
|
||||
|
||||
it('denies anonymous users', function() {
|
||||
expect(canUpdateCard(null, doc, ['title'])).to.equal(false);
|
||||
});
|
||||
|
||||
it('denies direct vote updates', function() {
|
||||
expect(canUpdateCard(userId, doc, ['vote'])).to.equal(false);
|
||||
expect(canUpdateCard(userId, doc, ['vote', 'modifiedAt', 'dateLastActivity'])).to.equal(false);
|
||||
expect(canUpdateCard(userId, doc, ['vote.positive'])).to.equal(false);
|
||||
expect(canUpdateCard(userId, doc, ['vote.negative'])).to.equal(false);
|
||||
});
|
||||
|
||||
it('denies direct poker updates', function() {
|
||||
expect(canUpdateCard(userId, doc, ['poker'])).to.equal(false);
|
||||
expect(canUpdateCard(userId, doc, ['poker.one'])).to.equal(false);
|
||||
expect(canUpdateCard(userId, doc, ['poker.allowNonBoardMembers'])).to.equal(false);
|
||||
expect(canUpdateCard(userId, doc, ['poker.end'])).to.equal(false);
|
||||
});
|
||||
|
||||
it('allows member updates when not touching vote', function() {
|
||||
expect(canUpdateCard(userId, doc, ['title'])).to.equal(true);
|
||||
expect(canUpdateCard(userId, doc, ['description', 'modifiedAt'])).to.equal(true);
|
||||
});
|
||||
|
||||
it('denies non-members even when not touching vote', function() {
|
||||
const nonMemberId = 'user2';
|
||||
expect(canUpdateCard(nonMemberId, doc, ['title'])).to.equal(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,3 +1,5 @@
|
|||
import './utils.tests';
|
||||
import './users.security.tests';
|
||||
import './boards.security.tests';
|
||||
import './cards.security.tests';
|
||||
import './cards.methods.tests';
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue