diff --git a/.gitignore b/.gitignore index fa84af9e..2b4c5d29 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/extractSslCertificate. +# 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.pem +test-app/src/main/ml-config/ssl-server-cert.pem +test-app/src/main/ml-config/ssl-server-key.pem +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..644fccea 100644 --- a/test-app/build.gradle +++ b/test-app/build.gradle @@ -47,6 +47,66 @@ ext { mlAppDeployer.commands.add(command) } +/* + * After mlDeploy, MarkLogic holds the generated self-signed certificate. This task + * fetches its PEM via the Management API and writes it to the well-known path + * src/main/ml-config/self-signed-ca.pem so that Node.js test clients can trust it + * via the `ca` option — do NOT use rejectUnauthorized: false in production. + * + * Because the cert is regenerated on every mlDeploy this file is not committed to the + * repository; it is listed in .gitignore and produced at deploy time. + */ +tasks.register("extractSslCertificate") { + description = "Extracts the generated SSL CA certificate from MarkLogic and writes it for Node.js test clients." + doLast { + def manageHost = project.findProperty("mlManageHost") ?: project.findProperty("mlHost") ?: "localhost" + def managePort = project.findProperty("mlManagePort") ?: "8002" + def username = project.findProperty("mlManageUsername") ?: project.findProperty("mlUsername") + def password = project.findProperty("mlManagePassword") ?: project.findProperty("mlPassword") + if (!username || !password) { + throw new GradleException("Missing MarkLogic credentials: set mlUsername/mlPassword or mlManageUsername/mlManagePassword") + } + def credentials = Base64.getEncoder().encodeToString( + "${username}:${password}".getBytes("UTF-8")) + + // Step 1 — find the certificate ID(s) attached to the template + def templateUrl = new URL( + "http://${manageHost}:${managePort}/manage/v2/certificate-templates/node-client-ssl-template?format=json") + def templateConn = templateUrl.openConnection() as HttpURLConnection + templateConn.setConnectTimeout(10_000) + templateConn.setReadTimeout(10_000) + templateConn.setRequestProperty("Authorization", "Basic ${credentials}") + templateConn.setRequestProperty("Accept", "application/json") + if (templateConn.responseCode >= 400) { + throw new GradleException("Failed to fetch certificate template 'node-client-ssl-template': HTTP ${templateConn.responseCode} ${templateConn.responseMessage}") + } + def templateJson = templateConn.inputStream.withCloseable { new groovy.json.JsonSlurper().parse(it) } + + def certEntry = templateJson["certificate-template-default"]["certificate-list"]["certificate"] + def certId = (certEntry instanceof List ? certEntry[0] : certEntry)["certificate-id"] + + // Step 2 — fetch the PEM for that certificate + def certUrl = new URL( + "http://${manageHost}:${managePort}/manage/v2/certificates/${certId}?format=json") + def certConn = certUrl.openConnection() as HttpURLConnection + certConn.setConnectTimeout(10_000) + certConn.setReadTimeout(10_000) + certConn.setRequestProperty("Authorization", "Basic ${credentials}") + certConn.setRequestProperty("Accept", "application/json") + if (certConn.responseCode >= 400) { + throw new GradleException("Failed to fetch certificate '${certId}': HTTP ${certConn.responseCode} ${certConn.responseMessage}") + } + def certJson = certConn.inputStream.withCloseable { new groovy.json.JsonSlurper().parse(it) } + def pem = certJson["certificate-default"]["pem"] + + // Step 3 — write to well-known path (overwritten on every mlDeploy) + def outFile = file("src/main/ml-config/self-signed-ca.pem") + outFile.text = pem + println "SSL certificate extracted from node-client-ssl-template → ${outFile.absolutePath}" + } +} +mlDeploy.finalizedBy extractSslCertificate + tasks.register("curlPeople", Exec) { commandLine = [ 'curl', diff --git a/test-basic/ssl-config-security-test.js b/test-basic/ssl-config-security-test.js new file mode 100644 index 00000000..bc295f2f --- /dev/null +++ b/test-basic/ssl-config-security-test.js @@ -0,0 +1,189 @@ +/* + * 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 extractSslCertificate 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 extractSslCertificate 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); +var BUILD_GRADLE_PATH = path.join(__dirname, "../test-app/build.gradle"); + +// 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 () { + + // Smoke-test the deploy wiring that provisions and exports the cert used by `ca`. + describe("build.gradle certificate provisioning wiring", function () { + + it("uses GenerateTemporaryCertificateCommand for node-client-ssl-template", function () { + fs.existsSync(BUILD_GRADLE_PATH).should.be.true("build.gradle file is missing"); + var build = fs.readFileSync(BUILD_GRADLE_PATH, "utf8"); + build.should.containEql("GenerateTemporaryCertificateCommand()"); + build.should.containEql("command.setTemplateIdOrName(\"node-client-ssl-template\")"); + }); + + it("extracts certificate via extractSslCertificate after mlDeploy", function () { + var build = fs.readFileSync(BUILD_GRADLE_PATH, "utf8"); + build.should.containEql("tasks.register(\"extractSslCertificate\")"); + build.should.containEql("mlDeploy.finalizedBy extractSslCertificate"); + build.should.containEql("src/main/ml-config/self-signed-ca.pem"); + }); + + it("does not use obsolete insertSslCertificate flow", function () { + var build = fs.readFileSync(BUILD_GRADLE_PATH, "utf8"); + build.should.not.containEql("tasks.register(\"insertSslCertificate\")"); + build.should.not.containEql("mlDeploy.finalizedBy insertSslCertificate"); + }); + }); + + 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 (extracted from MarkLogic by extractSslCertificate 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(); + }); + }); + }); +});