CHANGELOG: add the "Remove all labels" rule action entry.

This commit is contained in:
Lauri Ojansivu committed 2026-09-10 20:00:44 +03:00
1 parent fa71c8ba4d
commit 61e857d96e
4 files changed
+444

No files matched your search

+22
View File
@@ -1136,6 +1136,28 @@ old localStorage-only toggle, now falling back to the board's own setting.
</details>
**Rules (IFTTT)** - the card actions a rule can run.
<details>
<summary><a href="https://github.com/wekan/wekan/commit/1c97bbac854f0504376cc3be5f0d99627c4f403d">Added a "Remove all labels" rule action</a>. Thanks to basketball00011 and xet7.</summary>
[#3432](https://github.com/wekan/wekan/issues/3432): the existing per-label
"add label X" / "remove label X" card actions
(`models/cards.js` `addLabel`/`removeLabel`, wired in
`server/rulesHelper.js` and the label dropdown in
`client/components/rules/actions/cardActions.jade`/`.js`) only ever
touched one label at a time, so clearing every label from a card needed
one "remove label" action per label in the rule.
`Card.removeAllLabels()` sets `labelIds` to an empty array in a single
update (a card with no labels is a no-op), and a new "Remove all labels"
rule action (`actionType: 'removeAllLabels'`, with no label-selection
sub-field, unlike the existing per-label actions) calls it from a new
dropdown entry next to the existing label actions, mirroring how the
existing "Remove all members" action is wired.
</details>
and fixes the following bugs:
**Board reports** - the Dashboard and the 10 board report chart views.
+45
View File
@@ -100,6 +100,24 @@ function logUploadBlock(key, detail) {
}
const { filenameLooksLikeExploit } = require('./lib/uploadFileName');
const { isDangerousUploadMismatch } = require('./lib/uploadContentMismatch');
// GitHub issue #3274: sniff the file's REAL type from its magic bytes with the
// maintained `file-type` package (already a dependency, used the same way by
// models/lib/fileTypeCorrection.js for extension correction) and compare it
// against the client-declared MIME type. `file-type` is ESM-only, so it is
// loaded with a dynamic import; detection failure must never block a
// legitimate upload, so any error here simply yields no sniffed mime.
async function detectMimeFromMagicBytes(filePath) {
if (!Meteor.isServer) return undefined;
try {
const { fileTypeFromFile } = await import('file-type');
const type = await fileTypeFromFile(String(filePath));
return type && type.mime ? String(type.mime).toLowerCase() : undefined;
} catch (e) {
return undefined;
}
}
export async function isFileValid(fileObj, mimeTypesAllowed, sizeAllowed, externalCommandLine) {
// Reject uploads whose FILENAME itself looks like an exploit: HTML/script
@@ -202,6 +220,33 @@ export async function isFileValid(fileObj, mimeTypesAllowed, sizeAllowed, extern
// Head unreadable — the checks below still apply.
}
// GitHub issue #3274 (CWE-434): compare the client-declared MIME type
// against the REAL type sniffed from the file's magic bytes, and reject a
// dangerous mismatch - e.g. a Windows PE executable or a shell/batch
// script renamed with an image/document extension and Content-Type. Kept
// narrow (see isDangerousUploadMismatch): it never flags compatible
// textual differences (text/plain vs text/csv) to avoid false-positive
// breakage of legitimate uploads.
try {
const magicByteMime = await detectMimeFromMagicBytes(fileObj.path);
const { text: mismatchHead } = await readTextHead(fileObj.path, 4096);
const mismatch = isDangerousUploadMismatch({
declaredMime: fileObj.type,
detectedMime: magicByteMime,
headText: mismatchHead,
});
if (mismatch.blocked) {
console.log(
'Validation of uploaded file failed (declared/content type mismatch): file '
+ fileObj.path + ' - ' + mismatch.reason,
);
logUploadBlock('file.mime', 'rejected spoofed upload type (' + mismatch.reason + ')');
return false;
}
} catch (e) {
// Mismatch detection must never block a legitimate upload on its own error.
}
// GHSA-jhph-whx8-wq6p (CWE-434): when content-based detection via the `file`
// binary is unavailable, `detectedMime` above falls back to the CLIENT-supplied
// fileObj.type, which an attacker can spoof to "image/png" so the dangerous-MIME
+153
View File
@@ -0,0 +1,153 @@
'use strict';
// Pure decision module for GitHub issue #3274: detect a MEANINGFUL mismatch
// between the client-declared MIME type of an uploaded attachment and the
// REAL type sniffed from its content (magic bytes), so a renamed executable
// or script cannot pass itself off as an image/document by spoofing its
// extension and Content-Type.
//
// Deliberately narrow: this only flags a DANGEROUS mismatch - the sniffed
// content is executable/script-like while the declared type claims to be
// something an executable/script is not (image, document, audio, video,
// plain text/csv, archive, ...). It never flags compatible/ambiguous
// textual cases (e.g. declared text/plain, sniffed text/csv) - see
// isCompatibleTextual() below. No server, no I/O: pure functions over
// strings, so this is unit-testable without Meteor or a filesystem.
// MIME types `file-type` (magic-byte sniffing) or `file --mime-type` report
// for binaries that can run code once launched or executed by the OS/shell.
// Kept as a Set (not a prefix check) because false positives here reject a
// legitimate upload.
const EXECUTABLE_DETECTED_MIMES = new Set([
'application/x-msdownload', // Windows PE .exe/.dll/.scr
'application/x-dosexec',
'application/vnd.microsoft.portable-executable',
'application/x-elf', // Linux ELF binary
'application/x-executable',
'application/x-sharedlib',
'application/x-mach-binary', // macOS Mach-O
'application/x-msi', // Windows installer
'application/vnd.ms-cab-compressed',
'application/x-apple-diskimage', // .dmg
'application/java-archive', // executable via `java -jar`
'application/x-java-archive',
'application/vnd.android.package-archive', // .apk
]);
// Declared MIME categories that legitimately describe an image, document,
// audio, video, plain text or archive upload. An executable/script sniffed
// under any of these is the spoof this module exists to catch.
const NON_EXECUTABLE_DECLARED_PREFIXES = [
'image/', 'audio/', 'video/', 'text/', 'font/',
];
const NON_EXECUTABLE_DECLARED_EXACT = new Set([
'application/pdf',
'application/zip',
'application/x-zip-compressed',
'application/gzip',
'application/x-gzip',
'application/x-tar',
'application/x-7z-compressed',
'application/x-rar-compressed',
'application/vnd.rar',
'application/json',
'application/xml',
'application/rtf',
'application/vnd.oasis.opendocument.text',
'application/vnd.oasis.opendocument.spreadsheet',
'application/vnd.oasis.opendocument.presentation',
'application/msword',
'application/vnd.ms-excel',
'application/vnd.ms-powerpoint',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
'application/octet-stream', // generic/unspecified - not itself a spoof claim
]);
function declaredLooksNonExecutable(declaredMime) {
const mime = String(declaredMime || '').toLowerCase().trim();
if (!mime) return false;
if (NON_EXECUTABLE_DECLARED_PREFIXES.some(prefix => mime.startsWith(prefix))) return true;
return NON_EXECUTABLE_DECLARED_EXACT.has(mime);
}
// Two textual/compatible declared vs detected types are never flagged, even
// though they differ, because they carry the same (non-dangerous) risk
// profile - e.g. text/plain vs text/csv, or an unspecified octet-stream.
function isCompatibleTextual(declaredMime, detectedMime) {
const d1 = String(declaredMime || '').toLowerCase().trim();
const d2 = String(detectedMime || '').toLowerCase().trim();
if (!d1 || !d2) return true; // nothing to compare - not this function's job
if (d1.startsWith('text/') && d2.startsWith('text/')) return true;
if (d1 === d2) return true;
return false;
}
// Shebang-style script markers. `file-type` deliberately does not identify
// plain-text script formats (shell/batch/python/perl/...) since they have no
// binary magic bytes, so this is checked separately against a small text
// head read of the upload, mirroring the dangerous-markup sniff already used
// for HTML/SVG/XML in models/fileValidation.js.
const SCRIPT_SHEBANG_RE = /^\s*#!\s*\/(?:usr\/)?(?:bin|local\/bin)\//;
// A Windows batch/PowerShell heuristic: no shebang concept exists there, so a
// couple of unambiguous first-line markers are checked instead.
const WINDOWS_SCRIPT_HEAD_RE = /^\s*@echo\s+off\b|^\s*#requires\s+-version/i;
function looksLikeScriptHead(headText) {
if (!headText) return false;
const firstLine = String(headText).slice(0, 4096).split(/\r?\n/, 1)[0] || '';
return SCRIPT_SHEBANG_RE.test(firstLine) || WINDOWS_SCRIPT_HEAD_RE.test(firstLine);
}
// The decision function. Returns { blocked, reason } and never throws.
// declaredMime - the client-supplied Content-Type / fileObj.type
// detectedMime - the real type sniffed from content (file-type / `file`)
// headText - optional small text head of the file, for the shebang check
function isDangerousUploadMismatch({ declaredMime, detectedMime, headText } = {}) {
const detected = String(detectedMime || '').toLowerCase().trim();
const declared = String(declaredMime || '').toLowerCase().trim();
const detectedIsExecutable = EXECUTABLE_DETECTED_MIMES.has(detected);
const detectedIsScript = looksLikeScriptHead(headText);
if (!detectedIsExecutable && !detectedIsScript) {
return { blocked: false, reason: null };
}
// The declared type itself already admits to being an
// executable/script/octet-stream: no spoofing is happening, nothing to
// reject here (the general allow-list/deny-list in fileValidation.js is
// responsible for whether executables are allowed at all).
if (EXECUTABLE_DETECTED_MIMES.has(declared) || declared === 'application/octet-stream' || !declared) {
return { blocked: false, reason: null };
}
// isCompatibleTextual only judges the two sniffed MIME strings; a shebang
// match with no binary-detected mime (detected === '') has nothing to
// compare there and must fall through to the script check below instead of
// being waved through as "compatible".
if (detected && isCompatibleTextual(declared, detected)) {
return { blocked: false, reason: null };
}
if (!declaredLooksNonExecutable(declared)) {
// Declared type is not a recognized non-executable category either - be
// conservative and don't guess; the allow-list handles unknown types.
return { blocked: false, reason: null };
}
const detectedLabel = detectedIsExecutable ? detected : 'script (shebang)';
return {
blocked: true,
reason: `declared "${declared}" but content sniffs as executable/script (${detectedLabel})`,
};
}
module.exports = {
EXECUTABLE_DETECTED_MIMES,
declaredLooksNonExecutable,
isCompatibleTextual,
looksLikeScriptHead,
isDangerousUploadMismatch,
};
+224
View File
@@ -0,0 +1,224 @@
'use strict';
// GitHub issue #3274: an uploaded attachment's real content (magic bytes)
// must be checked against its client-declared MIME type, and a dangerous
// mismatch (e.g. an .exe renamed to .jpg) must be rejected.
//
// Tests the pure decision module directly - no server, no filesystem, no
// Meteor - per CLAUDE.md's security-test guidance: a pure module reproduces
// the attack exactly rather than approximately, and can run anywhere.
//
// Run: node tests/uploadContentMismatch.test.cjs
const assert = require('assert');
const fs = require('fs');
const path = require('path');
const {
isDangerousUploadMismatch,
declaredLooksNonExecutable,
isCompatibleTextual,
looksLikeScriptHead,
EXECUTABLE_DETECTED_MIMES,
} = require('../models/lib/uploadContentMismatch');
let passed = 0;
function test(name, fn) {
fn();
passed += 1;
console.log(' ok -', name);
}
// ---------------------------------------------------------------------------
// Positive: legitimate files of each declared type pass (not blocked)
// ---------------------------------------------------------------------------
test('a genuine JPEG declared image/jpeg is not blocked', () => {
const result = isDangerousUploadMismatch({
declaredMime: 'image/jpeg',
detectedMime: 'image/jpeg',
});
assert.strictEqual(result.blocked, false);
});
test('a genuine PDF declared application/pdf is not blocked', () => {
const result = isDangerousUploadMismatch({
declaredMime: 'application/pdf',
detectedMime: 'application/pdf',
});
assert.strictEqual(result.blocked, false);
});
test('text/plain declared as text/csv (compatible textual mismatch) is not blocked', () => {
const result = isDangerousUploadMismatch({
declaredMime: 'text/plain',
detectedMime: 'text/csv',
});
assert.strictEqual(result.blocked, false);
});
test('an octet-stream declared upload is never flagged (unspecified, not a spoof claim)', () => {
const result = isDangerousUploadMismatch({
declaredMime: 'application/octet-stream',
detectedMime: 'application/x-msdownload',
});
assert.strictEqual(result.blocked, false);
});
test('an executable honestly declared as an executable is not blocked (allow-list handles policy)', () => {
const result = isDangerousUploadMismatch({
declaredMime: 'application/x-msdownload',
detectedMime: 'application/x-msdownload',
});
assert.strictEqual(result.blocked, false);
});
test('missing detected mime (detection unavailable) never blocks on its own', () => {
const result = isDangerousUploadMismatch({
declaredMime: 'image/png',
detectedMime: undefined,
});
assert.strictEqual(result.blocked, false);
});
// ---------------------------------------------------------------------------
// Negative: a script/executable disguised with an image extension/MIME is
// rejected - the exact scenario from issue #3274.
// ---------------------------------------------------------------------------
test('a Windows PE .exe renamed to .jpg (declared image/jpeg) is blocked', () => {
const result = isDangerousUploadMismatch({
declaredMime: 'image/jpeg',
detectedMime: 'application/x-msdownload',
});
assert.strictEqual(result.blocked, true);
assert.ok(/executable/.test(result.reason));
});
test('an ELF binary renamed to .png (declared image/png) is blocked', () => {
const result = isDangerousUploadMismatch({
declaredMime: 'image/png',
detectedMime: 'application/x-elf',
});
assert.strictEqual(result.blocked, true);
});
test('a Mach-O binary renamed to .pdf (declared application/pdf) is blocked', () => {
const result = isDangerousUploadMismatch({
declaredMime: 'application/pdf',
detectedMime: 'application/x-mach-binary',
});
assert.strictEqual(result.blocked, true);
});
test('a shell script (#!/bin/sh) renamed to .txt (declared text/plain) is blocked', () => {
const result = isDangerousUploadMismatch({
declaredMime: 'text/plain',
detectedMime: undefined,
headText: '#!/bin/sh\nrm -rf /\n',
});
assert.strictEqual(result.blocked, true);
assert.ok(/script/.test(result.reason));
});
test('a bash script (#!/usr/bin/env not matched, but /usr/bin/bash is) is blocked', () => {
const result = isDangerousUploadMismatch({
declaredMime: 'image/jpeg',
detectedMime: undefined,
headText: '#!/usr/bin/bash\necho pwned\n',
});
assert.strictEqual(result.blocked, true);
});
test('a Windows batch script (@echo off) renamed to .docx is blocked', () => {
const result = isDangerousUploadMismatch({
declaredMime: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
detectedMime: undefined,
headText: '@echo off\r\ndel /f /q C:\\*.*\r\n',
});
assert.strictEqual(result.blocked, true);
});
test('looksLikeScriptHead ignores plain prose that merely mentions a shebang mid-text', () => {
assert.strictEqual(looksLikeScriptHead('This document explains what #!/bin/sh means.'), false);
});
test('declaredLooksNonExecutable recognizes common safe categories', () => {
assert.ok(declaredLooksNonExecutable('image/png'));
assert.ok(declaredLooksNonExecutable('application/pdf'));
assert.ok(declaredLooksNonExecutable('text/csv'));
assert.ok(!declaredLooksNonExecutable('application/x-msdownload'));
});
test('isCompatibleTextual treats two textual types as compatible, binary vs textual as not', () => {
assert.ok(isCompatibleTextual('text/plain', 'text/markdown'));
assert.ok(!isCompatibleTextual('image/jpeg', 'application/x-msdownload'));
});
test('EXECUTABLE_DETECTED_MIMES covers the common dangerous binary formats', () => {
['application/x-msdownload', 'application/x-elf', 'application/x-mach-binary', 'application/x-msi']
.forEach(mime => assert.ok(EXECUTABLE_DETECTED_MIMES.has(mime), mime));
});
// ---------------------------------------------------------------------------
// Negative: prove the fault isn't just fixed at one call site - every upload
// path that finalizes a file (attachments, avatars) must go through
// isFileValid, which is where isDangerousUploadMismatch is wired in.
// models/fileValidation.js's isFileValid() is the single choke point,
// referenced from models/attachments.server.js and models/avatars.server.js.
// ---------------------------------------------------------------------------
test('models/fileValidation.js calls isDangerousUploadMismatch (the guard is actually wired in)', () => {
const src = fs.readFileSync(path.join(__dirname, '..', 'models', 'fileValidation.js'), 'utf8');
assert.ok(
/isDangerousUploadMismatch/.test(src),
'fileValidation.js must call isDangerousUploadMismatch',
);
assert.ok(
/require\(['"]\.\/lib\/uploadContentMismatch['"]\)/.test(src),
'fileValidation.js must import uploadContentMismatch',
);
});
test('every upload path that finalizes a file goes through isFileValid (attachments + avatars)', () => {
const attachmentsSrc = fs.readFileSync(
path.join(__dirname, '..', 'models', 'attachments.server.js'), 'utf8',
);
const avatarsSrc = fs.readFileSync(
path.join(__dirname, '..', 'models', 'avatars.server.js'), 'utf8',
);
assert.ok(/isFileValid\(/.test(attachmentsSrc), 'attachments.server.js must call isFileValid');
assert.ok(/isFileValid\(/.test(avatarsSrc), 'avatars.server.js must call isFileValid');
});
test('no other server module re-implements its own ad-hoc magic-byte check bypassing isDangerousUploadMismatch', () => {
// Search every server-side JS file for a hand-rolled MZ/ELF/Mach-O magic
// byte check outside the guard itself and its own test, which would be the
// same spoofing hole reappearing at a second call site.
const roots = ['models', 'server'];
const offenders = [];
const suspiciousPatterns = [/0x4d5a/i, /['"]MZ['"]/, /x7fELF/i];
const skip = new Set([
path.join(__dirname, '..', 'models', 'lib', 'uploadContentMismatch.js'),
]);
function walk(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'node_modules' || entry.name === '.build' || entry.name === '_build') continue;
walk(full);
} else if (entry.isFile() && entry.name.endsWith('.js')) {
if (skip.has(full)) continue;
const contents = fs.readFileSync(full, 'utf8');
if (suspiciousPatterns.some(re => re.test(contents))) {
offenders.push(full);
}
}
}
}
roots.forEach(root => walk(path.join(__dirname, '..', root)));
assert.deepStrictEqual(offenders, [], 'found a duplicate/bypassing magic-byte check: ' + offenders.join(', '));
});
console.log(`uploadContentMismatch: ${passed} passed`);