mirror of
https://github.com/wekan/wekan.git
synced 2026-11-05 01:20:07 -05:00
CHANGELOG: add the "Remove all labels" rule action entry.
This commit is contained in:
1 parent
fa71c8ba4d
commit
61e857d96e
4 files changed
+444
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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`);
|
||||
Reference in new issue
Block a user