Skip to content

fix(security): run frontend nginx container as non-root user - #683

Merged
birme merged 2 commits into
mainfrom
security/648-nginx-non-root
Sep 17, 2026
Merged

birme merged 2 commits into
mainfrom
security/648-nginx-non-root

Conversation

@birme

@birme birme commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Add a USER nginx directive to the frontend Dockerfile so the nginx master, workers, and entrypoint no longer run as root, reducing the blast radius of a container escape or web server vulnerability.
  • chown -R nginx:nginx every path written at runtime — /app (npm run build output), /usr/share/nginx/html (built assets copied by the entrypoint), /etc/nginx/conf.d (entrypoint sed -i), plus nginx's cache, log, and pid dirs — so the unprivileged user can still serve traffic.
  • No privileged-port change needed: nginx/nginx.conf already listens on 8080 (default PORT), so the process runs fully rootless without binding port 80.
  • Change is scoped to the Dockerfile only; no application code touched.

Test plan

  • Tests pass (npm test)
  • TypeScript compiles (npm run typecheck)
  • Lint clean (npm run lint)
  • docker build . succeeds
  • Container starts and serves the SPA with id reporting a non-root uid (nginx, uid 101)
  • nginx binds 8080 and writes its pid/cache/logs without permission errors

Closes #648

🤖 Generated with Claude Code

Co-Authored-By: Claude Sonnet 4.6 noreply@anthropic.com

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

@birme birme left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

code-reviewer (daily-backlog-pr Phase 3):

Code Review

Verdict: LGTM

Summary: A well-scoped, correctly reasoned security hardening change. The USER nginx directive works with the existing non-privileged port config, and every runtime-writable path is chowned to the nginx user before the switch. No application code is touched.


Blocking

None.

Non-root port binding verified: nginx/nginx.conf listens on 8080 (>1024) and scripts/entrypoint.sh defaults PORT to 3000 (also >1024), so no cap_net_bind_service is required. The pid file (/var/run/nginx.pid from the stock main config), /var/cache/nginx, and /var/log/nginx are all covered by the chown -R. The three entrypoint write targets (/app build output, /usr/share/nginx/html copy target, /etc/nginx/conf.d sed) are also chowned. npm hygiene is intact — the Dockerfile still uses npm ci and npm run build, no yarn reintroduced.


Warnings

  • Dockerfile:35 (USER nginx) — Runtime footgun: entrypoint.sh rewrites the listen port from the PORT env var (sed -i "s/listen\s*8080;/listen $LISTENPORT;/"). If an operator sets PORT to a privileged value (<1024), the now-unprivileged nginx will fail to bind with permission denied. The default is safe, but consider documenting that PORT must stay >=1024 (or add a NET_BIND_SERVICE capability note) since previously root could bind anything.

Suggestions

  • The stock official-image main /etc/nginx/nginx.conf still contains a top-level user nginx; directive. When the master starts as a non-root user, nginx logs a benign warning: the "user" directive makes sense only if the master process runs with super-user privileges. Non-fatal — no action required, just expect it in logs.
  • Dockerfile:26-34chown -R nginx:nginx /app recursively rewrites ownership of the full node_modules tree, which adds a large layer. Optional: scope the app chown to the build output dir if image size/build time matters. Not a correctness issue.

No WebRTC/SDP, state, or API-surface code is affected by this change.

@birme birme left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Code Review

Verdict: LGTM

Summary: A clean, well-scoped hardening change that runs the frontend nginx container as the unprivileged nginx user (uid 101). The port choice and runtime-write path ownership are handled correctly, so the container remains fully functional rootless. No Blocking items.


Blocking

None.


Warnings

None.

The core security concerns for this change all check out:

  • Port bindingnginx/nginx.conf:2 listens on 8080 and scripts/entrypoint.sh:3 defaults PORT to 3000. Both are >1024, so a non-root user can bind them without CAP_NET_BIND_SERVICE or net.ipv4.ip_unprivileged_port_start tweaks. No capability adjustment needed. Confirmed no privileged (<=1024) port is ever bound.
  • Runtime write paths — every path the entrypoint mutates at runtime is covered by Dockerfile:28-35: /app (npm run build -> /app/dist), /usr/share/nginx/html (cp -r of built assets, entrypoint.sh:14), /etc/nginx/conf.d (sed -i on default.conf, entrypoint.sh:4), plus nginx cache/log dirs and /var/run + /run for the pid file. The chown list matches the actual write set.
  • User existsnginx:nginx (uid 101) ships with the official nginx:1.29.0 base image (Dockerfile:1), so USER nginx on line 38 resolves without a preceding useradd.
  • npm hygienenpm ci (Dockerfile:15) is used, no yarn reintroduced, no package.json/lockfile touched.

Suggestions

  • Dockerfile:38 — The stock /etc/nginx/nginx.conf still contains user nginx; and pid /var/run/nginx.pid;. When the master starts as non-root, nginx emits a the "user" directive makes sense only if the master process runs with super-user privileges warning to stderr on every boot. It is harmless (the worker already runs as the launching user) but noisy. Optional: strip the user line from the main config to silence it. Not required for correctness.
  • Dockerfile:28-35 — Consider adding a USER nginx smoke check to CI (docker run --rm <img> id asserting uid=101 and a container-start test asserting nginx binds the port without EACCES), mirroring the PR's manual test-plan checkboxes. This locks the hardening in against future regressions.
  • Dockerfile:5 — Pre-existing (outside this diff): EXPOSE $MANAGER_URL is not a valid EXPOSE argument (EXPOSE expects a port, not a URL). Not introduced by this PR; flagging only for awareness.

No application code, WebRTC/SDP, state-management, or test files are touched by this PR, so categories 1, 2, 4, 5, and 7 are not applicable.

@birme

birme commented Sep 17, 2026

Copy link
Copy Markdown
Contributor Author

Code Review

Verdict: LGTM

Summary: Adds USER nginx plus a targeted chown -R of every runtime-written path so the container no longer runs nginx as root; path set verified against entrypoint/nginx behavior.

Reviewed against the Open Intercom code-reviewer rubric (TypeScript correctness, error handling, architecture, testing, security, WebRTC/SDP, npm-migration hygiene). No Blocking items; CI green. Approving and squash-merging via daily-backlog-pr Phase 3.

@birme
birme merged commit 50264e9 into main Sep 17, 2026
4 checks passed
@birme
birme deleted the security/648-nginx-non-root branch September 17, 2026 06:46
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.

Security: Frontend Dockerfile runs nginx as root — add non-root USER directive

1 participant