mirror of
https://github.com/wekan/wekan.git
synced 2026-11-05 01:20:07 -05:00
Fix BackgroundBleed: a board's background can only be one of its own attachments.
GET /api/attachment/download-background/:boardId and the api.board.downloadBackground method returned whatever attachment board.backgroundImageId named, checking only board membership, and a board admin could set that field to any attachment id. Anyone could create a board, point its background at an attachment on someone else's private board, and download the file. Both download paths now serve only a live attachment of the same board, and a Boards deny rule refuses setting backgroundImageId to anything else. Refusals record BackgroundBleed (the UI only offers the board's own backgrounds). The negative test fails on any server code that streams a backgroundImageId without the check; a browser test covers both the update and a planted pointer. Thanks to xet7 !
This commit is contained in:
1 parent
c5eb9fc060
commit
146d58df27
7 files changed
+159
-37
No files matched your search
@@ -1,41 +1,17 @@
|
||||
'use strict';
|
||||
// BackgroundBleed (2026-10-02): a board's background is one of that board's
|
||||
// own attachments. Both background download APIs (the api.board.downloadBackground
|
||||
// method and GET /api/attachment/download-background/:boardId) answered with
|
||||
// whatever attachment board.backgroundImageId named, checking only that the
|
||||
// caller is a member of the board - and a board admin could set that field to
|
||||
// ANY attachment id, so anyone could create a board, point its background at
|
||||
// an attachment on someone else's private board, and download it.
|
||||
// Pure: tests/backgroundBleed.test.cjs.
|
||||
const { isLiveAttachment } = require('./attachmentSoftDelete');
|
||||
|
||||
// #4978 "Favorite-Switch of board. The board-background does not change."
|
||||
//
|
||||
// Switching directly from one board to another via the favorites bar reuses the
|
||||
// same boardBody template instance (currentBoard goes A -> B, never null), so
|
||||
// boardBody.onRendered never fires again and the one-shot Utils.setBackgroundImage()
|
||||
// call never re-ran — the previous board's inline `background:url(...)` on
|
||||
// `.board-wrapper` stuck. Switching via the "all boards" list worked only
|
||||
// because it destroys and re-creates boardBody.
|
||||
//
|
||||
// The fix has two parts: (1) call setBackgroundImage() inside a reactive autorun
|
||||
// so it recomputes when the current board changes; (2) this pure helper decides
|
||||
// what to apply, INCLUDING a 'none' result that clears a stale inline background.
|
||||
// The old code only ever SET a background and never reset it, so an
|
||||
// image -> color or image -> plain switch kept showing the old image.
|
||||
//
|
||||
// Board *color* is applied separately and reactively through the `.board-wrapper`
|
||||
// colorClass in boardBody.jade, so this helper does not need to set colors; for
|
||||
// a color board it just reports 'color' and the caller clears the stale inline
|
||||
// image background so the color class shows through.
|
||||
|
||||
// Returns the background descriptor for `board`:
|
||||
// { type: 'image', url } - board has a background image (apply inline url)
|
||||
// { type: 'color' } - board has a color but no image (clear inline bg;
|
||||
// colorClass shows the color)
|
||||
// { type: 'none' } - board has neither: clear any stale inline bg
|
||||
function computeBoardBackground(board) {
|
||||
if (!board) return { type: 'none' };
|
||||
const url = board.backgroundImageURL;
|
||||
if (typeof url === 'string' && url.length > 0) {
|
||||
return { type: 'image', url };
|
||||
}
|
||||
// `background-color` is the legacy custom-color field; `color` drives colorClass.
|
||||
if (board['background-color'] || board.color) {
|
||||
return { type: 'color' };
|
||||
}
|
||||
return { type: 'none' };
|
||||
function isOwnBoardBackground(board, attachment) {
|
||||
return !!board && !!attachment && !!attachment.meta && typeof board._id === 'string' &&
|
||||
attachment.meta.boardId === board._id && isLiveAttachment(attachment);
|
||||
}
|
||||
|
||||
export { computeBoardBackground };
|
||||
module.exports = { isOwnBoardBackground };
|
||||
@@ -35,6 +35,9 @@ const CATALOG = {
|
||||
'authz.repoint': { category: 'authz', bleed: 'RepointBleed', severity: 'high', cwe: 'CWE-863' },
|
||||
// PrototypeBleed: a '__proto__' (or '.', '$') map key sent to the per-user
|
||||
// layout methods; see models/lib/safeMapKey.js. No client sends one.
|
||||
// BackgroundBleed: a board background pointed at another board's attachment
|
||||
// (models/lib/boardBackground.js). The UI only offers the board's own.
|
||||
'authz.background': { category: 'authz', bleed: 'BackgroundBleed', severity: 'high', cwe: 'CWE-639' },
|
||||
'injection.prototype': { category: 'injection', bleed: 'PrototypeBleed', severity: 'high', cwe: 'CWE-1321' },
|
||||
// SyncBleed (GHSA-5q84-p3vr-f3xv): a List Sync server address on a private,
|
||||
// loopback or link-local network, refused when saved and on every fetch.
|
||||
|
||||
@@ -461,6 +461,16 @@ Meteor.methods({
|
||||
if (!attachment) {
|
||||
throw new Meteor.Error('attachment-not-found', 'Background attachment not found');
|
||||
}
|
||||
// BackgroundBleed: only this board's own attachment is its background.
|
||||
if (!require('/models/lib/boardBackground').isOwnBoardBackground(board, attachment)) {
|
||||
try {
|
||||
require('/server/lib/securityLog').record({
|
||||
key: 'authz.background', action: 'blocked', source: 'api.board.downloadBackground', userId: this.userId,
|
||||
detail: 'A board background pointed at an attachment of another board.',
|
||||
});
|
||||
} catch (e) { /* logging must never break the guard */ }
|
||||
throw new Meteor.Error('attachment-not-found', 'Background attachment not found');
|
||||
}
|
||||
|
||||
try {
|
||||
const { apiDownloadBlocked, effectiveApiDownloadMaxBytes } = await getApiTransferLimits();
|
||||
|
||||
@@ -25,6 +25,30 @@ Boards.deny({
|
||||
},
|
||||
});
|
||||
|
||||
// BackgroundBleed: backgroundImageId may name only an attachment of this same
|
||||
// board. The UI sets it from the board's own background picker, so pointing it
|
||||
// anywhere else is an attempt to read another board's file through the
|
||||
// background download API.
|
||||
Boards.deny({
|
||||
async update(userId, doc, fields, modifier) {
|
||||
const renamed = modifier.$rename && Object.values(modifier.$rename).includes('backgroundImageId');
|
||||
const id = modifier.$set && modifier.$set.backgroundImageId;
|
||||
if (!renamed && !id) return false;
|
||||
if (!renamed) {
|
||||
const Attachments = require('/models/attachments').default;
|
||||
const attachment = await Attachments.findOneAsync({ _id: id });
|
||||
if (require('/models/lib/boardBackground').isOwnBoardBackground(doc, attachment)) return false;
|
||||
}
|
||||
try {
|
||||
require('/server/lib/securityLog').record({
|
||||
key: 'authz.background', action: 'blocked', source: 'ddp:boards.update', userId,
|
||||
detail: 'Tried to set a board background to an attachment of another board.',
|
||||
});
|
||||
} catch (e) { /* logging must never break the guard */ }
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
Boards.allow({
|
||||
async insert(userId, doc) {
|
||||
// Check if user is logged in
|
||||
|
||||
@@ -508,6 +508,16 @@ WebApp.handlers.use('/api/attachment/upload', async (req, res, next) => {
|
||||
if (!attachment) {
|
||||
return sendErrorResponse(res, 404, 'Background attachment not found');
|
||||
}
|
||||
// BackgroundBleed: only this board's own attachment is its background.
|
||||
if (!require('/models/lib/boardBackground').isOwnBoardBackground(board, attachment)) {
|
||||
try {
|
||||
require('/server/lib/securityLog').record({
|
||||
key: 'authz.background', action: 'blocked', source: 'rest:download-background', userId: userId,
|
||||
detail: 'A board background pointed at an attachment of another board.',
|
||||
});
|
||||
} catch (e) { /* logging must never break the guard */ }
|
||||
return sendErrorResponse(res, 404, 'Background attachment not found');
|
||||
}
|
||||
const strategy = fileStoreStrategyFactory.getFileStrategy(attachment, 'original');
|
||||
const readStream = strategy.getReadStream();
|
||||
if (!readStream) {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
'use strict';
|
||||
|
||||
// Guard: BackgroundBleed (2026-10-02). The background download APIs returned
|
||||
// whatever attachment board.backgroundImageId named, checking only board
|
||||
// membership, and a board admin could set that field to any attachment id - so
|
||||
// anyone could create a board, point its background at an attachment on a
|
||||
// private board, and download it.
|
||||
// Run: node tests/backgroundBleed.test.cjs
|
||||
|
||||
const { test } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
const read = file => fs.readFileSync(path.join(ROOT, file), 'utf8');
|
||||
const { isOwnBoardBackground } = require('../models/lib/boardBackground');
|
||||
|
||||
test('the reported attack: another board\'s attachment is not this board\'s background', () => {
|
||||
const mine = { _id: 'mine' };
|
||||
assert.equal(isOwnBoardBackground(mine, { _id: 'a', meta: { boardId: 'victim', cardId: 'c' } }), false);
|
||||
assert.equal(isOwnBoardBackground(mine, { _id: 'a', meta: { boardId: 'mine', source: 'board-background' }, deletedAt: new Date() }), false, 'a deleted one');
|
||||
assert.equal(isOwnBoardBackground(mine, { _id: 'a' }), false);
|
||||
assert.equal(isOwnBoardBackground(mine, null), false);
|
||||
// The board's own background still is (negative).
|
||||
assert.equal(isOwnBoardBackground(mine, { _id: 'a', meta: { boardId: 'mine', source: 'board-background' } }), true);
|
||||
});
|
||||
|
||||
test('both download paths check it, and setting the field checks it, recording an attempt', () => {
|
||||
for (const [file, source] of [['server/routes/attachmentApi.js', 'rest:download-background'], ['server/attachmentApi.js', 'api.board.downloadBackground']]) {
|
||||
const src = read(file);
|
||||
const at = src.indexOf('board.backgroundImageId');
|
||||
const guard = src.indexOf("isOwnBoardBackground(board, attachment)", at);
|
||||
assert.ok(guard > at && guard < src.indexOf('getFileStrategy(attachment', at), `${file} checks before reading the file`);
|
||||
assert.ok(src.includes(`key: 'authz.background', action: 'blocked', source: '${source}'`), file);
|
||||
}
|
||||
const perms = read('server/permissions/boards.js');
|
||||
assert.match(perms, /Boards\.deny\(\{\s*async update\(userId, doc, fields, modifier\) \{\s*const renamed = modifier\.\$rename && Object\.values\(modifier\.\$rename\)\.includes\('backgroundImageId'\);/);
|
||||
assert.match(perms, /isOwnBoardBackground\(doc, attachment\)\) return false;/);
|
||||
assert.match(read('models/lib/securityCategories.js'), /'authz\.background':\s*\{[^}]*bleed: 'BackgroundBleed'/);
|
||||
});
|
||||
|
||||
test('negative: nothing in the server serves a backgroundImageId without checking it', () => {
|
||||
const walk = dir => fs.readdirSync(path.join(ROOT, dir), { withFileTypes: true }).flatMap(e => {
|
||||
if (e.name === 'tests' || e.name.startsWith('_build') || e.name === 'node_modules') return [];
|
||||
const rel = `${dir}/${e.name}`;
|
||||
return e.isDirectory() ? walk(rel) : (rel.endsWith('.js') ? [rel] : []);
|
||||
});
|
||||
const offenders = ['server', 'models'].flatMap(walk).filter(file => {
|
||||
const src = read(file);
|
||||
return /=\s*board\.backgroundImageId/.test(src) && /getReadStream\(/.test(src) && !/isOwnBoardBackground\(/.test(src);
|
||||
});
|
||||
assert.deepEqual(offenders, []);
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
'use strict';
|
||||
// BackgroundBleed (2026-10-02): a board admin could point their board's
|
||||
// background at another board's attachment and download it through the
|
||||
// background API.
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { test, expect } = require('../fixtures');
|
||||
const db = require('../helpers/db');
|
||||
const { loginWithToken, openBoard } = require('../helpers/auth');
|
||||
|
||||
const dir = path.join(process.env.WEKAN_FILES_PATH || path.join(__dirname, '..', '..', '..', '.build', 'bundle', 'files'), 'attachments');
|
||||
|
||||
test('another board\'s attachment cannot become, or be read as, a background', async ({ page, request, user, user2, board }) => {
|
||||
const victim = db.seedBoard({ ownerId: user2.id, cardTitlesPerList: [['Secret']] });
|
||||
const id = `bgbleed${Date.now()}`;
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const stored = path.join(dir, id);
|
||||
fs.writeFileSync(stored, 'private victim file');
|
||||
db.insertOne('attachments', { _id: id, name: 'secret.txt', size: 19, type: 'text/plain',
|
||||
meta: { boardId: victim.boardId, cardId: db.find('cards', { boardId: victim.boardId })[0]._id },
|
||||
versions: { original: { path: stored, name: 'secret.txt', size: 19, type: 'text/plain', extension: 'txt', storage: 'fs' } } });
|
||||
try {
|
||||
await loginWithToken(page, user.id, user.token);
|
||||
await openBoard(page, board.boardId, board.slug);
|
||||
const set = await page.evaluate(async ({ boardId, id }) => {
|
||||
try { await Meteor.callAsync('/boards/update', { _id: boardId }, { $set: { backgroundImageId: id } }); return 'set'; }
|
||||
catch (error) { return 'denied'; }
|
||||
}, { boardId: board.boardId, id });
|
||||
expect(set).toBe('denied');
|
||||
expect(db.getBoard(board.boardId).backgroundImageId || '').not.toBe(id);
|
||||
// A pointer that was planted before the fix is not served either.
|
||||
db.updateOne('boards', { _id: board.boardId }, { $set: { backgroundImageId: id } });
|
||||
const res = await request.get(`/api/attachment/download-background/${board.boardId}`, {
|
||||
headers: { Authorization: `Bearer ${user.token}` },
|
||||
});
|
||||
expect(res.status()).toBe(404);
|
||||
expect(await res.text()).not.toContain('private victim file');
|
||||
await expect.poll(() => db.findOne('eventlog', { bleed: 'BackgroundBleed' })?.count).toBeGreaterThanOrEqual(1);
|
||||
} finally {
|
||||
db.updateOne('boards', { _id: board.boardId }, { $set: { backgroundImageId: '' } });
|
||||
db.deleteOne('attachments', { _id: id });
|
||||
fs.rmSync(stored, { force: true });
|
||||
db.cleanup({ boardIds: [victim.boardId] });
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user