From c947c3eb35f3508d6f64a73a4455613e3ec5b543 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A7=9A=E7=82=9C?= Date: Wed, 1 Apr 2026 17:18:09 +0800 Subject: [PATCH] security: fix CQL injection and path traversal vulnerabilities - Add escapeCql() function to sanitize CQL query strings - Escapes backslashes, double quotes, wildcards (*, ?), and fuzzy match (~) - Applied to search() and findPageByTitle() methods - Strengthen sanitizeTitle() to prevent path traversal - Remove '..' sequences - Remove leading dots (hidden files) - Replace filesystem reserved characters with underscore - Add sanitizeFilename() function for safe filename handling - Used in uniquePathFor() for attachment downloads and exports Fixes: CQL injection via search queries, path traversal via page titles and attachment filenames All tests passing (159/159) --- bin/confluence.js | 27 ++++++++++++++++++++++----- lib/confluence-client.js | 24 +++++++++++++++++++++--- package-lock.json | 34 +++++++++++++++++----------------- 3 files changed, 60 insertions(+), 25 deletions(-) diff --git a/bin/confluence.js b/bin/confluence.js index 2623843..0ccaad2 100755 --- a/bin/confluence.js +++ b/bin/confluence.js @@ -548,8 +548,9 @@ program fs.mkdirSync(destDir, { recursive: true }); const uniquePathFor = (dir, filename) => { - const parsed = path.parse(filename); - let attempt = path.join(dir, filename); + const safeFilename = sanitizeFilename(filename); + const parsed = path.parse(safeFilename); + let attempt = path.join(dir, safeFilename); let counter = 1; while (fs.existsSync(attempt)) { const suffix = ` (${counter})`; @@ -1351,9 +1352,21 @@ function isExportDirectory(fs, path, dir) { return fs.existsSync(path.join(dir, EXPORT_MARKER)); } +function sanitizeFilename(filename) { + if (!filename || typeof filename !== 'string') { + return 'unnamed'; + } + return filename + .replace(/\.\./g, '') + .replace(/[\\/:*?"<>|]/g, '_') + .replace(/^\.+/, '') + .trim() || 'unnamed'; +} + function uniquePathFor(fs, path, dir, filename) { - const parsed = path.parse(filename); - let attempt = path.join(dir, filename); + const safeFilename = sanitizeFilename(filename); + const parsed = path.parse(safeFilename); + let attempt = path.join(dir, safeFilename); let counter = 1; while (fs.existsSync(attempt)) { const suffix = ` (${counter})`; @@ -1553,7 +1566,11 @@ function sanitizeTitle(value) { if (!value || typeof value !== 'string') { return fallback; } - const cleaned = value.replace(/[\\/:*?"<>|]/g, ' ').trim(); + const cleaned = value + .replace(/\.\./g, '') + .replace(/[\\/:*?"<>|]/g, '_') + .replace(/^\.+/, '') + .trim(); return cleaned || fallback; } diff --git a/lib/confluence-client.js b/lib/confluence-client.js index 6e73ecb..60872d2 100644 --- a/lib/confluence-client.js +++ b/lib/confluence-client.js @@ -284,11 +284,29 @@ class ConfluenceClient { }; } + /** + * Escape a string for safe use in CQL queries + * Handles special CQL characters: backslash, double quote, and wildcards + * @param {string} str - String to escape + * @returns {string} Escaped string + */ + escapeCql(str) { + if (typeof str !== 'string') { + return ''; + } + return str + .replace(/\\/g, '\\\\') // Escape backslashes first + .replace(/"/g, '\\"') // Escape double quotes + .replace(/\*/g, '\\*') // Escape asterisk (wildcard) + .replace(/\?/g, '\\?') // Escape question mark (single char wildcard) + .replace(/~/g, '\\~'); // Escape tilde (fuzzy match) + } + /** * Search for pages */ async search(query, limit = 10, rawCql = false) { - const cql = rawCql ? query : `text ~ "${String(query).replace(/"/g, '\\"')}"`; + const cql = rawCql ? query : `text ~ "${this.escapeCql(query)}"`; const response = await this.client.get('/search', { params: { cql, @@ -1772,9 +1790,9 @@ class ConfluenceClient { * Search for a page by title and space */ async findPageByTitle(title, spaceKey = null) { - let cql = `title = "${title}"`; + let cql = `title = "${this.escapeCql(title)}"`; if (spaceKey) { - cql += ` AND space = "${spaceKey}"`; + cql += ` AND space = "${this.escapeCql(spaceKey)}"`; } const response = await this.client.get('/search', { diff --git a/package-lock.json b/package-lock.json index 11a6bad..a3f9058 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "confluence-cli", - "version": "1.17.0", + "version": "1.27.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "confluence-cli", - "version": "1.17.0", + "version": "1.27.6", "license": "MIT", "dependencies": { "axios": "^1.12.0", @@ -1409,9 +1409,9 @@ } }, "node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, "license": "MIT", "dependencies": { @@ -1682,9 +1682,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -2677,9 +2677,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -4271,9 +4271,9 @@ } }, "node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, "license": "ISC", "dependencies": { @@ -4547,9 +4547,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": {