From a1e36806cc6a90f84f73670a5de4d1d1119af0c3 Mon Sep 17 00:00:00 2001 From: "godugu@progress.com" Date: Fri, 17 Jul 2026 10:37:52 -0700 Subject: [PATCH] MLE-30259: Replace rejectUnauthorized:false With Proper CA Certificates --- .gitignore | 10 ++ etc/test-config-qa-ssl.js | 17 ++- etc/test-config-qa.js | 17 ++- etc/test-config.js | 21 ++-- test-app/build.gradle | 67 ++++++++-- test-basic/ssl-config-security-test.js | 164 +++++++++++++++++++++++++ 6 files changed, 273 insertions(+), 23 deletions(-) create mode 100644 test-basic/ssl-config-security-test.js diff --git a/.gitignore b/.gitignore index fa84af9e..7d2d158d 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/etc/test-config-qa-ssl.js b/etc/test-config-qa-ssl.js index ff65507c..293ecb62 100644 --- a/etc/test-config-qa-ssl.js +++ b/etc/test-config-qa-ssl.js @@ -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'; @@ -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, @@ -69,7 +78,7 @@ module.exports = { user: restAdminUser, password: restAdminPassword, authType: 'BASIC', - rejectUnauthorized: false, - ssl: true + ssl: true, + ca: SSL_CA } }; diff --git a/etc/test-config-qa.js b/etc/test-config-qa.js index e4aaa140..4792884f 100644 --- a/etc/test-config-qa.js +++ b/etc/test-config-qa.js @@ -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'; @@ -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, @@ -114,7 +123,7 @@ module.exports = { user: restAdminUser, password: restAdminPassword, authType: 'BASIC', - rejectUnauthorized: false, - ssl: true + ssl: true, + ca: SSL_CA } }; diff --git a/etc/test-config.js b/etc/test-config.js index 3ceda5f2..fad1e693 100644 --- a/etc/test-config.js +++ b/etc/test-config.js @@ -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'; @@ -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, @@ -93,8 +102,8 @@ module.exports = { user: restAdminUser, password: restAdminPassword, authType: 'BASIC', - rejectUnauthorized: false, ssl: true, + ca: SSL_CA, enableGzippedResponses: true }, testConnection: { @@ -103,7 +112,6 @@ module.exports = { user: testUser, password: testPassword, authType: restAuthType, - rejectUnauthorized: false, enableGzippedResponses: true }, tdeConnection: { @@ -112,7 +120,6 @@ module.exports = { user: tdeUser, password: tdePassword, authType: restAuthType, - rejectUnauthorized: false, enableGzippedResponses: true }, restWriterConnectionWithBasePath: { @@ -144,7 +151,7 @@ module.exports = { user: restWriterUser, password: restWriterPassword, authType: restAuthType, - ssl: true, - rejectUnauthorized: false + ssl: true, + ca: SSL_CA } }; diff --git a/test-app/build.gradle b/test-app/build.gradle index 74a87c86..45d82d3b 100644 --- a/test-app/build.gradle +++ b/test-app/build.gradle @@ -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 = [ diff --git a/test-basic/ssl-config-security-test.js b/test-basic/ssl-config-security-test.js new file mode 100644 index 00000000..77f0f7ad --- /dev/null +++ b/test-basic/ssl-config-security-test.js @@ -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); + + 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(); + }); + }); + }); +});