From db2db5095b9aa9365b42933264f32da132fc5761 Mon Sep 17 00:00:00 2001 From: William Mantly Date: Mon, 27 Jul 2026 21:15:47 -0400 Subject: [PATCH] Add a static consistency check for jump-host's generated LDAP config Regression guard for bootstrap.js's jump-secrets.js template: its ldap block must use ldaps:// (implicit TLS, :636), not ldap:// (:389), as long as tlsOptions is set alongside it. ldapts treats a non-empty tlsOptions as "use implicit TLS" regardless of URL scheme, and jump-host's LDAP client always sets tlsOptions -- so this exact combination broke every SSH login to jump-host (any account, any password) before being root-caused against a real deployment. Static (parses bootstrap.js as text), not a require()+exec of it -- bootstrap.js is a self-running provisioning script with real side effects (LDAP writes, live API calls), not a library, so there's nothing safe to import and call in CI. Co-Authored-By: Claude Sonnet 5 --- .github/workflows/lint.yml | 7 +++- test/check_jump_ldap_tls.js | 66 +++++++++++++++++++++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 test/check_jump_ldap_tls.js diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index d2e5373..10acbcc 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2,7 +2,9 @@ name: Lint # theta-env has no app code of its own to unit-test (it orchestrates the # proxy/sso-manager-node submodules) -- this checks the one thing that can -# actually break silently: setup.sh and bootstrap.js. +# actually break silently: setup.sh and bootstrap.js, plus a static +# consistency check on the config bootstrap.js generates for jump-host +# (test/check_jump_ldap_tls.js). on: pull_request: branches: @@ -39,3 +41,6 @@ jobs: - name: Syntax check run: node --check bootstrap/bootstrap.js + + - name: Jump-host LDAP config consistency + run: node test/check_jump_ldap_tls.js diff --git a/test/check_jump_ldap_tls.js b/test/check_jump_ldap_tls.js new file mode 100644 index 0000000..d8a4795 --- /dev/null +++ b/test/check_jump_ldap_tls.js @@ -0,0 +1,66 @@ +#!/usr/bin/env node +'use strict'; + +// Regression guard for bootstrap.js's generated jump-secrets.js template: +// its ldap block must use ldaps:// (implicit TLS, :636), never ldap:// (:389), +// as long as tlsOptions is set alongside it. +// +// ldapts treats a non-empty tlsOptions as "use implicit TLS" regardless of URL +// scheme, and jump-host's LDAP client always sets tlsOptions -- so ldap:// +// + tlsOptions opens a raw TLS handshake against a port serving plaintext +// LDAP. The server silently drops the connection before any LDAP message +// parses, and every operation (getUser, checkPassword, ...) then fails +// identically -- indistinguishable from a wrong password. This shipped once +// (every SSH login to jump-host failed, for any account, any password) before +// being root-caused against a real deployment. Static, not a require()+exec +// of bootstrap.js, because bootstrap.js is a self-running provisioning script +// with real side effects (LDAP writes, API calls), not a library. + +const fs = require('fs'); +const path = require('path'); + +const BOOTSTRAP_PATH = path.join(__dirname, '..', 'bootstrap', 'bootstrap.js'); +const src = fs.readFileSync(BOOTSTRAP_PATH, 'utf8'); + +// Isolate the generated jump-secrets.js template (the backtick string +// assigned to `body` inside writeJumpSecrets) rather than scanning the whole +// file, so this only ever looks at what's actually written to the deployed +// config -- not, say, a comment or an unrelated ldap:// URL elsewhere. +// bootstrap.js's own source has literal backslash-t escape sequences inside +// the backtick string (they only become real tabs when the template +// literal is actually evaluated) -- so these patterns match `\t` as two +// literal characters, not a real tab byte. +const bodyMatch = /const body = `([\s\S]*?)`;\n\tfs\.writeFileSync\(JUMP_SECRETS/.exec(src); +if (!bodyMatch) { + console.error('check_jump_ldap_tls: could not locate the jump-secrets.js template in bootstrap.js — did writeJumpSecrets change shape?'); + process.exit(1); +} +const template = bodyMatch[1]; + +// Bounded by the next top-level key (sso:) rather than the ldap block's own +// closing brace, which is more robust to exactly how it's indented/escaped. +const ldapBlockMatch = /ldap:\s*\{([\s\S]*?)\\tsso:\s*\{/.exec(template); +if (!ldapBlockMatch) { + console.error('check_jump_ldap_tls: could not find the ldap: {...} block in the jump-secrets.js template.'); + process.exit(1); +} +const ldapBlock = ldapBlockMatch[1]; + +const hasTlsOptions = /tlsOptions\s*:/.test(ldapBlock); +const urlMatch = /url:\s*'([^']+)'/.exec(ldapBlock); +const url = urlMatch ? urlMatch[1] : null; + +if (!url) { + console.error('check_jump_ldap_tls: no url found in the ldap block.'); + process.exit(1); +} + +if (hasTlsOptions && !url.startsWith('ldaps://')) { + console.error( + `check_jump_ldap_tls: jump-secrets.js template sets tlsOptions but url is "${url}" (not ldaps://). ` + + 'This is the exact bug that broke every SSH login to jump-host -- see the comment above this check.' + ); + process.exit(1); +} + +console.log(`check_jump_ldap_tls: OK (url=${url}, tlsOptions=${hasTlsOptions})`);