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
10 changes: 10 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,13 @@ test-basic-proxy/lib/positive/*.js
!test-basic-proxy/lib/positive/*Test.js
test-basic-proxy/lib/negative/*.js
!test-basic-proxy/lib/negative/*Test.js

# SSL certificates generated at deploy time by mlDeploy/generateAndInstallSslCertificate.
# Never commit private keys or regenerated certs — they change on every deployment.
test-app/src/main/ml-config/self-signed-ca.pem
test-app/src/main/ml-config/self-signed-ca.key
test-app/src/main/ml-config/ssl-server.pem
test-app/src/main/ml-config/ssl-server.key
test-app/src/main/ml-config/ssl-server.csr
test-app/src/main/ml-config/ssl-server-ext.cnf
test-app/src/main/ml-config/*.srl
17 changes: 13 additions & 4 deletions etc/test-config-qa-ssl.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
/*
* 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.
*/
const fs = require('fs');
const path = require('path');

// self-signed-ca.pem is written by the Gradle extractSslCertificate task after mlDeploy.
// It is not committed to the repository. SSL test connections fail gracefully
// (cert verification error) if mlDeploy has not been run yet.
const CA_PATH = path.join(__dirname, '../test-app/src/main/ml-config/self-signed-ca.pem');
const SSL_CA = fs.existsSync(CA_PATH) ? fs.readFileSync(CA_PATH) : undefined;

var testHost = 'localhost';

var restPort = '8016';
Expand All @@ -23,7 +32,7 @@ var restEvaluatorPassword = 'x';

var testServerName = 'node-client-api-ssl-server';

// For SSL without client cert, use rejectUnauthorized: false
// Do NOT use rejectUnauthorized: false in production.
module.exports = {
testServerName: testServerName,
testHost: testHost,
Expand Down Expand Up @@ -69,7 +78,7 @@ module.exports = {
user: restAdminUser,
password: restAdminPassword,
authType: 'BASIC',
rejectUnauthorized: false,
ssl: true
ssl: true,
ca: SSL_CA
}
};
17 changes: 13 additions & 4 deletions etc/test-config-qa.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
/*
* 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.
*/
const fs = require('fs');
const path = require('path');

// self-signed-ca.pem is written by the Gradle extractSslCertificate task after mlDeploy.
// It is not committed to the repository. SSL test connections fail gracefully
// (cert verification error) if mlDeploy has not been run yet.
const CA_PATH = path.join(__dirname, '../test-app/src/main/ml-config/self-signed-ca.pem');
const SSL_CA = fs.existsSync(CA_PATH) ? fs.readFileSync(CA_PATH) : undefined;

var testHost = 'localhost';

var restPort = '8024';
Expand Down Expand Up @@ -31,7 +40,7 @@ var configAdminPassword = 'admin';
var testServerName = 'node-client-api-rest-server';
var dmsdktestServerName = 'dmsdk-api-rest-server';

// For SSL without client cert, use rejectUnauthorized: false
// Do NOT use rejectUnauthorized: false in production.
module.exports = {
testServerName: testServerName,
testHost: testHost,
Expand Down Expand Up @@ -114,7 +123,7 @@ module.exports = {
user: restAdminUser,
password: restAdminPassword,
authType: 'BASIC',
rejectUnauthorized: false,
ssl: true
ssl: true,
ca: SSL_CA
}
};
21 changes: 14 additions & 7 deletions etc/test-config.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
/*
* 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.
*/
const fs = require('fs');
const path = require('path');

// self-signed-ca.pem is written by the Gradle extractSslCertificate task after mlDeploy.
// It is not committed to the repository. SSL test connections fail gracefully
// (cert verification error) if mlDeploy has not been run yet.
const CA_PATH = path.join(__dirname, '../test-app/src/main/ml-config/self-signed-ca.pem');
const SSL_CA = fs.existsSync(CA_PATH) ? fs.readFileSync(CA_PATH) : undefined;

let testHost = 'localhost';

let restPort = '8015';
Expand Down Expand Up @@ -33,7 +42,7 @@ let testPassword = 'x';
let tdeUser = 'tde-user';
let tdePassword = 'x';

