Close the MutationBleed siblings: shared board display settings need write access.

applyListWidth, setBoardAutoWidth and setStickyListHeaders change board-wide
state that every viewer sees, and refused only non-members, so read-only,
comment-only and worker members could change them. They now require write
access (not recorded as attempts: the menus may still offer them).
applyListWidth also awaits its write, which it had fired and forgotten. The
negative test fails on any server method that writes board content after a
membership-only check.

Thanks to xet7 !
This commit is contained in:
Lauri Ojansivu committed 2026-10-02 11:09:01 +03:00
1 parent 215293868b
commit fd458543f9
2 files changed
+46 -4

No files matched your search

+14 -4
View File
@@ -1,5 +1,6 @@
const { CALENDAR_SYSTEM_IDS } = require('/imports/lib/calendarSystems');
const { assertSafeMapKey } = require('/models/lib/safeMapKey');
const { memberCan } = require('/models/lib/boardRoleCapabilities');
import { Meteor } from 'meteor/meteor';
// Only the authorized server method can populate this creation context.
const adminCreation = new Meteor.EnvironmentVariable();
@@ -1152,7 +1153,10 @@ Meteor.methods({
// list.width/constraint are per-board fields shared with all users, so
// only board members may change them, and only on the list's own board.
const board = await ReactiveCache.getBoard(boardId);
if (!board || !board.hasMember(this.userId)) {
// MutationBleed sibling (2026-10-02): a shared board setting needs write
// access, not just membership. Not recorded as an attempt: the menus may
// still offer it to a read-only member.
if (!board || !memberCan(board.members, this.userId, 'write')) {
throw new Meteor.Error('error-notAuthorized');
}
const list = await ReactiveCache.getList(listId);
@@ -1163,7 +1167,7 @@ Meteor.methods({
// #6409: only the shared per-board width is stored on the list. The old
// `constraint` (max-width) is no longer used; the param is kept for
// backwards compatibility with existing callers but ignored.
Lists.updateAsync(listId, { $set: { width: width } });
await Lists.updateAsync(listId, { $set: { width: width } });
return true;
} catch (error) {
console.error('Error updating list width:', error);
@@ -1180,7 +1184,10 @@ Meteor.methods({
// Shared (per-board) auto-width affects everyone, so only board members may
// change it (parity with applyListWidth). See #6409.
const board = await ReactiveCache.getBoard(boardId);
if (!board || !board.hasMember(this.userId)) {
// MutationBleed sibling (2026-10-02): a shared board setting needs write
// access, not just membership. Not recorded as an attempt: the menus may
// still offer it to a read-only member.
if (!board || !memberCan(board.members, this.userId, 'write')) {
throw new Meteor.Error('error-notAuthorized');
}
await Boards.updateAsync(boardId, { $set: { autoWidth: !!autoWidth } });
@@ -1197,7 +1204,10 @@ Meteor.methods({
throw new Meteor.Error('not-logged-in', 'User must be logged in');
}
const board = await ReactiveCache.getBoard(boardId);
if (!board || !board.hasMember(this.userId)) {
// MutationBleed sibling (2026-10-02): a shared board setting needs write
// access, not just membership. Not recorded as an attempt: the menus may
// still offer it to a read-only member.
if (!board || !memberCan(board.members, this.userId, 'write')) {
throw new Meteor.Error('error-notAuthorized');
}
await Boards.updateAsync(boardId, {
+32
View File
@@ -74,3 +74,35 @@ test('negative inventory: every reported sibling uses the shared write guard', (
}
assert.doesNotMatch(read('server/models/lists.js'), /typeof allowIsBoardMemberWithWriteAccess/);
});
// MutationBleed siblings (2026-10-02): applyListWidth, setBoardAutoWidth and
// setStickyListHeaders change shared board state every viewer sees, and
// checked only membership, so read-only, comment-only and worker members could
// change them. They need write access now; applyListWidth also awaits its
// write so a failure is not lost.
test('shared board display settings need write access, and no method writes a board after a membership-only check', () => {
const users = read('server/models/users.js');
for (const name of ['applyListWidth', 'setBoardAutoWidth', 'setStickyListHeaders']) {
const at = users.indexOf(` async ${name}(`);
const body = users.slice(at, users.indexOf('\n },', at));
assert.match(body, /if \(!board \|\| !memberCan\(board\.members, this\.userId, 'write'\)\) \{/, name);
assert.doesNotMatch(body, /\.hasMember\(/, name);
}
assert.match(users, /await Lists\.updateAsync\(listId, \{ \$set: \{ width: width \} \}\);/);
// Negative, tree-wide: a method that writes board content after refusing
// only non-members is the MutationBleed shape.
const walk = dir => fs.readdirSync(dir, { withFileTypes: true }).flatMap(e => e.isDirectory()
? (e.name === 'tests' || e.name.startsWith('_build') ? [] : walk(path.join(dir, e.name)))
: (e.name.endsWith('.js') ? [path.join(dir, e.name)] : []));
const offenders = [];
for (const file of [...walk(path.join(root, 'server')), ...walk(path.join(root, 'models'))]) {
for (const method of fs.readFileSync(file, 'utf8').split(/\n async (?=\w+\()/).slice(1)) {
const body = method.slice(0, method.indexOf('\n },'));
if (/!\s*\w+\.hasMember\(this\.userId\)/.test(body) &&
/(Boards|Lists|Swimlanes|Cards|Checklists|CustomFields)\.(direct\.)?(updateAsync|insertAsync|removeAsync)\(/.test(body)) {
offenders.push(`${file}: ${method.slice(0, method.indexOf('('))}`);
}
}
}
assert.deepEqual(offenders, []);
});