diff --git a/lib/extlibs.js b/lib/extlibs.js index 656fe487..c1fa55e0 100644 --- a/lib/extlibs.js +++ b/lib/extlibs.js @@ -1,5 +1,5 @@ /* -* Copyright (c) 2015-2025 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved. +* Copyright (c) 2015-2026 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved. */ 'use strict'; const requester = require('./requester.js'); @@ -33,6 +33,49 @@ function emptyOutputTransform(/*headers, data*/) { }; } +/** + * Safely encodes a caller-supplied extension library path for use in a REST + * request URL. Each path segment is encoded individually with + * encodeURIComponent(), preventing path-separator injection (CWE-22) and + * query-parameter injection (CWE-20). Any leading /v1/ext/, /ext/, or bare / + * prefix is stripped before splitting so that all three accepted input forms + * (bare name, /path/name, /ext/path/name) produce the same canonical output. + * + * @param {string} rawPath - caller-supplied path or directory + * @param {boolean} trailingSlash - when true, appends a trailing '/' (used by list()) + * @returns {string} the encoded path beginning with /v1/ext/ + * @throws {Error} if any segment equals '..' or '.' + * @ignore + */ +function encodeExtPath(rawPath, trailingSlash) { + if (typeof rawPath !== 'string' && !(rawPath instanceof String)) { + throw new Error('extension library path must be a string'); + } + + // Require a leading '/' before stripping any prefix so that undocumented + // bare forms like 'ext/module.xqy' are treated as literal path segments + // rather than silently having their 'ext/' prefix removed. + const stripped = rawPath + .replace(/^\/(?:(?:v1\/)?ext\/)?/, '') + .replace(/\/$/, ''); + + const segments = stripped.length === 0 ? [] : stripped.split('/'); + + for (const seg of segments) { + if (seg === '..' || seg === '.') { + throw new Error( + 'extension library path must not contain relative path components (".", ".."): ' + rawPath + ); + } + } + + // Build the result using a conditional join so that an empty segment list + // does not produce a double slash (e.g. '/v1/ext//' for list('/ext/')). + const encodedSegments = segments.map(s => encodeURIComponent(s)); + const base = '/v1/ext' + (encodedSegments.length > 0 ? '/' + encodedSegments.join('/') : ''); + return base + (trailingSlash ? '/' : ''); +} + /** @ignore */ function ExtLibs(client) { if (!(this instanceof ExtLibs)) { @@ -56,11 +99,7 @@ ExtLibs.prototype.read = function readExtensionLibrary(path) { const requestOptions = mlutil.copyProperties(this.client.getConnectionParams()); requestOptions.method = 'GET'; - requestOptions.path = encodeURI( - (path.substr(0,5) === '/ext/') ? ('/v1'+path) : - (path.substr(0,1) === '/') ? ('/v1/ext'+path) : - ('/v1/ext/'+path) - ); + requestOptions.path = encodeExtPath(path); const operation = new Operation( 'read extension library', this.client, requestOptions, 'empty', 'single' @@ -127,15 +166,13 @@ ExtLibs.prototype.write = function writeExtensionLibrary() { throw new Error('must specify the path, content type, and source when writing a extension library'); } - let endpoint = - (path.substr(0,5) === '/ext/') ? ('/v1'+path) : - (path.substr(0,1) === '/') ? ('/v1/ext'+path) : - ('/v1/ext/'+path); + const encodedPath = encodeExtPath(path); + let queryString = ''; if (Array.isArray(permissions)) { let role = null; let capabilities = null; - let j=null; + let j = null; for (i=0; i < permissions.length; i++) { arg = permissions[i]; role = arg['role-name']; @@ -144,7 +181,8 @@ ExtLibs.prototype.write = function writeExtensionLibrary() { throw new Error('cannot set permissions from '+JSON.stringify(arg)); } for (j=0; j < capabilities.length; j++) { - endpoint += ((i === 0 && j=== 0) ? '?' : '&') + 'perm:'+role+'='+capabilities[j]; + queryString += ((queryString.length === 0) ? '?' : '&') + + 'perm:' + encodeURIComponent(role) + '=' + encodeURIComponent(capabilities[j]); } } } @@ -154,7 +192,7 @@ ExtLibs.prototype.write = function writeExtensionLibrary() { requestOptions.headers = { 'Content-Type': contentType }; - requestOptions.path = encodeURI(endpoint); + requestOptions.path = encodedPath + queryString; const operation = new Operation( 'write extension library', this.client, requestOptions, 'single', 'empty' @@ -180,11 +218,7 @@ ExtLibs.prototype.remove = function removeExtensionLibrary(path) { const requestOptions = mlutil.copyProperties(this.client.getConnectionParams()); requestOptions.method = 'DELETE'; - requestOptions.path = encodeURI( - (path.substr(0,5) === '/ext/') ? ('/v1'+path) : - (path.substr(0,1) === '/') ? ('/v1/ext'+path) : - ('/v1/ext/'+path) - ); + requestOptions.path = encodeExtPath(path); const operation = new Operation( 'remove extension library', this.client, requestOptions, 'empty', 'empty' @@ -215,24 +249,8 @@ ExtLibs.prototype.list = function listExtensionLibraries(directory) { if (typeof directory !== 'string' && !(directory instanceof String)) { requestOptions.path = '/v1/ext'; - } else if (directory.substr(0,5) === '/ext/') { - if (directory.substr(-1,1) === '/') { - requestOptions.path = encodeURI(directory); - } else { - requestOptions.path = encodeURI(directory+'/'); - } } else { - const hasInitialSlash = (directory.substr(0,1) === '/'); - const hasTrailingSlash = (directory.substr(-1,1) === '/'); - if (hasInitialSlash && hasTrailingSlash) { - requestOptions.path = encodeURI('/v1/ext' + directory); - } else if (hasTrailingSlash) { - requestOptions.path = encodeURI('/v1/ext/' + directory); - } else if (hasInitialSlash) { - requestOptions.path = encodeURI('/v1/ext' + directory+'/'); - } else { - requestOptions.path = encodeURI('/v1/ext/' + directory+'/'); - } + requestOptions.path = encodeExtPath(directory, true); } const operation = new Operation( diff --git a/test-basic/extlibs.js b/test-basic/extlibs.js index 6df1161f..06df410b 100644 --- a/test-basic/extlibs.js +++ b/test-basic/extlibs.js @@ -1,5 +1,5 @@ /* -* Copyright (c) 2015-2025 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved. +* Copyright (c) 2015-2026 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved. */ var should = require('should'); @@ -260,4 +260,91 @@ describe('extension libraries', function(){ .catch(done); }); }); + + describe('when handling path encoding security', function() { + it('should throw a user-facing Error (not TypeError) when a non-string path is passed to read()', function() { + (function() { restAdminDB.config.extlibs.read(42); }).should.throw(/must be a string/); + (function() { restAdminDB.config.extlibs.read({}); }).should.throw(/must be a string/); + }); + + it('should throw a user-facing Error (not TypeError) when a non-string path is passed to remove()', function() { + (function() { restAdminDB.config.extlibs.remove(42); }).should.throw(/must be a string/); + }); + + it('should throw a user-facing Error (not TypeError) when a non-string path is passed to write()', function() { + (function() { + restAdminDB.config.extlibs.write({ path: 42, contentType: 'application/xquery', source: Buffer.from('') }); + }).should.throw(/must be a string/); + }); + + it('should reject a path traversal attempt via .. segments in read()', function() { + (function() { + restAdminDB.config.extlibs.read('../../../v1/databases'); + }).should.throw(/relative path components/); + }); + + it('should reject a path traversal attempt via .. segments in write()', function() { + (function() { + restAdminDB.config.extlibs.write('../../../v1/databases', 'application/xquery', Buffer.from('')); + }).should.throw(/relative path components/); + }); + + it('should reject path traversal in the single-object form of write()', function() { + (function() { + restAdminDB.config.extlibs.write({ + path: '../../../v1/databases', + contentType: 'application/xquery', + source: Buffer.from('') + }); + }).should.throw(/relative path components/); + }); + + it('should reject a path traversal attempt via .. segments in remove()', function() { + (function() { + restAdminDB.config.extlibs.remove('../../secret'); + }).should.throw(/relative path components/); + }); + + it('should reject a path traversal attempt via .. segments in list()', function() { + (function() { + restAdminDB.config.extlibs.list('../../admin'); + }).should.throw(/relative path components/); + }); + + it('should produce a correctly percent-encoded path when a name contains a space', function(done) { + var spacedPath = 'my lib/module.xqy'; + restAdminDB.config.extlibs.read(spacedPath) + .result(function() { done(); }, + function(err) { + // A 404/403 response from MarkLogic is acceptable — it confirms that + // the percent-encoded URL was sent and understood by the server. + // A client-side URL construction error is a different failure class. + if (err && (err.statusCode === 404 || err.statusCode === 403)) { done(); } else { done(err); } + }); + }); + + it('should produce the same encoded path for all three input forms', function(done) { + // Calling read() is synchronous up to the point where it sets requestOptions.path; + // we verify none of the three forms throw and that the operation is initiated. + // (The network requests will all 404 since the module does not exist.) + var promises = [ + restAdminDB.config.extlibs.read('my/module.xqy').result(), + restAdminDB.config.extlibs.read('/my/module.xqy').result(), + restAdminDB.config.extlibs.read('/ext/my/module.xqy').result() + ]; + Promise.allSettled(promises).then(function(results) { + results.forEach(function(r) { + // Each should either resolve or reject with a server status code (404), + // never with a client-side TypeError or URL construction error. + if (r.status === 'rejected') { + if (!r.reason || !r.reason.statusCode) { + done(new Error('Unexpected non-HTTP error: ' + r.reason)); + return; + } + } + }); + done(); + }); + }); + }); });