8000 Fix GroupLinkBleed: board admins can no longer attach foreign teams o… · wekan/wekan@e345800 · GitHub
[go: up one dir, main page]

Skip to content

Commit e345800

Browse files
committed
Fix GroupLinkBleed: board admins can no longer attach foreign teams or add members by direct board writes.
GHSA-v544-rmr5-38gx. Boards.allow lets a board admin update any field of their board, and two checks existed only inside Meteor methods: a board admin may add only the organizations and teams they belong to (#5141, setBoardOrgs/setBoardTeams), and inviteUserToBoard's checks (who may invite, disabled accounts, the #6116 same organization/team policy). A client calling /boards/update or /boards/insert directly skipped both. New Boards.deny rules refuse any client insert or update naming orgs, teams or domains - the UI changes them only through the board methods, which write server-side - and any client update that adds a member or switches a removed member back on (models/lib/boardWriteGuards.js). Role changes and removals, which the client writes itself, still pass. A refusal is recorded in Admin Panel / Problems as GroupLinkBleed (authz.group-link, authz.member-add, medium). tests/groupLinkBleed.test.cjs runs the real deny rules in a vm against the reported writes and every legitimate client write, and fails if a method-guarded board field is client-writable; it fails without the fix. tests/playwright/specs/group-link-bleed.e2e.js replays the attack over DDP. Thanks to bhaswanthc and xet7 !
1 parent 6ae4ad3 commit e345800

6 files changed

Lines changed: 373 additions & 1 deletion

File tree

‎models/lib/boardWriteGuards.js‎

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
'use strict';
2+
3+
// GroupLinkBleed (GHSA-v544-rmr5-38gx): which client writes to a board skip a
4+
// check that only a Meteor method makes. Used by the Boards.deny rules in
5+
// server/permissions/boards.js. Pure: tests/groupLinkBleed.test.cjs.
6+
7+
// orgs, teams and domains are written only by setBoardOrgs, setBoardTeams and
8+
// setBoardDomains (#5141 own groups only, #5850 domain validation).
9+
const GROUP_LINK_FIELDS = ['orgs', 'teams', 'domains'];
10+
11+
function isGroupLinkField(field) {
12+
if (typeof field !== 'string') return false;
13+
return GROUP_LINK_FIELDS.some(f => field === f || field.startsWith(`${f}.`));
14+
}
15+
16+
// A client board insert carrying any group link. The UI creates a board with
17+
// none and adds them through the methods afterwards.
18+
function groupLinksInInsert(doc) {
19+
return GROUP_LINK_FIELDS.some(f => doc && Array.isArray(doc[f]) && doc[f].length > 0);
20+
}
21+
22+
// Members may be changed from the client (role changes, removal), never added:
23+
// adding goes through inviteUserToBoard. Allowed shapes:
24+
// $set members.<i>.<field> for an existing index, not changing userId
25+
// $set members.<i> an entry keeping the same userId
26+
// $set members an array whose userIds were all on the board
27+
// $unset, $pull, $pullAll, $pop (remove only)
28+
// Everything else that touches members - $push, $addToSet, $setOnInsert, an
29+
// index past the end, a positional or non-numeric index, a new userId - adds.
30+
// So does switching a removed (inactive) member back on: removal keeps the
31+
// entry with isActive false, and bringing it back is an invitation too.
32+
const MEMBER_SAFE_OPS = ['$set', '$unset', '$pull', '$pullAll', '$pop'];
33+
const isMembersPath = p => p === 'members' || p.startsWith('members.');
34+
35+
function addsBoardMember(doc, modifier) {
36+
const current = (doc && Array.isArray(doc.members)) ? doc.members : [];
37+
const ids = new Set(current.map(m => m && m.userId).filter(Boolean));
38+
const inactive = new Set(current.filter(m => m && m.userId && m.isActive !== true).map(m => m.userId));
39+
const reactivates = m => m && m.isActive === true && inactive.has(m.userId);
40+
for (const [op, args] of Object.entries(modifier || {})) {
41+
if (!args || typeof args !== 'object') continue;
42+
if (op === '$rename') {
43+
if (Object.keys(args).some(isMembersPath) || Object.values(args).some(v => typeof v === 'string' && isMembersPath(v))) return true;
44+
continue;
45+
}
46+
const paths = Object.keys(args).filter(isMembersPath);
47+
if (!paths.length) continue;
48+
if (!MEMBER_SAFE_OPS.includes(op)) return true;
49+
if (op !== '$set') continue;
50+
for (const path of paths) {
51+
const value = args[path];
52+
const parts = path.split('.');
53+
if (parts.length === 1) {
54+
if (!Array.isArray(value) || value.some(m => !m || !ids.has(m.userId) || reactivates(m))) return true;
55+
continue;
56+
}
57+
if (!/^\d+$/.test(parts[1])) return true;
58+
const i = Number(parts[1]);
59+
if (i >= current.length) return true;
60+
const existing = current[i] && current[i].userId;
61+
if (parts.length === 2) {
62+
if (!value || typeof value !== 'object' || value.userId !== existing || reactivates(value)) return true;
63+
continue;
64+
}
65+
if (parts[2] === 'userId') return true;
66+
if (parts.length === 3 && parts[2] === 'isActive' && value === true && inactive.has(existing)) return true;
67+
}
68+
}
69+
return false;
70+
}
71+
72+
module.exports = { GROUP_LINK_FIELDS, isGroupLinkField, groupLinksInInsert, addsBoardMember };

‎models/lib/securityCategories.js‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,12 @@ const CATALOG = {
9696
'ssrf.webhook-forge': { category: 'ssrf', bleed: 'HookBleed', severity: 'high', cwe: 'CWE-345' },
9797
'authz.board-owner': { category: 'authz', bleed: 'OwnerBleed', severity: 'high', cwe: 'CWE-639' },
9898
'authz.background': { category: 'authz', bleed: 'BackgroundBleed', severity: 'high', cwe: 'CWE-639' },
99+
// GroupLinkBleed (GHSA-v544-rmr5-38gx): a client board write naming orgs,
100+
// teams or domains, which the UI changes only through the board methods.
101+
'authz.group-link': { category: 'authz', bleed: 'GroupLinkBleed', severity: 'medium', cwe: 'CWE-863' },
102+
// GroupLinkBleed, member variant: a client board update adding a member
103+
// instead of going through inviteUserToBoard.
104+
'authz.member-add': { category: 'authz', bleed: 'GroupLinkBleed', severity: 'medium', cwe: 'CWE-863' },
99105
'injection.prototype': { category: 'injection', bleed: 'PrototypeBleed', severity: 'high', cwe: 'CWE-1321' },
100106
'authz.notification-tray': { category: 'authz', bleed: 'TrayBleed', severity: 'high', cwe: 'CWE-639' },
101107
'authn.cas-state': { category: 'authn', bleed: 'CasTokenBleed', severity: 'high', cwe: 'CWE-352' },

‎server/permissions/boards.js‎

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,52 @@ Boards.allow({
108108
fetch: ['members'],
109109
});
110110

111+
// GroupLinkBleed (GHSA-v544-rmr5-38gx): orgs, teams and domains are written
112+
// only by the methods setBoardOrgs, setBoardTeams and setBoardDomains, which
113+
// let a board admin add only the organizations and teams they belong to
114+
// (#5141) and validate domains (#5850). The allow rule above admits any field
115+
// for a board admin, so a client collection write would skip those checks. The
116+
// UI never writes these fields directly; the methods write server-side, which
117+
// allow/deny does not see.
118+
const { isGroupLinkField, groupLinksInInsert, addsBoardMember } = require('/models/lib/boardWriteGuards');
119+
function recordBoardWriteDenial(key, userId, source, detail) {
120+
try {
121+
require('/server/lib/securityLog').record({ key, action: 'blocked', source, userId, detail });
122+
} catch (e) { /* logging must never break the guard */ }
123+
}
124+
Boards.deny({
125+
insert(userId, doc) {
126+
if (!groupLinksInInsert(doc)) return false;
127+
recordBoardWriteDenial('authz.group-link', userId, 'ddp:boards.insert',
128+
'A client board insert named organizations, teams or domains.');
129+
return true;
130+
},
131+
update(userId, doc, fieldNames, modifier) {
132+
const fields = [...(fieldNames || [])];
133+
if (modifier && modifier.$rename) fields.push(...Object.values(modifier.$rename));
134+
8 if (!fields.some(isGroupLinkField)) return false;
135+
recordBoardWriteDenial('authz.group-link', userId, 'ddp:boards.update',
136+
'A client board update named organizations, teams or domains outside the board methods.');
137+
return true;
138+
},
139+
fetch: [],
140+
});
141+
142+
// GroupLinkBleed, member variant: a board admin adding a user straight into
143+
// `members` skips inviteUserToBoard (who may invite, disabled accounts, the
144+
// #6116 same organization/team policy). Client code only changes a member's
145+
// role or removes one (Boards.helpers removeMember, setMemberPermission), so
146+
// any client write that ADDS a member is refused.
147+
Boards.deny({
148+
update(userId, doc, fieldNames, modifier) {
149+
if (!(fieldNames || []).includes('members') || !addsBoardMember(doc, modifier)) return false;
150+
recordBoardWriteDenial('authz.member-add', userId, 'ddp:boards.update',
151+
'A client board update added a member outside inviteUserToBoard.');
152+
return true;
153+
},
154+
fetch: ['members'],
155+
});
156+
111157
// The number of users that have starred this board is managed by trusted code
112158
// and the user is not allowed to update it
113159
Boards.deny({

‎tests/groupLinkBleed.test.cjs‎

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
1+
'use strict';
2+
3+
// Guard: GroupLinkBleed (GHSA-v544-rmr5-38gx). Boards.allow lets a board admin
4+
// update any field of their board, and two checks lived only inside Meteor
5+
// methods: "a board admin may add only the organizations and teams they belong
6+
// to" (#5141, setBoardOrgs/setBoardTeams) and inviteUserToBoard's checks (who
7+
// may invite, disabled accounts, the #6116 same organization/team policy). A
8+
// client calling Boards.update / Boards.insert directly - /boards/update over
9+
// DDP - skipped both: it could attach any team or organization to its board,
10+
// so the whole group saw it and became members, and push any user id into
11+
// members.
12+
//
13+
// This runs the REAL deny rules of server/permissions/boards.js in a vm, with
14+
// stubbed imports, against the reporter's writes and against every write the
15+
// cli 10BC8 ent legitimately makes. A write is refused when any deny rule says so,
16+
// exactly as Meteor's allow/deny decides.
17+
// Run: node tests/groupLinkBleed.test.cjs
18+
19+
const { test } = require('node:test');
20+
const assert = require('node:assert/strict');
21+
const fs = require('node:fs');
22+
const path = require('node:path');
23+
const vm = require('node:vm');
24+
25+
const ROOT = path.join(__dirname, '..');
26+
const read = file => fs.readFileSync(path.join(ROOT, file), 'utf8');
27+
const guards = require('../models/lib/boardWriteGuards');
28+
29+
function loadRules() {
30+
const denies = [];
31+
const recorded = [];
32+
const Boards = { deny: rules => denies.push(rules), allow: () => {} };
33+
const resolve = spec => {
34+
if (spec === '/server/lib/securityLog') return { record: evt => recorded.push(evt) };
35+
if (spec === '/models/attachments') return { default: { findOneAsync: async () => null } };
36+
return require(path.join(ROOT, spec));
37+
};
38+
const src = read('server/permissions/boards.js')
39+
.replace(/^import Boards from .*$/m, '')
40+
.replace(/^import TableVisibilityModeSettings from .*$/m,
41+
'const TableVisibilityModeSettings = { findOneAsync: async () => null };')
42+
.replace(/^import \{ findWhere, where \} from .*$/m,
43+
'const where = (a, q) => (a || []).filter(x => Object.keys(q).every(k => x[k] === q[k]));'
44+
+ 'const findWhere = (a, q) => where(a, q)[0];')
45+
.replace(/^import \{[^}]*\} from '\/server\/lib\/utils';$/m,
46+
'const allowIsBoardAdminOrSiteAdmin = () => true; const canUpdateBoardSort = () => false;'
47+
+ 'const canUpdateBoardSameWidthValue = () => false;')
48+
.replace(/^import \{[^}]*\} from '\/server\/lib\/subtaskDepositAccess';$/m,
49+
'const canWriteSubtaskDeposit = async () => true; const recordSubtaskDepositDenial = () => {};');
50+
assert.ok(!/^import /m.test(src), 'every import of server/permissions/boards.js is stubbed');
51+
vm.runInNewContext(src, { Boards, require: resolve, console });
52+
return { denies, recorded };
53+
}
54+
55+
const ADMIN = 'admin';
56+
const board = () => ({
57+
_id: 'b1',
58+
members: [
59+
{ userId: ADMIN, isAdmin: true, isActive: true },
60+
{ userId: 'bob', isAdmin: false, isActive: true },
61+
{ userId: 'gone', isAdmin: false, isActive: false },
62+
],
63+
teams: [{ teamId: 'mine', teamDisplayName: 'Mine', isActive: true }],
64+
});
65+
const fieldsOf = modifier => [...new Set(Object.values(modifier).flatMap(a => Object.keys(a).map(k => k.split('.')[0])))];
66+
67+
async function refusedUpdate(modifier, doc = board()) {
68+
const { denies, recorded } = loadRules();
69+
for (const rules of denies) {
70+
if (rules.update && await rules.update(ADMIN, doc, fieldsOf(modifier), modifier)) return { refused: true, recorded };
71+
}
72+
return { refused: false, recorded };
73+
}
74+
async function refusedInsert(doc) {
75+
const { denies, recorded } = loadRules();
76+
for (const rules of denies) {
77+
if (rules.insert && await rules.insert(ADMIN, doc)) return { refused: true, recorded };
78+
}
79+
return { refused: false, recorded };
80+
}
81+
82+
const newMember = userId => ({ userId, isAdmin: false, isActive: true, isNoComments: false, isCommentOnly: false,
83+
isWorker: false, isNormalAssignedOnly: false, isCommentAssignedOnly: false, isReadOnly: false, isReadAssignedOnly: false });
84+
85+
test('the reported attack: a foreign team, organization or domain written straight to the board is refused and recorded', async () => {
86+
for (const modifier of [
87+
{ $set: { teams: [{ teamId: 'foreign', teamDisplayName: 'x', isActive: true }] } },
88+
{ $push: { teams: { teamId: 'foreign', teamDisplayName: 'x', isActive: true } } },
89+
{ $addToSet: { orgs: { orgId: 'foreign', orgDisplayName: 'x', isActive: true } } },
90+
{ $set: { 'teams.0.teamId': 'foreign' } },
91+
{ $set: { domains: [{ domain: 'example.com', isActive: true }] } },
92+
{ $set: { title: 'harmless', orgs: [{ orgId: 'foreign', orgDisplayName: 'x', isActive: true }] } },
93+
]) {
94+
const { refused, recorded } = await refusedUpdate(modifier);
95+
assert.equal(refused, true, JSON.stringify(modifier));
96+
assert.equal(recorded[0].key, 'authz.group-link', JSON.stringify(modifier));
97+
assert.equal(recorded[0].action, 'blocked');
98+
}
99+
for (const field of ['teams', 'orgs', 'domains']) {
100+
const { refused, recorded } = await refusedInsert({ title: 'x', [field]: [{ isActive: true }] });
101+
assert.equal(refused, true, field);
102+
assert.equal(recorded[0].source, 'ddp:boards.insert');
103+
}
104+
});
105+
106+
test('the member variant: adding or bringing back a member from the client is refused and recorded', async () => {
107+
for (const modifier of [
108+
{ $push: { members: newMember('stranger') } },
109+
{ $addToSet: { members: newMember('stranger') } },
110+
{ $set: { members: [...board().members, newMember('stranger')] } },
111+
{ $set: { 'members.3': newMember('stranger') } },
112+
{ $set: { 'members.1': newMember('stranger') } },
113+
{ $set: { 'members.1.userId': 'stranger' } },
114+
{ $set: { 'members.$.isActive': true } },
115+
{ $set: { 'members.2.isActive': true } },
116+
{ $set: { 'members.2': { ...board().members[2], isActive: true } } },
117+
{ $set: { members: board().members.map(m => ({ ...m, isActive: true })) } },
118+
]) {
119+
const { refused, recorded } = await refusedUpdate(modifier);
120+
assert.equal(refused, true, JSON.stringify(modifier));
121+
assert.equal(recorded.at(-1).key, 'authz.member-add', JSON.stringify(modifier));
122+
}
123+
});
124+
125+
test('negative: what the client legitimately writes still passes and is not logged', async () => {
126+
for (const modifier of [
127+
// setMemberPermission (models/boards.js), dotted $set + $unset.
128+
{ $set: { 'members.1.isAdmin': true, 'members.1.isCommentOnly': false }, $unset: { 'members.1.customRoleId': '' } },
129+
// removeMember: deactivate, keep the last admin, orphan cleanup.
130+
{ $set: { 'members.1.isActive': false, 'members.1.isAdmin': false }, $unset: { 'members.1.grantedBy': '' } },
131+
{ $set: { 'members.0.isActive': true } },
132+
{ $set: { members: board().members.filter(m => m.userId !== 'bob') } },
133+
{ $pull: { members: { userId: 'bob' } } },
134+
// Ordinary board settings.
135+
{ $set: { title: 'Renamed', color: 'belize' } },
136+
{ $set: { allowsSubtasks: true } },
137+
]) {
138+
const { refused, recorded } = await refusedUpdate(modifier);
139+
assert.equal(refused, false, JSON.stringify(modifier));
140+
assert.deepEqual(recorded, [], JSON.stringify(modifier));
141+
}
142+
// The UI creates a board without groups or other members.
143+
const { refused } = await refusedInsert({ title: 'New', members: [newMember(ADMIN)], teams: [], orgs: [] });
144+
assert.equal(refused, false);
145+
});
146+
147+
test('the pure helpers', () => {
148+
assert.equal(guards.isGroupLinkField('teams'), true);
149+
assert.equal(guards.isGroupLinkField('orgs.0.orgId'), true);
150+
assert.equal(guards.isGroupLinkField('domainsX'), false);
151+
assert.equal(guards.isGroupLinkField('title'), false);
152+
assert.equal(guards.addsBoardMember(board(), { $rename: { 'members': 'x' } }), true);
153+
assert.equal(guards.addsBoardMember(board(), { $set: { 'members.1.isWorker': true } }), false);
154+
assert.equal(guards.addsBoardMember({}, { $set: { 'members.0.isActive': false } }), true, 'no members: no index exists');
155+
});
156+
157+
test('the methods keep their checks, the catalog has the keys', () => {
158+
const methods = read('server/models/boards.js');
159+
for (const name of ['setBoardOrgs', 'setBoardTeams']) {
160+
const start = methods.indexOf(`async ${name}(`);
161+
const guard = methods.indexOf('forbiddenNewGroupIds(', start);
162+
const write = methods.indexOf('Boards.updateAsync(', start);
163+
assert.ok(start > 0 && guard > start && guard < write, `${name} checks before writing`);
164+
}
165+
const catalog = read('models/lib/securityCategories.js');
166+
assert.match(catalog, /'authz\.group-link':\s*\{[^}]*bleed: 'GroupLinkBleed'[^}]*cwe: 'CWE-863'/);
167+
assert.match(catalog, /'authz\.member-add':\s*\{[^}]*bleed: 'GroupLinkBleed'[^}]*cwe: 'CWE-863'/);
168+
});
169+
170+
// The shape of the fault: a field whose only check sits inside a Meteor method
171+
// is still writable by a client through the collection, unless a deny rule
172+
// names it. Every board field a method guards with an ownership/membership
173+
// check must be named by a Boards.deny rule - or, for members, by the add
174+
// guard.
175+
test('negative: no board field guarded only by a method is left client-writable', () => {
176+
const methods = read('server/models/boards.js');
177+
const perms = read('server/permissions/boards.js') + read('models/boards.js');
178+
const guarded = new Set();
179+
for (const m of methods.matchAll(/forbiddenNewGroupIds\(\{\s*previous: board\.(\w+)/g)) guarded.add(m[1]);
180+
assert.deepEqual([...guarded].sort(), ['orgs', 'teams']);
181+
for (const field of [...guarded, 'domains']) {
182+
assert.ok(guards.GROUP_LINK_FIELDS.includes(field), `${field} is denied to client writes`);
183+
}
184+
assert.match(perms, /addsBoardMember\(doc, modifier\)/);
185+
// No client code writes these fields directly: the UI goes through the methods.
186+
const walk = dir => fs.readdirSync(path.join(ROOT, dir), { withFileTypes: true }).flatMap(e => {
187+
if (e.name === 'tests' || e.name === 'node_modules' || e.name.startsWith('_build')) return [];
188+
const rel = `${dir}/${e.name}`;
189+
return e.isDirectory() ? walk(rel) : (rel.endsWith('.js') ? [rel] : []);
190+
});
191+
const offenders = walk('client').filter(file =>
192+
/Boards\.update(Async)?\([^;]*\b(teams|orgs|domains)\s*:/.test(read(file))
193+
|| /Boards\.update(Async)?\([^;]*\$(push|addToSet)\s*:\s*\{\s*members\b/.test(read(file)));
194+
assert.deepEqual(offenders, []);
195+
});

0 commit comments

Comments
 (0)
0