Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
88 changes: 53 additions & 35 deletions lib/extlibs.js
Original file line number Diff line number Diff line change
@@ -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');
Expand Down Expand Up @@ -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.
Comment thread
Copilot marked this conversation as resolved.
*
* @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)) {
Expand All @@ -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'
Expand Down Expand Up @@ -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'];
Expand All @@ -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]);
}
}
}
Expand All @@ -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'
Expand All @@ -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'
Expand Down Expand Up @@ -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(
Expand Down
89 changes: 88 additions & 1 deletion test-basic/extlibs.js
Original file line number Diff line number Diff line change
@@ -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');

Expand Down Expand Up @@ -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();
});
});
});
});
Loading