mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-26 09:12:16 +00:00
fix(login): load SSL_CERT_DIR certificates without requiring hashed filenames (#12029)
This commit is contained in:
@@ -11,9 +11,8 @@ USER nextjs
|
||||
ENV HOSTNAME="::" \
|
||||
PORT="3000" \
|
||||
NODE_ENV="production" \
|
||||
NODE_OPTIONS="--use-openssl-ca" \
|
||||
NODE_OPTIONS="--use-openssl-ca --require /app/load-ssl-cert-dir.cjs" \
|
||||
SSL_CERT_FILE="/etc/ssl/certs/ca-certificates.crt" \
|
||||
SSL_CERT_DIR="/etc/ssl/certs" \
|
||||
ZITADEL_TLS_ENABLED="false" \
|
||||
OTEL_SERVICE_NAME="zitadel-login" \
|
||||
OTEL_EXPORTER_OTLP_PROTOCOL="http/protobuf"
|
||||
|
||||
@@ -21,12 +21,20 @@ const SMOCKER_ADMIN_PORT = 8081;
|
||||
* Integration tests for custom CA certificate support in the login application.
|
||||
*
|
||||
* These tests verify that the Dockerfile correctly configures Node.js to trust
|
||||
* custom Certificate Authorities via the SSL_CERT_FILE environment variable and
|
||||
* the NODE_OPTIONS=--use-openssl-ca flag.
|
||||
* custom Certificate Authorities via SSL_CERT_FILE, SSL_CERT_DIR, and the
|
||||
* load-ssl-cert-dir.cjs preload script.
|
||||
*
|
||||
* We use Smocker as a TLS server and test CA loading by making curl requests
|
||||
* from inside the container - this verifies TLS at the transport layer without
|
||||
* needing gRPC/HTTP2 support.
|
||||
* OpenSSL (used by Node with --use-openssl-ca) only reads certs from
|
||||
* SSL_CERT_DIR when they have hashed filenames (e.g. "9d66eef0.0"). Go's
|
||||
* crypto/x509 reads any file. The preload script bridges this gap so that
|
||||
* operators get the same behaviour with both the Go backend and the login
|
||||
* container.
|
||||
*
|
||||
* We use Smocker as a TLS server whose certificate is signed by a test CA
|
||||
* that is NOT in the system trust store. Each test block configures the
|
||||
* login container differently and asserts whether the TLS handshake to
|
||||
* Smocker succeeds or fails — verifying actual trust-store behaviour, not
|
||||
* just environment variable values.
|
||||
*/
|
||||
const LOGIN_IMAGE_TAG = `zitadel-login-test:${process.env.GITHUB_SHA || "local"}`;
|
||||
|
||||
@@ -84,7 +92,7 @@ describe("Custom CA Certificate Integration", () => {
|
||||
await network?.stop().catch(() => {});
|
||||
});
|
||||
|
||||
describe("when custom CA certificate is provided", () => {
|
||||
describe("when custom CA is provided via SSL_CERT_FILE", () => {
|
||||
let container: StartedTestContainer;
|
||||
|
||||
beforeAll(async () => {
|
||||
@@ -113,24 +121,40 @@ describe("Custom CA Certificate Integration", () => {
|
||||
const result = await testTlsConnection(container, "https://mock-zitadel/");
|
||||
expect(result.exitCode).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("has NODE_OPTIONS set with --use-openssl-ca", async () => {
|
||||
const result = await container.exec(["printenv", "NODE_OPTIONS"]);
|
||||
expect(result.output.trim()).toContain("--use-openssl-ca");
|
||||
describe("when custom CA is provided via SSL_CERT_DIR (non-hashed filename)", () => {
|
||||
let container: StartedTestContainer;
|
||||
let customCaDir: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
customCaDir = fs.mkdtempSync(path.join(os.tmpdir(), "ca-dir-test-"));
|
||||
fs.writeFileSync(path.join(customCaDir, "corp-root.crt"), fs.readFileSync(path.join(certsDir, "ca.crt")));
|
||||
|
||||
container = await new GenericContainer(LOGIN_IMAGE_TAG)
|
||||
.withNetwork(network)
|
||||
.withExposedPorts(3000)
|
||||
.withEnvironment({
|
||||
SSL_CERT_DIR: "/custom-certs",
|
||||
})
|
||||
.withCopyFilesToContainer([
|
||||
{
|
||||
source: path.join(customCaDir, "corp-root.crt"),
|
||||
target: "/custom-certs/corp-root.crt",
|
||||
},
|
||||
])
|
||||
.withStartupTimeout(180_000)
|
||||
.withWaitStrategy(Wait.forHttp("/ui/v2/login/healthy", 3000))
|
||||
.start();
|
||||
}, 180_000);
|
||||
|
||||
afterAll(async () => {
|
||||
await container?.stop().catch(() => {});
|
||||
});
|
||||
|
||||
it("has SSL_CERT_FILE pointing to custom CA", async () => {
|
||||
const result = await container.exec(["printenv", "SSL_CERT_FILE"]);
|
||||
expect(result.output.trim()).toBe("/etc/ssl/certs/custom-ca.crt");
|
||||
});
|
||||
|
||||
it("has CA certificate accessible in container", async () => {
|
||||
const result = await container.exec([
|
||||
"sh",
|
||||
"-c",
|
||||
"test -f /etc/ssl/certs/custom-ca.crt && echo exists",
|
||||
]);
|
||||
expect(result.output.trim()).toContain("exists");
|
||||
it("can establish TLS connection with non-hashed cert in SSL_CERT_DIR", async () => {
|
||||
const result = await testTlsConnection(container, "https://mock-zitadel/");
|
||||
expect(result.exitCode).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -154,19 +178,5 @@ describe("Custom CA Certificate Integration", () => {
|
||||
const result = await testTlsConnection(container, "https://mock-zitadel/");
|
||||
expect(result.exitCode).not.toBe(0);
|
||||
});
|
||||
|
||||
it("uses system CA store by default", async () => {
|
||||
const result = await container.exec(["printenv", "SSL_CERT_FILE"]);
|
||||
expect(result.output.trim()).toBe("/etc/ssl/certs/ca-certificates.crt");
|
||||
});
|
||||
|
||||
it("does not have custom CA certificate mounted", async () => {
|
||||
const result = await container.exec([
|
||||
"sh",
|
||||
"-c",
|
||||
"test -f /etc/ssl/certs/custom-ca.crt && echo exists || echo missing",
|
||||
]);
|
||||
expect(result.output.trim()).toContain("missing");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
// Preload script that makes Node.js read CA certificates from SSL_CERT_DIR
|
||||
// the same way Go's crypto/x509 does: every regular file in the directory is
|
||||
// read regardless of filename. OpenSSL (used by Node when --use-openssl-ca
|
||||
// is set) only reads files named with a subject-hash (e.g. "9d66eef0.0"),
|
||||
// silently ignoring plain names like "corp-ca.crt". This script closes that
|
||||
// gap so operators get identical behaviour across the Go backend and the
|
||||
// Node.js login container.
|
||||
//
|
||||
// Usage: node --require /app/load-ssl-cert-dir.cjs ...
|
||||
//
|
||||
// The script is a no-op when SSL_CERT_DIR is not set.
|
||||
|
||||
"use strict";
|
||||
|
||||
const tls = require("tls");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const sslCertDir = process.env.SSL_CERT_DIR;
|
||||
if (!sslCertDir) {
|
||||
return; // nothing to do — system trust via SSL_CERT_FILE is sufficient
|
||||
}
|
||||
|
||||
// Go splits on ":" and walks every directory in order.
|
||||
const dirs = sslCertDir.split(":").filter(Boolean);
|
||||
const certs = [];
|
||||
|
||||
for (const dir of dirs) {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(dir);
|
||||
} catch {
|
||||
// Directory doesn't exist or isn't readable — skip, like Go does.
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const filePath = path.join(dir, entry);
|
||||
|
||||
try {
|
||||
const stat = fs.lstatSync(filePath);
|
||||
|
||||
// Go's readUniqueDirectoryEntries skips symlinks whose target is a
|
||||
// bare filename (no slash), because those are the c_rehash hash links
|
||||
// pointing at files in the same directory. This avoids loading the
|
||||
// same certificate twice.
|
||||
if (stat.isSymbolicLink()) {
|
||||
const target = fs.readlinkSync(filePath);
|
||||
if (!target.includes("/")) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (!stat.isFile() && !stat.isSymbolicLink()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const content = fs.readFileSync(filePath, "utf8");
|
||||
if (content.includes("-----BEGIN CERTIFICATE-----")) {
|
||||
certs.push(content);
|
||||
}
|
||||
} catch {
|
||||
// Unreadable file — skip silently, matching Go behaviour.
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (certs.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const origCreateSecureContext = tls.createSecureContext;
|
||||
|
||||
tls.createSecureContext = function (options) {
|
||||
const ctx = origCreateSecureContext.call(this, options);
|
||||
|
||||
// Only inject directory CAs when the caller did not pass an explicit "ca"
|
||||
// option. This matches Go's SystemCertPool() semantics: the extra certs
|
||||
// are part of the system pool, not a forced override.
|
||||
if (!options || !options.ca) {
|
||||
for (let i = 0; i < certs.length; i++) {
|
||||
try {
|
||||
ctx.context.addCACert(certs[i]);
|
||||
} catch (err) {
|
||||
// Log once per bad cert, then remove it so subsequent contexts
|
||||
// don't pay the cost of retrying a known-bad certificate.
|
||||
console.error("load-ssl-cert-dir: skipping malformed certificate at index " + i + ": " + err.message);
|
||||
certs.splice(i, 1);
|
||||
i--;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return ctx;
|
||||
};
|
||||
Reference in New Issue
Block a user