Skip to content

fix: remove CAP_NET_BIND_SERVICE from python binary and default to port 8080#9950

Open
neel5481 wants to merge 2 commits into
pgadmin-org:masterfrom
neel5481:remove-cap-net-bind
Open

fix: remove CAP_NET_BIND_SERVICE from python binary and default to port 8080#9950
neel5481 wants to merge 2 commits into
pgadmin-org:masterfrom
neel5481:remove-cap-net-bind

Conversation

@neel5481
Copy link
Copy Markdown
Contributor

@neel5481 neel5481 commented May 19, 2026

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:

  /entrypoint.sh: line 198: /venv/bin/python3: Operation not permitted
  /entrypoint.sh: /venv/bin/gunicorn: /venv/bin/python3: bad interpreter: Operation not permitted

Root Cause

The Dockerfile sets a file capability on the python3 binary:

  setcap CAP_NET_BIND_SERVICE=+eip /usr/local/bin/python3.[0-9][0-9]

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:

  • allowPrivilegeEscalation: false
  • capabilities.drop: ALL

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

  • Remove the setcap CAP_NET_BIND_SERVICE=+eip call from the Dockerfile
  • Remove the libcap package dependency (no longer needed)
  • Change default listen port from 80 to 8080 (non-TLS) and 443 to 8443 (TLS)
  • Update EXPOSE directive to 8080 8443
  • Update documentation to reflect new defaults

The PGADMIN_LISTEN_PORT environment variable continues to work. Users
who need port 80 can set PGADMIN_LISTEN_PORT=80 and grant
NET_BIND_SERVICE capability to the container explicitly.

Backward Compatibility

  • Users setting PGADMIN_LISTEN_PORT explicitly: no impact
  • Users relying on default port 80: must update port mappings
    (e.g., docker run -p 80:8080 or set PGADMIN_LISTEN_PORT=80 with
    --cap-add=NET_BIND_SERVICE)
  • TLS mode: default changed from 443 to 8443 (same mapping approach:
    docker run -p 443:8443)
  • Kubernetes/Helm deployments: Service port remains configurable
    independent of container port — update targetPort in values

How to Validate

  1. Build the image:

    docker build -t pgadmin4:test .
    
  2. Run without any special capabilities (simulates restricted env):

    docker run --rm -p 8080:8080 \
        --security-opt=no-new-privileges \
        --cap-drop=ALL \
        -e 'PGADMIN_DEFAULT_EMAIL=test@test.com' \
        -e 'PGADMIN_DEFAULT_PASSWORD=test1234' \
        pgadmin4:test
    
  3. Verify pgAdmin is accessible at http://localhost:8080

  4. Verify TLS mode works:

    docker run --rm -p 8443:8443 \
        --security-opt=no-new-privileges \
        --cap-drop=ALL \
        -v /path/to/cert.pem:/certs/server.cert \
        -v /path/to/key.pem:/certs/server.key \
        -e 'PGADMIN_DEFAULT_EMAIL=test@test.com' \
        -e 'PGADMIN_DEFAULT_PASSWORD=test1234' \
        -e 'PGADMIN_ENABLE_TLS=True' \
        pgadmin4:test
    
  5. Verify custom port override still works:

    docker run --rm -p 9090:9090 \
        --security-opt=no-new-privileges \
        --cap-drop=ALL \
        -e 'PGADMIN_DEFAULT_EMAIL=test@test.com' \
        -e 'PGADMIN_DEFAULT_PASSWORD=test1234' \
        -e 'PGADMIN_LISTEN_PORT=9090' \
        pgadmin4:test
    
  6. On OpenShift (restricted-v2 SCC):

    oc new-project pgadmin-test
    oc run pgadmin --image=<registry>/pgadmin4:test \
        --env=PGADMIN_DEFAULT_EMAIL=test@test.com \
        --env=PGADMIN_DEFAULT_PASSWORD=test1234
    oc logs pgadmin  # should show gunicorn starting on port 8080
    

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 19, 2026

