Skip to content

[#1093] Hand the root password to the Docker bootstrap tools in a file - #1099

Open
vharseko wants to merge 3 commits into
OpenIdentityPlatform:masterfrom
vharseko:issue-1093-setup-password-file
Open

vharseko wants to merge 3 commits into
OpenIdentityPlatform:masterfrom
vharseko:issue-1093-setup-password-file

Conversation

@vharseko

@vharseko vharseko commented Sep 24, 2026 •

Copy link
Copy Markdown
Member

Fixes #1093

Problem

bootstrap/setup.sh loads the optional LDIFs under /opt/opendj/bootstrap/schema/ and /opt/opendj/bootstrap/data/ with ldapmodify -w $ROOT_PASSWORD unquoted, so a password with whitespace, or with *, ? or [ that matches files in /opt/opendj, is split or expanded and the bind fails (49 Invalid Credentials). setup, dsconfig and import-ldif quote the password, but all of them still take it on the command line, readable from /proc/<pid>/cmdline (and ps on the Docker host) while they run.

What the skipped load leads to depends on the image. The released 5.1.2 image reports itself healthy without the data, as the issue shows. On master, since #898, the exit status of setup.sh is that of its last command, so a failed bind on the last data LDIF keeps the container from ever turning healthy, while a failed bind on a schema LDIF, or on an earlier data LDIF, is skipped silently.

Found while reproducing: the dsconfig set-password-policy-prop call that allows pre-encoded passwords for the data LDIFs names no host or port, so it goes to 4444 whatever ADMIN_PORT the server listens on. With a non-default ADMIN_PORT it fails (Unable to connect to the server at "localhost" on port 4444, not checked), and every entry carrying a pre-encoded password is refused (19 Constraint Violation) while the container reports itself healthy.

Change

  • setup.sh:

    • The password is written once to a mktemp file (mode 0600, removed by an EXIT trap). Every tool reads it from there: setup --rootUserPasswordFile, dsconfig / import-ldif --bindPasswordFile, ldapmodify -j. The tools read the first line of the file as is, so spaces and glob characters survive. $file is quoted, in both ldapmodify loops and in the copy of bootstrap/config/schema.
    • The tools read only the first line of the file, and a CR ends it as an LF does, so a ROOT_PASSWORD with a line break would be set up cut short without a word. setup.sh refuses it instead, and the README says so.
    • The file is opendj-setup-password.$ADMIN_PORT.XXXXXX on the tmpfs of /dev/shm where there is one, and in /tmp otherwise, so that a container killed during the bootstrap, before the EXIT trap runs, does not keep the password in its writable layer.
    • dsconfig set-password-policy-prop gets -h localhost -p $ADMIN_PORT, like create-backend above it.
    • The base entry template was created twice by mktemp when SAMPLE_DATA is unset, which left an empty file in /tmp; it is created once, and the EXIT trap also removes it when an import fails.
    • The exit status contract with run.sh (Report the OpenDJ container healthy only once its bootstrap has succeeded #898) is unchanged.
  • run.sh removes /dev/shm/opendj-setup-password."$ADMIN_PORT".* and /tmp/opendj-setup-password.* before anything else. That is what a killed bootstrap leaves behind: in /tmp of a restarted Docker container, and in /dev/shm on Kubernetes, where it is shared by the pod and outlives a restart of the container. The containers of a pod share one network namespace, hence differ in ADMIN_PORT, so the removal leaves alone the file of another container that is still bootstrapping. Containers in distinct network namespaces that share /dev/shm through --ipc=host on the same ADMIN_PORT are not told apart.

  • replicate.sh ([#1084] Keep the root password out of the log and off the command line when a Docker container joins replication #1094) had the same flaw in its removal: its password file is now opendj-replicate.$ADMIN_PORT.XXXXXX, and run.sh removes only /dev/shm/opendj-replicate."$ADMIN_PORT".*.

  • CI: no step mounted bootstrap LDIFs until now. Docker test bootstrap LDIFs is added to both build-docker and build-docker-alpine. It first starts the image twice, with ab\ncd and with ab\rcd as the root password, and waits for the refusal in each log. Then it starts the image with ROOT_PASSWORD='p@ss b*', ADMIN_PORT=5444, a schema file of bootstrap/config/schema, a schema LDIF and a data LDIF, all with a space in their names, the data LDIF holding a user with an {SSHA} password. The container shares /dev/shm with a holder container, as the containers of a pod do. It checks that

    • the password file is on /dev/shm, with the admin port in its name, while the bootstrap runs,
    • the container turns healthy,
    • the attribute types of the schema file and of the schema LDIF are in cn=schema,
    • the user of the data LDIF binds with its pre-encoded password,
    • the root password is not in the container log,
    • no file under /tmp or /dev/shm holds the root password once the bootstrap is over, apart from the perf data of the HEALTHCHECK's own ldapsearch JVM (Docker image: the HEALTHCHECK binds with the initial ROOT_PASSWORD, so the container turns unhealthy once the root password is changed #1092),
    • setup.sh in the image passes no password on a command line (-w, --bindPassword, --rootUserPassword, also in the --option=value form); grep must exit with 1, so a file it cannot read fails the step,
    • password files planted in /tmp and in /dev/shm are gone after a docker restart, while one planted in /dev/shm for another admin port is kept.

    Docker test replication ([#1084] Keep the root password out of the log and off the command line when a Docker container joins replication #1094) plants its leftover password file under the admin port of the replica, and a second one under another port, which has to be kept.

    The container is started without --rm, so that the ERR trap still prints its log when the container exits early.

Verification

The step was run locally as a script taken from build.yml, against images built from release 5.1.2 with the scripts swapped in:

image result
Debian, scripts of master fails: bind 49 on both LDIFs, never healthy
Alpine, scripts of master fails: bind 49, plus Unable to connect … port 4444
Debian, scripts of this branch passes, in both this step and Docker test replication
Alpine, scripts of this branch passes
Debian, this branch without the EXIT trap fails: the root password is left in /dev/shm/opendj-setup-password.*
Debian, this branch with -f $file in the schema loop fails: …/schema/10 not found, bootstrapProbe not in cn=schema
Debian, this branch without the removal in run.sh fails: the planted /tmp/opendj-setup-password.* is still there after the restart
Debian, without the line-break check fails: no refusal in the log of test_bootstrap_lf
Debian, the check without "$cr" fails: no refusal in the log of test_bootstrap_cr
Debian, the password file always in /tmp fails: /dev/shm/opendj-setup-password.5444.* never shows up
Debian, the /dev/shm removal in run.sh without the port fails: run.sh removed /dev/shm/opendj-setup-password.4444.…, the password file of another container
Debian, the removal in run.sh without its /dev/shm half fails: /dev/shm/opendj-setup-password.5444.… is still there after a restart
Debian, basename -- $file fails: 99 config.ldif is copied as …/schema/99, and bootstrapConfigProbe is not in cn=schema
Debian, the opendj-replicate.* removal without the port (replication step) fails: run.sh of test_replica removed the password file of another container

The same check was also run on setup.sh with -w "$ROOT_PASSWORD", --bindPassword="$ROOT_PASSWORD" and --rootUserPassword "$ROOT_PASSWORD" put back, and it finds each of them.

The command-line check finds 0 lines in setup.sh of this branch and 7 in that of master, with GNU grep, with the busybox grep of the Alpine image and with that of the Debian one. With docker top sampled during the bootstrap, the setup process of master shows --rootUserPassword p@ss b*; the one of this branch shows only the file path.

Out of scope

@vharseko vharseko added bug docker CI security Security fixes / CodeQL code-scanning alerts labels Sep 24, 2026
@vharseko vharseko added the data-loss Data integrity / loss of entries label Sep 24, 2026
@vharseko
vharseko force-pushed the issue-1093-setup-password-file branch from 439e185 to d38890a Compare September 25, 2026 06:56
@vharseko

Copy link
Copy Markdown
Member Author

@maximthomas, rebased onto the current master (2ba918a) as d38890a, with no change to the fix itself.

The merge of #1091 made this PR conflict in build.yml: both add a step after Docker test custom password in each docker job. Both steps are kept, Docker test arbitrary uid first and then Docker test bootstrap LDIFs. The commit is unchanged apart from that context. The "Out of scope" note in the description about that conflict is updated.

@maximthomas maximthomas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: The fix is at the source of #1093, and the new CI step pins it.

  • Both LDIF loops bind with -j "$PASSWORD_FILE" and quote -f "$file" (setup.sh:94, :114) instead of the unquoted -w $ROOT_PASSWORD -f $file, so the password reaches ldapmodify as a single value.
  • set-password-policy-prop now names -h localhost -p $ADMIN_PORT (setup.sh:103-104), which fixes the silent 19 Constraint Violation on pre-encoded passwords behind a non-default admin port.
  • Docker test bootstrap LDIFs turns both bugs red: ROOT_PASSWORD='p@ss b*' makes the bootstrapProbe grep fail, and ADMIN_PORT=5444 makes the {SSHA} bind of uid=pre fail.

issue (non-blocking): A container killed mid-bootstrap leaves the root password in clear text in /tmp.

opendj-packages/opendj-docker/bootstrap/setup.sh:28-30

The password file sits in the container's writable layer, and only setup.sh's EXIT trap removes it. The trap does not run when the container is stopped or killed during the bootstrap (setup, dsconfig, import-ldif take tens of seconds): run.sh is PID 1 with no trap, so SIGTERM is dropped and the SIGKILL at the end of the grace period takes setup.sh down with it. After that, run.sh:44 goes straight to start-ds once data/config exists. Nothing sweeps /tmp, so the file (mode 0600) stays for the container's life and shows in docker diff, docker cp and docker export. The description says this is done "the same way #1094 does it", but #1094 has since moved to mktemp -p /dev/shm … || mktemp. The review there also asks for a fixed prefix and a sweep at start, because /dev/shm is pod-wide on Kubernetes. The password is also in the container's Config.Env, which limits the extra exposure.

# setup.sh
PASSWORD_FILE=$(mktemp -p /dev/shm opendj-setup.XXXXXXXXXX 2>/dev/null || mktemp /tmp/opendj-setup.XXXXXXXXXX) || exit 1
# run.sh, before the data/config check at :44
rm -f /dev/shm/opendj-setup.* /tmp/opendj-setup.*

Or: whatever #1094 settles for replicate.sh, applied to both scripts.


suggestion (non-blocking): Drop --rm from the new step's docker run.

.github/workflows/build.yml:574, :762

The ERR trap's docker logs test_bootstrap only helps while the container still exists. Suppose setup rejects --rootUserPasswordFile, or setup.sh fails before data/config exists. Then run.sh:105 execs start-ds --nodetach, which exits, and --rm removes the container. The step goes red after the 5-minute timeout, and the trap prints only "No such container". That is the one road this PR changes. The pattern is copied from the steps above; runners are ephemeral, so the stopped container docker kill leaves behind costs nothing.

          docker run -it -d --memory="512m" -e ADD_BASE_ENTRY="--addBaseEntry" -e ROOT_PASSWORD="$ROOT_PASSWORD" -e ADMIN_PORT=5444 ... --name=test_bootstrap ...

suggestion (non-blocking): The "password not in the container log" check cannot fail for any change this PR makes.

.github/workflows/build.yml:579, :767

Neither version of setup.sh or run.sh prints the password. setup runs --no-prompt, and dsconfig has no --displayCommand. So reverting any --rootUserPasswordFile / --bindPasswordFile / -j "$PASSWORD_FILE" to the quoted --bindPassword "$ROOT_PASSWORD" keeps the whole step green, and so does deleting the EXIT trap at setup.sh:29. The claim that the password is off the command line rests on the local docker top sample. The HEALTHCHECK needs $BOOTSTRAP_COMPLETE, which run.sh:93 touches only after setup.sh has exited. So once the container is healthy, the trap has run and can be pinned:

          docker exec test_bootstrap sh -c '! grep -rqF -e "$ROOT_PASSWORD" /tmp'
          docker exec test_bootstrap sh -c '! grep -nE -e "--(bind|rootUser)Password[ =]|-w " /opt/opendj/bootstrap/setup.sh'

Pin: the first line goes red when the trap is deleted. The second is a static guard against the argv revert, which has no cheap runtime observable.


suggestion (non-blocking): The schema LDIF name has no space, so the -f "$file" quoting in the schema loop is unpinned.

.github/workflows/build.yml:568, :756, opendj-packages/opendj-docker/bootstrap/setup.sh:94

With 10-schema.ldif, reverting setup.sh:94 to -f $file passes the same single word, and grep bootstrapProbe stays green. Only the data loop is pinned, through 10 probe.ldif.

          printf "dn: cn=schema\n..." > "$BOOTSTRAP_DIR/schema/10 schema.ldif"

Pin: with the space, the unquoted mutant splits the path, bootstrapProbe never loads, and the cn=schema grep goes red.

@vharseko

Copy link
Copy Markdown
Member Author

@maximthomas, thank you. All four points are taken in 99e627b.

Password file left by a killed bootstrap. Confirmed: run.sh is PID 1 with no handler, so docker stop drops SIGTERM, and the SIGKILL takes setup.sh down before its EXIT trap. The file is now opendj-setup-password.XXXXXX on /dev/shm, falling back to /tmp, with exactly six X because busybox mktemp replaces only the last six. run.sh removes /dev/shm/opendj-setup-password.* and /tmp/opendj-setup-password.* before anything else, since on Kubernetes /dev/shm is shared by the pod and outlives a restart of the container. The prefix is not opendj-setup. because setup writes its own log as /tmp/opendj-setup-<n>.log, and a broader glob would come close to it. The comment in setup.sh now names the Kubernetes case. mktemp -p /dev/shm <template> works in both images. The description no longer says "the same way #1094 does it". The round 3 review of #1094 asks for the same removal for replicate.sh, at the same place in run.sh.

The removal is pinned: once the container is healthy, the step creates /tmp/opendj-setup-password.XXXXXX as the image user, restarts the container, waits for healthy again, and checks that the file is gone. The move to /dev/shm itself is not pinned. A plain Docker container gets a fresh /dev/shm on every restart, so no runtime check can tell the two places apart.

--rm. Dropped in both jobs, so the ERR trap still prints the log of a container that exited early.

Log check cannot fail. Right. Two checks are added, with two changes from the snippet:

The log check stays as a guard against a future set -x or echo. The description no longer counts it as covering this change.

Schema LDIF name. It is 10 schema.ldif now. With -f $file put back in the schema loop, ldapmodify gets …/schema/10 and treats schema.ldif as a trailing file argument, the first file is not found, and the cn=schema check fails.

Local run of the step, taken from build.yml, against images built from release 5.1.2 with the scripts swapped in:

image result
Debian, this branch passes
Alpine, this branch passes
Debian, without the EXIT trap fails: The root password is left in /dev/shm/opendj-setup-password.*
Debian, -f $file in the schema loop fails at the cn=schema check
Debian, without the removal in run.sh fails: the planted file is still there after the restart

@vharseko
vharseko force-pushed the issue-1093-setup-password-file branch from 99e627b to 1cac805 Compare September 25, 2026 09:46
@vharseko

Copy link
Copy Markdown
Member Author

@maximthomas, rebased onto the current master (67eb249) as 1cac805, with no change to the fix itself.

The merge of #1094 made both commits conflict:

  • build.yml (first commit): both add a step after Docker test arbitrary uid in each docker job. Both steps are kept, Docker test replication first and then Docker test bootstrap LDIFs.
  • run.sh (second commit): both add a removal of a password file a killed script leaves in /dev/shm, at the same place. Both are kept, the one for replicate.sh first and then the one for setup.sh. They remove files of different names (opendj-replicate.* and opendj-setup-password.*), so neither affects the other.

The commits are unchanged apart from that context. The "Out of scope" notes in the description about #1094 and the conflicts are updated.

@maximthomas maximthomas left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

praise: Both round-1 points are fixed at the source, and the new checks turn red on the mutants they target.

  • The password file is on the tmpfs of /dev/shm (setup.sh:33-34), and run.sh:39 sweeps what a killed bootstrap leaves. Both steps drop --rm (build.yml:571, :772), so the ERR trap's docker logs still has a container to read.
  • The command-line check accepts only exit status 1 from grep (build.yml:586-587), so an unreadable setup.sh fails the step instead of passing it. The leftover grep (:582) goes red when the EXIT trap is removed, as the PR's table shows.
  • 10 schema.ldif and 10 probe.ldif pin the quoted -f "$file" in both LDIF loops.

issue (non-blocking): A ROOT_PASSWORD with a CR or LF is cut at the line break for every tool that reads the password file, while the HEALTHCHECK and replicate.sh still get the whole value.

opendj-packages/opendj-docker/bootstrap/setup.sh:36, :61, opendj-packages/opendj-docker/Dockerfile:82, opendj-packages/opendj-docker/bootstrap/replicate.sh:39-57

FileBasedArgument reads the file with BufferedReader.readLine(), and readLine() ends the line at \r as well as \n. A probe on JDK 26 gives "abc\r\n" -> abc and "ab\rc\n" -> ab. Take -e ROOT_PASSWORD="$(cat crlf-file)" (value abc\r) or a Kubernetes secret with a trailing newline (value abc\n). The root password becomes abc, but the HEALTHCHECK's --bindPassword "${ROOT_PASSWORD:-password}" and replicate.sh bind with the whole value, so the container never turns healthy. At the base every tool got the value on argv, and the same container turned healthy. Failing loudly beats the silent mismatch. setup.sh runs under sh (run.sh:71), so this is POSIX:

cr=$(printf '\r')
nl='
'
case $ROOT_PASSWORD in
  *"$cr"*|*"$nl"*)
    echo "ROOT_PASSWORD must not contain a line break: the tools read only the first line of the password file" >&2
    exit 1 ;;
esac

issue (non-blocking): run.sh removes every opendj-setup-password.* in /dev/shm, including the live file of another container of this image that shares /dev/shm and is still bootstrapping.

opendj-packages/opendj-docker/run.sh:37-39, opendj-packages/opendj-docker/bootstrap/setup.sh:30-34

The comment's "nothing is bootstrapping yet at this point" is true only inside one container, and setup.sh:30-32 itself says that /dev/shm is shared by the containers of a pod. Take two OpenDJ containers in one pod (distinct ports), or --ipc=host, or --ipc=container:X. Both run as uid 1001, so the 1777 sticky bit does not help. If B starts while A's setup runs, B's rm deletes A's file. A's next dsconfig create-backend --bindPasswordFile then fails with ERR_FILEARG_NO_SUCH_FILE, and || exit 1 ends A's bootstrap. After a restart, A comes up healthy without its userRoot backend. Plain Docker and the in-repo OpenShift template (one container per pod) do not reach this. Key the file to the container: ADMIN_PORT is an image ENV, and it must differ within a pod, whose containers share one network namespace. --ipc=host across network namespaces with equal ports stays unkeyed, so the comment should say that:

# setup.sh:33-34
PASSWORD_FILE=$(mktemp -p /dev/shm "opendj-setup-password.$ADMIN_PORT.XXXXXX" 2>/dev/null \
  || mktemp "/tmp/opendj-setup-password.$ADMIN_PORT.XXXXXX") || exit 1

# run.sh:37-39
# A container killed during its bootstrap leaves the password file of setup.sh behind, as the
# EXIT trap that removes it does not run then. /dev/shm may be shared with the other containers
# of a pod, so only the file of this container is removed: the containers of a pod share one
# network namespace, hence differ in ADMIN_PORT
rm -f /dev/shm/opendj-setup-password."$ADMIN_PORT".* /tmp/opendj-setup-password."$ADMIN_PORT".*

The restart check then plants mktemp /tmp/opendj-setup-password.5444.XXXXXX (build.yml:589, :790).


issue (non-blocking): The bootstrap/config/schema copy loop still splits a file name that contains a space: $(basename -- $file) is unquoted.

opendj-packages/opendj-docker/bootstrap/setup.sh:46-49

This is the same whitespace class the PR fixes in the two ldapmodify loops, on a line the PR does not touch (it is the same at the base). For bootstrap/config/schema/10 foo.ldif, basename -- /opt/.../10 foo.ldif takes foo.ldif as a suffix and prints 10. cp then writes template/config/schema/10, and the schema loader takes only *.ldif, so the schema is silently not loaded while the container turns healthy.

    target_file="/opt/opendj/template/config/schema/$(basename -- "$file")"

suggestion (non-blocking): BASE_TEMPLATE is removed only when the bootstrap succeeds, so a failed makeldif or import-ldif leaves it in /tmp.

opendj-packages/opendj-docker/bootstrap/setup.sh:76-88, :35

The || exit 1 at :79, :81 and :86 skips the rm at :88. The EXIT trap removes only the password file, and run.sh:39 matches only opendj-setup-password.*. The file stays in the writable layer. It holds no secret, but with SAMPLE_DATA it holds the generated entries. This was already so at the base; the PR fixes the other leak, the double mktemp.

trap 'rm -f "$PASSWORD_FILE" ${BASE_TEMPLATE:+"$BASE_TEMPLATE"}' EXIT

suggestion (non-blocking): No check pins that the password file is created in /dev/shm: a mutant that always uses /tmp keeps both docker jobs green.

opendj-packages/opendj-docker/bootstrap/setup.sh:33-34, .github/workflows/build.yml:582, :589, :783, :790

Replace :33-34 with PASSWORD_FILE=$(mktemp /tmp/opendj-setup-password.XXXXXX). The file is then back in the writable layer, which is what the commit title says the change prevents. The leftover grep still passes, because it runs after the healthy wait, when the EXIT trap has already removed the file from either directory. The restart check still passes, because it plants its own /tmp file. The file lives for the whole bootstrap (tens of seconds), so polling it from a second container is deterministic. Run this before the step's own container starts or after it is killed:

docker run -d --memory="512m" --name test_bootstrap_shm localhost:5000/${GITHUB_REPOSITORY,,}:${{ env.release_version }}
timeout 2m bash -c 'until docker exec test_bootstrap_shm sh -c "ls /dev/shm/opendj-setup-password.*" >/dev/null 2>&1; do sleep 1; done'
docker exec test_bootstrap_shm sh -c '! ls /tmp/opendj-setup-password.* 2>/dev/null'
docker kill test_bootstrap_shm

Pin: the always-/tmp mutant never creates the /dev/shm file, so the loop times out and the step goes red. On build-docker-alpine it also shows that busybox mktemp -p works.


suggestion (non-blocking): The /dev/shm half of run.sh's leftover removal has no pin: the restart check plants its file only under /tmp.

opendj-packages/opendj-docker/run.sh:39, .github/workflows/build.yml:589-592, :790-793

If the /dev/shm glob is dropped from run.sh:39, both jobs stay green, yet that glob is the Kubernetes road the commit message gives as the reason for the removal. A /dev/shm plant in the current step would pass either way, because a plain container gets a fresh /dev/shm on every start. The fixture needs a /dev/shm that outlives the restart, which a holder container gives:

docker run -d --ipc=shareable --name shm_holder --entrypoint sleep localhost:5000/${GITHUB_REPOSITORY,,}:${{ env.release_version }} 600
# the step's docker run gets --ipc=container:shm_holder; then, next to the /tmp plant:
shm_left=$(docker exec test_bootstrap mktemp -p /dev/shm opendj-setup-password.XXXXXX)
docker restart test_bootstrap
timeout 5m bash -c 'until docker inspect --format="{{json .State.Health.Status}}" test_bootstrap | grep -q \"healthy\"; do sleep 10; done'
docker exec test_bootstrap test ! -e "$shm_left" || { echo "::error::$shm_left is still there after a restart"; false; }
docker kill test_bootstrap shm_holder

Pin: the dropped-glob mutant leaves $shm_left in the holder's /dev/shm, and the step goes red. With the $ADMIN_PORT key above, plant opendj-setup-password.5444.XXXXXX.

…strap tools in a file

setup.sh passed ROOT_PASSWORD unquoted to ldapmodify, so a password with
whitespace or a glob character failed the bind and the schema and data
LDIFs under /opt/opendj/bootstrap were skipped. Every tool of the script
now reads the password from a file of its own, which also keeps it off
the command line of the processes the bootstrap runs.

The dsconfig call that allows pre-encoded passwords named no port and
reached 4444 whatever ADMIN_PORT was, so the entries of the data LDIFs
carrying one were refused while the container reported itself healthy.
It now goes to ADMIN_PORT.

The base entry template is no longer created twice, which left an empty
file in /tmp.

CI runs the image with schema and data LDIFs, a password with a space
and a glob character, and a non-default admin port.

Fixes OpenIdentityPlatform#1093
…ff the writable layer, and pin it in CI

A setup.sh killed before its EXIT trap left the password file in /tmp, in
the writable layer of the container. It now goes to the tmpfs of /dev/shm
where there is one, and run.sh removes what a killed bootstrap left behind,
which on Kubernetes also covers /dev/shm, shared by the pod.

The bootstrap step no longer removes its container on exit, so its log
stays available to the ERR trap. It names the schema LDIF with a space, and
checks that no file under /tmp or /dev/shm holds the root password, that
setup.sh passes no password on a command line, and that a planted password
file is gone after a restart.
… and remove only the password files of this container

The tools read only the first line of a password file, and a CR ends it
as an LF does, so a ROOT_PASSWORD with a line break was set up cut short.
setup.sh now refuses it.

/dev/shm is shared by the containers of a Kubernetes pod, so run.sh
removed the live password file of another container still bootstrapping.
The password files of setup.sh and replicate.sh now carry ADMIN_PORT in
their name, which the containers of a pod cannot share, and run.sh removes
only those of its own port there.

The copy of bootstrap/config/schema quotes the file name, and the EXIT
trap of setup.sh also removes the base entry template a failed import
leaves in /tmp.

The bootstrap step checks that both a CR and an LF in the root password
are refused, that the password file is on /dev/shm while the bootstrap
runs, and that a schema file of bootstrap/config/schema with a space in
its name is loaded. The container shares /dev/shm with a holder container,
as in a pod, so the removal after a restart is checked there too, together
with a file of another admin port that must be kept. The replication step
checks the latter for replicate.sh.
@vharseko
vharseko force-pushed the issue-1093-setup-password-file branch from 1cac805 to d2e63ac Compare September 25, 2026 12:34
@vharseko

Copy link
Copy Markdown
Member Author

@maximthomas, all six points are taken in d2e63ac, and the branch is rebased onto the current master (d30ff78) with no conflict.

Line break in ROOT_PASSWORD. Taken, with one correction: replicate.sh does not get the whole value. Since #1094 it reads the password from a file as well (--bindPasswordFile1/2, --adminPasswordFile), so it sees the same first line as setup. The HEALTHCHECK is the only place that gets the whole value, and once #1102 (#1092) is merged it no longer binds with the root password at all. Either way, ab\rc would have become the root password ab without a word, so setup.sh now refuses a CR or an LF in ROOT_PASSWORD with your check, before the password file is written. The README says so next to ROOT_PASSWORD. The step starts two containers, one with ab\ncd and one with ab\rcd, and waits for the refusal in each log, so dropping either half of the case fails it.

Removal in run.sh of another container's file. Taken as proposed: the file is opendj-setup-password.$ADMIN_PORT.XXXXXX, and run.sh removes only /dev/shm/opendj-setup-password."$ADMIN_PORT".*. The comment names the --ipc=host case the key does not cover. /tmp belongs to the container alone, so a file left there is removed whatever its name. The opendj-replicate.* removal #1094 added right above has the same flaw, so replicate.sh gets the same key and run.sh the same scoped removal. In the replication step the replica already shares the master's /dev/shm: the planted file is now opendj-replicate.4444.killed, and a second one, opendj-replicate.5444.other, has to still be there once the replica is healthy.

basename -- $file. Quoted. The step mounts bootstrap/config/schema with 99 config.ldif, and checks that its bootstrapConfigProbe is in cn=schema.

BASE_TEMPLATE. The EXIT trap removes it too, as proposed. BASE_TEMPLATE is set empty before the trap, so a value in the environment of the container is never removed. This one is not pinned: nothing makes makeldif or import-ldif fail on demand, and the file holds no secret.

No pin for /dev/shm. Taken, in the step's own container rather than a second one: right after docker run, the step waits up to two minutes for /dev/shm/opendj-setup-password.5444.* to show up. The file lives for the whole bootstrap, so the wait is deterministic, and the name also pins the ADMIN_PORT key.

No pin for the /dev/shm removal. Taken with your holder: test_bootstrap_shm runs sleep with --ipc=shareable, and test_bootstrap runs with --ipc=container:test_bootstrap_shm. Before the restart the step plants /tmp/opendj-setup-password.5444.* and /dev/shm/opendj-setup-password.5444.*, which must be gone after it, and /dev/shm/opendj-setup-password.4444.*, the file of another container, which must still be there.

Local run of the steps, taken from build.yml, against images built from release 5.1.2 with the scripts swapped in:

image result
Debian, this branch passes, in both steps
Alpine, this branch passes
Debian, without the line-break check fails: no refusal in the log of test_bootstrap_lf
Debian, the check without "$cr" fails: no refusal in the log of test_bootstrap_cr
Debian, the password file always in /tmp fails: /dev/shm/opendj-setup-password.5444.* never shows up
Debian, the /dev/shm removal in run.sh without the port fails: run.sh removed /dev/shm/opendj-setup-password.4444.…, the password file of another container
Debian, the removal in run.sh without its /dev/shm half fails: /dev/shm/opendj-setup-password.5444.… is still there after a restart
Debian, basename -- $file fails: 99 config.ldif is copied as …/schema/99, and bootstrapConfigProbe is not in cn=schema
Debian, without the EXIT trap fails: The root password is left in /dev/shm/opendj-setup-password.5444.…
Debian, the opendj-replicate.* removal without the port (replication step) fails: run.sh of test_replica removed the password file of another container

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug CI data-loss Data integrity / loss of entries docker security Security fixes / CodeQL code-scanning alerts

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Docker image: setup.sh passes ROOT_PASSWORD unquoted to ldapmodify, so bootstrap LDIFs are silently skipped for a password with spaces

2 participants