fix: remove CAP_NET_BIND_SERVICE from python binary and default to port 8080#9950
fix: remove CAP_NET_BIND_SERVICE from python binary and default to port 8080#9950neel5481 wants to merge 2 commits into
Conversation
WalkthroughChange container default listening ports to 8080 (HTTP) and 8443 (TLS); update entrypoint fallbacks, remove libcap/setcap from the final image and set USER 5050, and update deployment docs and examples to the new ports. ChangesDefault ports 8080/8443 migration
Estimated code review effort🎯 3 (Moderate) | ⏱️ ~20 minutes Possibly related PRs
🚥 Pre-merge checks | ✅ 2 | ❌ 3❌ Failed checks (2 warnings, 1 inconclusive)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@pkg/docker/entrypoint.sh`:
- Around line 270-273: The TLS branch still defaults to privileged port 443
causing bind failures when the container drops CAP_NET_BIND_SERVICE; change the
TLS default port in the PGADMIN_ENABLE_TLS branch so BIND_ADDRESS uses
PGADMIN_LISTEN_PORT default 8443 (or another unprivileged port) instead of 443,
e.g. update the BIND_ADDRESS assignment that references PGADMIN_ENABLE_TLS,
PGADMIN_LISTEN_ADDRESS, and PGADMIN_LISTEN_PORT so the fallback for TLS is an
unprivileged port.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: eecb62d1-3555-4bb1-b998-a5d42b618468
📒 Files selected for processing (3)
Dockerfiledocs/en_US/container_deployment.rstpkg/docker/entrypoint.sh
asheshv
left a comment
There was a problem hiding this comment.
Thanks for the careful root-cause writeup — the kernel-refuses-to-exec-cap-tagged-binaries diagnosis is exactly right and matches what I see on OpenShift restricted-v2 SCC.
My concern with the current patch is that changing the container's default listen port from 80/443 to 8080/8443 is a user-visible breaking change for every existing docker run -p 80:80 dpage/pgadmin4 (and the TLS equivalent). That feels heavier than what we want to ship in a minor release, especially since the underlying fix is straightforward.
I'd like to propose a non-breaking alternative: keep the cap-tagged binary for the privileged-port path, but make the venv's python un-capped so OpenShift can exec it, and let the entrypoint pick the right one (and fall back to 8080/8443 automatically when we detect a restricted environment).
Concept
- At build time, make a copy of
python3.XXnamedpython3-cap, and applysetcapto the copy. Leave the original (which/venv/bin/python3points at) without capabilities. - At entrypoint time, inspect
/proc/self/statusto decide whether the kernel will honor file capabilities in this context. If yes → invoke gunicorn through the cap-tagged python (privileged ports work as today). If no → auto-default to 8080/8443 if the user hasn't setPGADMIN_LISTEN_PORT, and invoke gunicorn through the un-capped venv python.
Net result:
- Existing Docker users (
-p 80:80) — no change. - OpenShift /
--cap-drop=ALL/--security-opt=no-new-privilegesusers — container starts, listens on 8080/8443 automatically, no rebuild needed. - Users already overriding
PGADMIN_LISTEN_PORT— respected in both paths.
Why /proc/self/status (and not an exec probe)
Two fields capture every restriction mode that affects file caps:
NoNewPrivs: 1— set by--security-opt=no-new-privilegesand OpenShift'sallowPrivilegeEscalation: false. With this flag the kernel silently strips file capabilities on exec — the cap-tagged binary execs successfully but acquires no caps, sobind()to a privileged port fails later. An exec probe would miss this case; the status check catches it.CapBnd:— bounding set bitmask.--cap-drop=ALL(and restricted SCCs) zero this out. WithoutCAP_NET_BIND_SERVICE(bit 10,0x400) in the bounding set, exec'ing the cap-tagged binary returns EPERM.
Reading /proc/self/status also avoids the noisy kernel-audit entry that an exec probe generates on every container start under restricted SCC.
Proposed diff (against master, replacing the current PR changes)
Dockerfile — revert the libcap removal and the EXPOSE change; modify only the setcap line so it applies to a copy rather than the original:
@@ Dockerfile (around the existing setcap line in the final RUN step)
- setcap CAP_NET_BIND_SERVICE=+eip /usr/local/bin/python3.[0-9][0-9] && \
+ cp /usr/local/bin/python3.[0-9][0-9] /usr/local/bin/python3-cap && \
+ setcap CAP_NET_BIND_SERVICE=+eip /usr/local/bin/python3-cap && \(Keep libcap in the runtime apk add list — setcap needs it at build time. Keep EXPOSE 80 443.)
pkg/docker/entrypoint.sh — revert the port-default changes; add a detection block near the top (before any python invocation, since the existing script calls /venv/bin/python3 for check_external_config_db, validate_email, setup.py, etc.); plumb PYTHON_BIN into the final gunicorn exec:
@@ pkg/docker/entrypoint.sh (after PUID/PGID handling, before the file_env calls)
+# Decide which python interpreter to use for gunicorn.
+#
+# /usr/local/bin/python3-cap carries CAP_NET_BIND_SERVICE and is needed to
+# bind to privileged ports (default 80/443) as the non-root pgadmin user.
+# However, some platforms refuse to honor file capabilities:
+#
+# - NoNewPrivs=1 (--security-opt=no-new-privileges, OpenShift's
+# allowPrivilegeEscalation: false): the kernel silently strips file
+# capabilities on exec, so the binary runs but cannot bind <1024.
+# - CAP_NET_BIND_SERVICE missing from the bounding set (--cap-drop=ALL,
+# OpenShift restricted-v2 SCC): exec of the cap-tagged binary returns
+# EPERM.
+#
+# Detect either condition via /proc/self/status and fall back to the
+# un-capped venv python on a non-privileged port.
+PYTHON_BIN=/usr/local/bin/python3-cap
+restricted=0
+
+if grep -q '^NoNewPrivs:[[:space:]]*1' /proc/self/status; then
+ restricted=1
+fi
+
+if [ "$restricted" = "0" ]; then
+ cap_bnd=$(awk '/^CapBnd:/ { print $2 }' /proc/self/status)
+ if [ $(( 0x${cap_bnd} & 0x400 )) -eq 0 ]; then
+ restricted=1
+ fi
+fi
+
+if [ "$restricted" = "1" ]; 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
+
# Set values for config variables that can be passed using secrets@@ pkg/docker/entrypoint.sh (the two final gunicorn exec lines, keep the 80/443 defaults from master)
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(Revert the ${PGADMIN_LISTEN_PORT:-443} / ${PGADMIN_LISTEN_PORT:-80} changes back to 443 / 80 — defaults stay as they are on master.)
docs/en_US/container_deployment.rst — please revert all the example/host-port rewrites (port 80 → 8080, port 443 → 8443, -p 5050:80 → -p 8080:8080) so existing readers' muscle-memory commands keep matching. Then add a new short subsection — somewhere near the "Examples" block — covering the restricted-env case, e.g.:
Restricted Security Contexts (OpenShift, ``--cap-drop=ALL``)
************************************************************
Some platforms (notably OpenShift's ``restricted-v2`` SCC) refuse to
honor Linux file capabilities — either by zeroing the bounding set
(``--cap-drop=ALL``) or by setting ``no_new_privs``
(``--security-opt=no-new-privileges``,
``allowPrivilegeEscalation: false``). Under either condition the
non-root pgadmin user cannot bind to privileged ports.
In these environments the container automatically detects the
restriction, runs gunicorn under the non-capability interpreter, and
(if ``PGADMIN_LISTEN_PORT`` is unset) defaults to **8080** for plain
HTTP and **8443** for TLS instead of 80/443. A message is logged at
startup. This means a typical OpenShift deployment needs no special
build or 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/pgadmin4Why this is safe
- The detection is two
grep/awkcalls against/proc/self/status— no fork/exec of the cap-tagged binary, no audit-log noise. - On every existing supported environment,
NoNewPrivs=0and the bounding set includesCAP_NET_BIND_SERVICE→PYTHON_BIN=/usr/local/bin/python3-cap→ behavior is bit-identical to today's image. - The fallback only triggers in environments where the current image is already broken (it can't start at all), so there's no regression surface.
libcapis only needed at build time forsetcap; keeping it in the runtime image preserves the current image contents.
Happy to split this into smaller commits or iterate on the wording of the new docs subsection if useful. Let me know what you think.
fix: remove CAP_NET_BIND_SERVICE from python binary and default to port 8080 for non-TLS and 8443 for TLS.
Fixes #9657
Problem
The pgAdmin4 Docker container fails to start on OpenShift (and other
restricted Kubernetes platforms) with the following error:
Root Cause
The Dockerfile sets a file capability on the python3 binary:
This allows binding to port 80 as non-root user 5050. However, on
platforms enforcing restricted security policies (e.g., OpenShift
restricted-v2 SCC), the container runs with:
The Linux kernel refuses to execute ANY binary that has file capabilities
when the process cannot gain those capabilities — this is treated as a
potential privilege escalation regardless of what the binary actually
does. The result is "Operation not permitted" on every invocation of
python3, including gunicorn startup.
This is not a filesystem permission issue — the binary is readable and
executable (rwxr-xr-x). It is the kernel's capability security check
that blocks execution.
Fix
setcap CAP_NET_BIND_SERVICE=+eipcall from the Dockerfilelibcappackage dependency (no longer needed)8080 8443The
PGADMIN_LISTEN_PORTenvironment variable continues to work. Userswho need port 80 can set
PGADMIN_LISTEN_PORT=80and grantNET_BIND_SERVICE capability to the container explicitly.
Backward Compatibility
(e.g.,
docker run -p 80:8080or set PGADMIN_LISTEN_PORT=80 with--cap-add=NET_BIND_SERVICE)
docker run -p 443:8443)independent of container port — update targetPort in values
How to Validate
Build the image:
Run without any special capabilities (simulates restricted env):
Verify pgAdmin is accessible at http://localhost:8080
Verify TLS mode works:
Verify custom port override still works:
On OpenShift (restricted-v2 SCC):