Walkthrough

Change 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.

Changes

Default ports 8080/8443 migration

Layer / File(s) Summary
Entrypoint BIND_ADDRESS fallbacks
pkg/docker/entrypoint.sh
Fallback PGADMIN_LISTEN_PORT used to build BIND_ADDRESS updated: 808080 (non‑TLS) and 4438443 (TLS).
Dockerfile runtime configuration
Dockerfile
Removed libcap from final apk installs and eliminated setcap usage; consolidated Postfix sudoers creation into the final RUN step; set USER 5050; changed EXPOSE from 80 443 to 8080 8443; retained VOLUME /var/lib/pgadmin.
Container deployment documentation
docs/en_US/container_deployment.rst
Rewrote PGADMIN_ENABLE_TLS/PGADMIN_LISTEN_PORT descriptions to show defaults 8080/8443; adjusted mapped-files guidance; updated Docker run examples and Nginx proxy_pass examples to target localhost:8080 and TLS mapping 443:8443.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • pgadmin-org/pgadmin4#9633: Modifies Docker image runtime user handling and entrypoint startup logic; related to container user/port startup changes.
🚥 Pre-merge checks | ✅ 2 | ❌ 3

❌ Failed checks (2 warnings, 1 inconclusive)

Check name Status Explanation Resolution
Title check ⚠️ Warning The PR title mentions port 8080, but the PR objectives state the default port should be 5050; this discrepancy between title and stated objectives is misleading. Clarify the intended default port and update the title to accurately reflect whether the change defaults to port 5050 or 8080.
Out of Scope Changes check ⚠️ Warning The raw summary shows port changes to 8080/8443, but PR objectives state the default should be 5050/443; the port values in the implementation differ from the stated objectives. Align the actual port changes in Dockerfile and entrypoint.sh with the documented objectives (5050 vs. 8080), or update objectives to match the 8080/8443 implementation.
Linked Issues check ❓ Inconclusive Linked issue #9657 requests PUID/PGID customization, but the PR changes focus on removing CAP_NET_BIND_SERVICE and changing default ports; no PUID/PGID mechanism is implemented in the code changes. Confirm whether this PR is intended to address issue #9657 or if the linked issue is incorrect; if PUID/PGID is required, add those implementation changes.
✅ Passed checks (2 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@neel5481 neel5481 marked this pull request as ready for review May 19, 2026 12:12
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

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

📥 Commits

Reviewing files that changed from the base of the PR and between 86a8b16 and c6a5202.

📒 Files selected for processing (3)
  • Dockerfile
  • docs/en_US/container_deployment.rst
  • pkg/docker/entrypoint.sh

Comment thread pkg/docker/entrypoint.sh Outdated
@neel5481 neel5481 changed the title fix: remove CAP_NET_BIND_SERVICE from python binary and default to port 5050 fix: remove CAP_NET_BIND_SERVICE from python binary and default to port 8080 May 19, 2026
Copy link
Copy Markdown
Contributor

@asheshv asheshv left a comment

Choose a reason for hiding this comment

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

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

  1. At build time, make a copy of python3.XX named python3-cap, and apply setcap to the copy. Leave the original (which /venv/bin/python3 points at) without capabilities.
  2. At entrypoint time, inspect /proc/self/status to 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 set PGADMIN_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-privileges users — 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-privileges and OpenShift's allowPrivilegeEscalation: false. With this flag the kernel silently strips file capabilities on exec — the cap-tagged binary execs successfully but acquires no caps, so bind() 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. Without CAP_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/pgadmin4

Why this is safe

  • The detection is two grep/awk calls against /proc/self/status — no fork/exec of the cap-tagged binary, no audit-log noise.
  • On every existing supported environment, NoNewPrivs=0 and the bounding set includes CAP_NET_BIND_SERVICEPYTHON_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.
  • libcap is only needed at build time for setcap; 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.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Customizing container user permissions using PUID and PGID.

2 participants