// For SSL without client cert, use rejectUnauthorized: false
// Do NOT use rejectUnauthorized: false in production.
module.exports = {
testServerName: testServerName,
testHost: testHost,
Expand Down Expand Up @@ -93,8 +102,8 @@ module.exports = {
user: restAdminUser,
password: restAdminPassword,
authType: 'BASIC',
rejectUnauthorized: false,
ssl: true,
ca: SSL_CA,
enableGzippedResponses: true
},
testConnection: {
Expand All @@ -103,7 +112,6 @@ module.exports = {
user: testUser,
password: testPassword,
authType: restAuthType,
rejectUnauthorized: false,
enableGzippedResponses: true
},
tdeConnection: {
Expand All @@ -112,7 +120,6 @@ module.exports = {
user: tdeUser,
password: tdePassword,
authType: restAuthType,
rejectUnauthorized: false,
enableGzippedResponses: true
},
restWriterConnectionWithBasePath: {
Expand Down Expand Up @@ -144,7 +151,7 @@ module.exports = {
user: restWriterUser,
password: restWriterPassword,
authType: restAuthType,
ssl: true,
rejectUnauthorized: false
ssl: true,
ca: SSL_CA
}
};
67 changes: 59 additions & 8 deletions test-app/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -36,16 +36,67 @@ mlDeploy.dependsOn addMarkLogic12SchemasIfNecessary
mlLoadSchemas.dependsOn addMarkLogic12SchemasIfNecessary

/*
* Required for forcing MarkLogic to generate a temporary certificate for the app server
* that requires SSL.
* Generates a CA + server certificate pair using openssl and inserts the server
* cert into the MarkLogic node-client-ssl-template. The self-signed CA cert is
* written to src/main/ml-config/self-signed-ca.pem so Node.js test clients can
* trust the MarkLogic SSL server via the `ca` option.
*
* The generated files are listed in .gitignore and recreated on every run.
*/
ext {
def command = new com.marklogic.appdeployer.command.security.GenerateTemporaryCertificateCommand()
command.setTemplateIdOrName("node-client-ssl-template")
command.setCommonName("localhost")
command.setValidFor(365)
mlAppDeployer.commands.add(command)
tasks.register("generateAndInstallSslCertificate", com.marklogic.gradle.task.MarkLogicTask) {
description = "Generates CA + server certs with openssl and inserts the server cert into MarkLogic."
doLast {
def certDir = file("src/main/ml-config")
certDir.mkdirs()

def caKeyFile = new File(certDir, "self-signed-ca.key")
def caCertFile = new File(certDir, "self-signed-ca.pem")
def srvKeyFile = new File(certDir, "ssl-server.key")
def srvCsrFile = new File(certDir, "ssl-server.csr")
def srvCertFile = new File(certDir, "ssl-server.pem")
def extFile = new File(certDir, "ssl-server-ext.cnf")

// Step 1 — self-signed CA cert
exec {
commandLine "openssl", "req", "-x509", "-newkey", "rsa:2048",
"-keyout", caKeyFile.absolutePath, "-out", caCertFile.absolutePath,
"-days", "365", "-nodes",
"-subj", "/C=US/ST=VA/L=McLean/O=MarkLogic/OU=Sales/CN=localhost"
errorOutput = System.err
}

// Step 2 — server CSR
exec {
commandLine "openssl", "req", "-newkey", "rsa:2048",
"-keyout", srvKeyFile.absolutePath, "-out", srvCsrFile.absolutePath,
"-nodes", "-subj", "/CN=localhost"
errorOutput = System.err
}

// Step 3 — server cert signed by our CA (SAN required by modern TLS clients)
extFile.text = "subjectAltName=IP:127.0.0.1,DNS:localhost\nextendedKeyUsage=serverAuth"
exec {
commandLine "openssl", "x509", "-req",
"-in", srvCsrFile.absolutePath,
"-CA", caCertFile.absolutePath, "-CAkey", caKeyFile.absolutePath,
"-CAcreateserial", "-out", srvCertFile.absolutePath,
"-days", "365", "-extfile", extFile.absolutePath
errorOutput = System.err
}

// Step 4 — insert server cert + key into the MarkLogic certificate template.
def payload = new groovy.json.JsonBuilder([
operation : "insert-host-certificates",
certificates : [[certificate: [cert: srvCertFile.text, pkey: srvKeyFile.text]]]
]).toString()
getManageClient().postJson(
"/manage/v2/certificate-templates/node-client-ssl-template", payload)

println "SSL CA cert -> ${caCertFile.absolutePath}"
println "SSL server cert inserted into MarkLogic template: node-client-ssl-template"
}
}
mlDeploy.finalizedBy generateAndInstallSslCertificate

tasks.register("curlPeople", Exec) {
commandLine = [
Expand Down
164 changes: 164 additions & 0 deletions test-basic/ssl-config-security-test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,164 @@
/*
* Copyright (c) 2015-2026 Progress Software Corporation and/or its subsidiaries or affiliates. All Rights Reserved.
*
* Regression guard: verify that no SSL-capable connection config disables TLS certificate
* verification via rejectUnauthorized: false, and that each uses the CA extracted from
* MarkLogic by the Gradle generateAndInstallSslCertificate task.
*
* Two tiers of checks:
* 1. rejectUnauthorized: false guard -- always runs, no MarkLogic required.
* 2. CA Buffer presence check -- runs only after mlDeploy has been executed
* (self-signed-ca.pem exists). Tests are skipped with a clear message otherwise.
*/

"use strict";

var fs = require("fs");
var path = require("path");
var should = require("should");
var marklogic = require("../");
var testlib = require("../etc/test-lib");

// Path where generateAndInstallSslCertificate writes the CA PEM after mlDeploy
var CA_PATH = path.join(__dirname, "../test-app/src/main/ml-config/self-signed-ca.pem");
var caDeployed = fs.existsSync(CA_PATH);

// All three test-config files that previously contained rejectUnauthorized: false
var configs = [
{ label: "test-config.js", module: require("../etc/test-config.js") },
{ label: "test-config-qa-ssl.js", module: require("../etc/test-config-qa-ssl.js") },
{ label: "test-config-qa.js", module: require("../etc/test-config-qa.js") }
];
var mainTestConfig = configs[0].module;

// SSL-enabled connection objects expected in each config
var sslConnectionKeys = {
"test-config.js": ["restSslConnection", "restConnectionForTls"],
"test-config-qa-ssl.js": ["restSslConnection"],
"test-config-qa.js": ["restSslConnection"]
};

// Non-SSL connections that previously carried the stray property
var nonSslConnectionKeys = {
"test-config.js": ["testConnection", "tdeConnection"]
};

function looksLikeTlsVerifyError(message) {
if (!message) {
return false;
}
return /self signed|unable to verify|certificate verify failed|DEPTH_ZERO_SELF_SIGNED_CERT|SELF_SIGNED_CERT_IN_CHAIN|UNABLE_TO_VERIFY_LEAF_SIGNATURE|SSL routines/i.test(message);
}

describe("SSL connection config security guard", function () {

configs.forEach(function(cfgEntry) {
var label = cfgEntry.label;
var cfg = cfgEntry.module;

describe(label, function () {

var sslKeys = sslConnectionKeys[label] || [];
sslKeys.forEach(function (key) {

// Tier 1: always enforce -- no MarkLogic needed
it(key + " must not set rejectUnauthorized: false", function () {
var conn = cfg[key];
should.exist(conn, key + " should exist in " + label);
should(conn.rejectUnauthorized).not.equal(false,
key + ".rejectUnauthorized must not be false -- use a ca bundle instead");
});

// Tier 2: CA presence -- only meaningful after mlDeploy has run
it(key + " must provide a ca certificate Buffer (requires mlDeploy)", function () {
if (!caDeployed) {
return this.skip(
"self-signed-ca.pem not found -- run gradle mlDeploy first to " +
"extract the CA certificate from MarkLogic");
}
var conn = cfg[key];
should.exist(conn, key + " should exist in " + label);
should.exist(conn.ca,
key + ".ca must be set -- self-signed-ca.pem exists but ca is undefined");
Buffer.isBuffer(conn.ca).should.be.true(
key + ".ca should be a Buffer loaded from the PEM file");
conn.ca.length.should.be.greaterThan(0, key + ".ca buffer must not be empty");
conn.ca.toString("utf8").should.containEql("-----BEGIN CERTIFICATE-----");
});
});

// Non-SSL connections must not carry the stray property
var nonSslKeys = nonSslConnectionKeys[label] || [];
nonSslKeys.forEach(function (key) {
it(key + " (non-SSL) must not set rejectUnauthorized: false", function () {
var conn = cfg[key];
should.exist(conn, key + " should exist in " + label);
should(conn.rejectUnauthorized).not.equal(false,
key + ".rejectUnauthorized: false has no effect on non-SSL connections -- remove it");
});
});
});
});

// CA file sanity check -- skipped pre-deploy, validates post-deploy
describe("self-signed-ca.pem (generated by generateAndInstallSslCertificate task)", function () {

it("CA file exists at the well-known path after mlDeploy", function () {
if (!caDeployed) {
return this.skip("Run gradle mlDeploy to generate self-signed-ca.pem");
}
fs.existsSync(CA_PATH).should.be.true("CA file not found at " + CA_PATH);
});

it("CA file contains a valid PEM certificate", function () {
if (!caDeployed) {
return this.skip("Run gradle mlDeploy to generate self-signed-ca.pem");
}
var content = fs.readFileSync(CA_PATH, "utf8");
content.should.containEql("-----BEGIN CERTIFICATE-----");
content.should.containEql("-----END CERTIFICATE-----");
});
});

// Live-server integration check: verification must fail when CA is not provided.
describe("SSL certificate enforcement (negative)", function () {
this.timeout(15000);

var serverConfiguration = {};

before(function (done) {
testlib.findServerConfiguration(serverConfiguration);
setTimeout(function () {
done();
}, 3000);
});

it("fails TLS handshake when CA is intentionally omitted", function (done) {
if (serverConfiguration.serverVersion < 12) {
return this.skip();
}
if (!caDeployed || !mainTestConfig.restConnectionForTls || !Buffer.isBuffer(mainTestConfig.restConnectionForTls.ca)) {
return this.skip(
"CA not deployed/loaded -- run gradle mlDeploy first so restConnectionForTls includes a CA before running this integration check");
}

// Clone the known-good TLS connection and intentionally remove CA trust.
var insecureConn = Object.assign({}, mainTestConfig.restConnectionForTls);
delete insecureConn.ca;
var dbNoCa = marklogic.createDatabaseClient(insecureConn);

Comment thread
Copilot marked this conversation as resolved.
dbNoCa.documents.read({ uris: "/does-not-matter-for-tls-check.json" })
.result(function () {
done(new Error("Expected TLS handshake failure when CA is omitted, but request succeeded"));
})
.catch(function (error) {
should.exist(error);
should.exist(error.message);
looksLikeTlsVerifyError(error.message).should.be.true(
"Expected TLS/certificate verification failure, got: " + error.message
);
done();
});
});
});
});
Loading