Files
wekan/server/lib/tests/fileStoreStrategy.security.tests.js
T
Lauri OjansivuandClaude Opus 4.8 b9f9390d04 Server-side Mocha suite: fixed a startup crash and the 44 latent failures it had been hiding
- imports/i18n/i18n.test.js crashed the whole run at load: chai 6.x plugins (sinon-chai,
  chai-as-promised) are ESM-only, so use(require('sinon-chai')) handed chai.use() a module
  namespace instead of the plugin function ("fn is not a function"). Now imports the default
  export.
- That unmasked a second load crash - PositionHistory.helpers is not a function: the
  meteor test entry (server/lib/tests/index.js) never ran the .helpers / .attachSchema shim
  that server/main.js bootstraps, so the first model to call Collection.helpers({...}) threw.
  The shim is now imported first in the test entry.
- With the suite finally running, 44 server tests failed because meteor test only loads what
  the specs import (not the app's /server/imports). The specs now import the files that
  register the methods / globals under test (cards.vote / cards.pokerVote, api.attachment.*,
  cloneBoard, getBackgroundImageURL, applyListWidth, updateListSort, moveChecklist,
  userPositionHistory.*, archiveBoard, sendSMTPTestEmail, and the Attachments global). Also
  fixed genuine test bugs: the cards.vote / cards.pokerVote specs were written synchronously
  against async methods; the header-login trust specs restored env vars with
  process.env.X = undefined (which stores the string "undefined" and shadowed the trusted-IP
  allowlist, making every trusted source read as untrusted); the DnsBleed decimal-loopback
  matcher assumed the integer host survived URL normalisation; and the dependencies-OpenAPI
  spec could not locate its source file from the built bundle. The two cards.archive /
  cards.move specs tested Meteor methods that do not exist (archive / move are Minimongo
  document helpers secured by Cards.allow / Cards.deny, already covered by the cards security
  tests) and were removed. Server-side Mocha is now 409 passing, 0 failing.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-05 16:48:58 +03:00

115 lines
3.4 KiB
JavaScript

/* eslint-env mocha */
import fs from 'fs';
import os from 'os';
import path from 'path';
import { expect } from 'chai';
import { Random } from 'meteor/random';
import { FileStoreStrategyFilesystem } from '/models/lib/fileStoreStrategy';
import Attachments from '/models/attachments';
// FileStoreStrategyFilesystem falls back to a bare global `Attachments` when no
// collection is passed (models/lib/fileStoreStrategy.js). The app exposes model
// collections as globals; the meteor-test module scope does not, so define it
// here to avoid a ReferenceError when constructing the strategy.
globalThis.Attachments = globalThis.Attachments || Attachments;
function readStreamToString(stream) {
return new Promise((resolve, reject) => {
const chunks = [];
stream.on('data', (chunk) => chunks.push(chunk));
stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
stream.on('error', reject);
});
}
describe('fileStoreStrategy security', function() {
let tempRoot;
let storageRoot;
let originalWritablePath;
beforeEach(function() {
originalWritablePath = process.env.WRITABLE_PATH;
tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'wekan-fss-'));
storageRoot = path.join(tempRoot, 'files', 'attachments');
fs.mkdirSync(storageRoot, { recursive: true });
process.env.WRITABLE_PATH = tempRoot;
});
afterEach(function() {
if (typeof originalWritablePath === 'string') {
process.env.WRITABLE_PATH = originalWritablePath;
} else {
delete process.env.WRITABLE_PATH;
}
try {
fs.rmSync(tempRoot, { recursive: true, force: true });
} catch (err) {
// Ignore cleanup errors in tests.
}
});
function createAttachmentFileObj(filePath) {
return {
_id: Random.id(),
name: 'proof.txt',
collectionName: 'attachments',
versions: {
original: {
path: filePath,
storage: 'fs',
type: 'text/plain',
size: 4,
meta: {},
},
},
meta: {
boardId: Random.id(),
},
};
}
it('denies absolute path outside attachment storage root', function() {
const externalPath = path.join(tempRoot, 'outside-secret.txt');
fs.writeFileSync(externalPath, 'secret');
const strategy = new FileStoreStrategyFilesystem(
createAttachmentFileObj(externalPath),
'original',
);
const stream = strategy.getReadStream();
expect(stream).to.equal(undefined);
});
it('allows reading a regular file from storage root', async function() {
const insidePath = path.join(storageRoot, 'safe.txt');
fs.writeFileSync(insidePath, 'safe-data');
const strategy = new FileStoreStrategyFilesystem(
createAttachmentFileObj(insidePath),
'original',
);
const stream = strategy.getReadStream();
expect(stream).to.not.equal(undefined);
const content = await readStreamToString(stream);
expect(content).to.equal('safe-data');
});
it('denies symlink inside storage root that points outside', function() {
const externalPath = path.join(tempRoot, 'outside-via-link.txt');
fs.writeFileSync(externalPath, 'linked-secret');
const symlinkPath = path.join(storageRoot, 'escape-link.txt');
fs.symlinkSync(externalPath, symlinkPath);
const strategy = new FileStoreStrategyFilesystem(
createAttachmentFileObj(symlinkPath),
'original',
);
const stream = strategy.getReadStream();
expect(stream).to.equal(undefined);
});
});