From eafdc0c0b71415bb5db18cd75e18ea27c80ee77b Mon Sep 17 00:00:00 2001 From: Ashesh Vashi Date: Thu, 28 May 2026 17:12:02 +0530 Subject: [PATCH] fix(docker): make CAP_NET_BIND_SERVICE optional for restricted runtimes The container previously applied CAP_NET_BIND_SERVICE to the python interpreter so the non-root pgadmin user could bind to ports 80/443. Some platforms refuse to honor file capabilities: - --cap-drop=ALL / OpenShift restricted-v2 SCC zero the bounding set, so the kernel returns EPERM on exec of any capability-tagged binary. This makes the image fail to start (issue #9657). - --security-opt=no-new-privileges / allowPrivilegeEscalation: false causes the kernel to silently strip file capabilities on exec, so the binary runs but a subsequent bind() to <1024 still fails. Split the interpreter so neither default behavior nor restricted-runtime support has to give up the other: - Dockerfile copies python3.X to /usr/local/bin/python3-cap and applies setcap to the copy. /usr/local/bin/python3.X stays un-capped, so /venv/bin/python3 (which symlinks to it) execs cleanly under restricted SCCs. A parallel /venv/bin/python3-cap symlink keeps the venv activation working when the capped interpreter is used. - entrypoint.sh reads /proc/self/status at startup. If NoNewPrivs is set, or CAP_NET_BIND_SERVICE is missing from the bounding set, gunicorn is invoked through the un-capped python and (when PGADMIN_LISTEN_PORT is unset) the default port falls back to 8080 for plain HTTP or 8443 for TLS. A startup message records the choice. - Existing deployments with the default 80/443 mapping are unaffected: on every unrestricted runtime the bounding set still contains NET_BIND_SERVICE and gunicorn runs through the capped interpreter exactly as before. - PGADMIN_LISTEN_PORT, if set, is honored in both paths. Docs gain a "Restricted Security Contexts" subsection covering the new auto-detected fallback and the OpenShift / --cap-drop=ALL invocation. Fixes #9657 --- Dockerfile | 5 ++- docs/en_US/container_deployment.rst | 38 +++++++++++++++++++++ pkg/docker/entrypoint.sh | 51 +++++++++++++++++++++++++++-- 3 files changed, 91 insertions(+), 3 deletions(-) diff --git a/Dockerfile b/Dockerfile index c805fc48399..e88e92a8b41 100644 --- a/Dockerfile +++ b/Dockerfile @@ -204,7 +204,10 @@ RUN /venv/bin/python3 -m pip install --no-cache-dir gunicorn==23.0.0 && \ chown pgadmin:root /pgadmin4/config_distro.py && \ chmod g=u /pgadmin4/config_distro.py && \ chmod g=u /etc/passwd && \ - setcap CAP_NET_BIND_SERVICE=+eip /usr/local/bin/python3.[0-9][0-9] && \ + PYBIN="$(ls /usr/local/bin/python3.[0-9][0-9] 2>/dev/null | head -n1)" && \ + cp "$PYBIN" /usr/local/bin/python3-cap && \ + setcap CAP_NET_BIND_SERVICE=+eip /usr/local/bin/python3-cap && \ + ln -s /usr/local/bin/python3-cap /venv/bin/python3-cap && \ echo "pgadmin ALL = NOPASSWD: /usr/sbin/postfix start" > /etc/sudoers.d/postfix && \ echo "pgadminr ALL = NOPASSWD: /usr/sbin/postfix start" >> /etc/sudoers.d/postfix diff --git a/docs/en_US/container_deployment.rst b/docs/en_US/container_deployment.rst index 0b71a7ebe44..2b7f4289c91 100644 --- a/docs/en_US/container_deployment.rst +++ b/docs/en_US/container_deployment.rst @@ -320,6 +320,44 @@ Run a TLS secured container using a shared config/storage directory in -e 'PGADMIN_ENABLE_TLS=True' \ -d dpage/pgadmin4 +Restricted Security Contexts (OpenShift, ``--cap-drop=ALL``) +************************************************************ + +Some platforms refuse to honor Linux file capabilities. The two situations +the pgAdmin container handles automatically are: + +- ``--cap-drop=ALL`` (or an equivalent restricted Kubernetes SecurityContext + such as OpenShift's ``restricted-v2`` SCC), which zeros the bounding set + and removes ``CAP_NET_BIND_SERVICE``. Exec of a capability-tagged binary + then fails with ``Operation not permitted``. + +- ``--security-opt=no-new-privileges`` (or + ``allowPrivilegeEscalation: false``), which causes the kernel to silently + strip file capabilities on exec. The binary runs, but a subsequent + ``bind()`` to a port below 1024 fails with ``EPERM``. + +The container's entrypoint reads ``/proc/self/status`` at startup, detects +either condition, switches gunicorn to the non-capability python +interpreter, and (when *PGADMIN_LISTEN_PORT* is not set) defaults the +listen port to **8080** for plain HTTP and **8443** for TLS instead of +80/443. A message is logged so the choice is visible. + +In practice this means a typical OpenShift deployment requires no special +build, no setcap, and no custom configuration — only a Service / Route +that targets the chosen non-privileged port: + +.. code-block:: bash + + docker run --rm -p 8080:8080 \ + --security-opt=no-new-privileges \ + --cap-drop=ALL \ + -e 'PGADMIN_DEFAULT_EMAIL=user@domain.com' \ + -e 'PGADMIN_DEFAULT_PASSWORD=SuperSecret' \ + dpage/pgadmin4 + +If you explicitly set *PGADMIN_LISTEN_PORT*, that value is honored in both +the restricted and unrestricted paths. + Reverse Proxying **************** diff --git a/pkg/docker/entrypoint.sh b/pkg/docker/entrypoint.sh index 6a83bec4494..c40987e5f9f 100755 --- a/pkg/docker/entrypoint.sh +++ b/pkg/docker/entrypoint.sh @@ -75,6 +75,53 @@ else fi fi +# Decide which python interpreter to use for gunicorn. +# +# /venv/bin/python3-cap is a symlink to /usr/local/bin/python3-cap, a copy +# of the system python carrying CAP_NET_BIND_SERVICE. It is needed to bind +# to privileged ports (the default 80/443) as the non-root pgadmin user. +# +# Some platforms refuse to honor file capabilities, in which case execing +# the capped binary either fails outright or silently strips the caps so +# bind() to a port <1024 still returns EPERM: +# +# - NoNewPrivs=1 (--security-opt=no-new-privileges, OpenShift's +# allowPrivilegeEscalation: false): the kernel silently strips file +# capabilities on exec. +# - CAP_NET_BIND_SERVICE missing from the bounding set (--cap-drop=ALL, +# OpenShift restricted-v2 SCC): exec of the capped binary returns +# EPERM. +# +# Detect either condition via /proc/self/status. When restricted, fall +# back to the un-capped venv python and (if the user has not picked a +# port) default PGADMIN_LISTEN_PORT to 8080/8443 so the server can +# actually bind. +PYTHON_BIN=/venv/bin/python3-cap +restricted=0 + +if grep -q '^NoNewPrivs:[[:space:]]*1' /proc/self/status 2>/dev/null; then + restricted=1 +fi + +if [ "$restricted" = "0" ]; then + cap_bnd=$(awk '/^CapBnd:/ { print $2 }' /proc/self/status 2>/dev/null) + if [ -n "$cap_bnd" ] && [ "$(( 0x${cap_bnd} & 0x400 ))" -eq 0 ]; then + restricted=1 + fi +fi + +if [ "$restricted" = "1" ] || [ ! -x /venv/bin/python3-cap ]; then + PYTHON_BIN=/venv/bin/python3 + if [ -z "${PGADMIN_LISTEN_PORT}" ]; then + if [ -n "${PGADMIN_ENABLE_TLS}" ]; then + export PGADMIN_LISTEN_PORT=8443 + else + export PGADMIN_LISTEN_PORT=8080 + fi + echo "Restricted security context detected; defaulting PGADMIN_LISTEN_PORT to ${PGADMIN_LISTEN_PORT}." + fi +fi + # usage: file_env VAR [DEFAULT] ie: file_env 'XYZ_DB_PASSWORD' 'example' # (will allow for "$XYZ_DB_PASSWORD_FILE" to fill in the value of # "$XYZ_DB_PASSWORD" from a file, for Docker's secrets feature) @@ -275,7 +322,7 @@ else fi if [ -n "${PGADMIN_ENABLE_TLS}" ]; then - exec $SU_EXEC /venv/bin/gunicorn --limit-request-line "${GUNICORN_LIMIT_REQUEST_LINE:-8190}" --timeout "${TIMEOUT}" --bind "${BIND_ADDRESS}" -w 1 --threads "${GUNICORN_THREADS:-25}" --access-logfile "${GUNICORN_ACCESS_LOGFILE:--}" --keyfile /certs/server.key --certfile /certs/server.cert -c gunicorn_config.py run_pgadmin:app + exec $SU_EXEC "${PYTHON_BIN}" /venv/bin/gunicorn --limit-request-line "${GUNICORN_LIMIT_REQUEST_LINE:-8190}" --timeout "${TIMEOUT}" --bind "${BIND_ADDRESS}" -w 1 --threads "${GUNICORN_THREADS:-25}" --access-logfile "${GUNICORN_ACCESS_LOGFILE:--}" --keyfile /certs/server.key --certfile /certs/server.cert -c gunicorn_config.py run_pgadmin:app else - exec $SU_EXEC /venv/bin/gunicorn --limit-request-line "${GUNICORN_LIMIT_REQUEST_LINE:-8190}" --limit-request-fields "${GUNICORN_LIMIT_REQUEST_FIELDS:-100}" --limit-request-field_size "${GUNICORN_LIMIT_REQUEST_FIELD_SIZE:-8190}" --timeout "${TIMEOUT}" --bind "${BIND_ADDRESS}" -w 1 --threads "${GUNICORN_THREADS:-25}" --access-logfile "${GUNICORN_ACCESS_LOGFILE:--}" -c gunicorn_config.py run_pgadmin:app + exec $SU_EXEC "${PYTHON_BIN}" /venv/bin/gunicorn --limit-request-line "${GUNICORN_LIMIT_REQUEST_LINE:-8190}" --limit-request-fields "${GUNICORN_LIMIT_REQUEST_FIELDS:-100}" --limit-request-field_size "${GUNICORN_LIMIT_REQUEST_FIELD_SIZE:-8190}" --timeout "${TIMEOUT}" --bind "${BIND_ADDRESS}" -w 1 --threads "${GUNICORN_THREADS:-25}" --access-logfile "${GUNICORN_ACCESS_LOGFILE:--}" -c gunicorn_config.py run_pgadmin:app fi