Skip to content

Gateway container runs its internet-facing server as root #1792

Open

Description

@joshunrau

The gateway image runs its Node server as root. Its runner stage has no USER instruction, so run.sh and node ./dist/main.js run as uid 0. The api image drops to USER node. The gateway is the one service that faces the public internet directly: compose publishes it on GATEWAY_PORT, and anonymous participants open its links. Its own logs in #1203 show scanners probing it. Any code-execution bug in the gateway or its dependencies (Express, the SSR renderer, Prisma's engine, Cap.js) would therefore run as root in the container. As root it can write anywhere in the image, including /app/dist, which it serves. It also owns the bind-mounted ./sqlite on the host and gets root's default capabilities. USER node was removed in 7c40422 ("run gateway docker as root", Feb 2024), most likely because a root-owned ./sqlite bind mount was not writable by node. That was a workaround, and nothing in apps/gateway/AGENTS.md records it as a decision.

Where

apps/gateway/Dockerfile:27-37:

# RUN SERVER
FROM base AS runner
COPY apps/gateway/scripts/run.sh run.sh
COPY --from=installer /app/sqlite/gateway.db /app/gateway.tmpl.db
# ...
RUN echo '{ "type": "module" }' > package.json
RUN echo '{ "type": "module" }' > /runtime/package.json
CMD [ "./run.sh" ]

Compare apps/api/Dockerfile:32, which has USER node. The gateway runs as root because docker-compose.yaml bind-mounts ./sqlite:/app/sqlite, and Docker creates that directory as root:root on a fresh Linux host.

Reproduce

  1. Run docker compose up -d gateway.
  2. Run docker compose exec gateway id.

Actual: uid=0(root) gid=0(root) groups=0(root)…. The published image confirms it. docker buildx imagetools inspect ghcr.io/douglasneuroinformatics/open-data-capture-gateway:latest --format '{{json (index .Image "linux/amd64").Config.User}}' prints "", meaning root, while the same command for open-data-capture-api:latest prints "node".
Expected: the gateway server runs as an unprivileged user, as the api does.

Tests

No unit or Playwright test runs the production image. The e2e suite starts the gateway's dev:test directly. Verify the fix in the built image. docker compose exec gateway id should report node, a remote assignment should still complete end to end, which proves the server can still write /app/sqlite/gateway.db, and this should hold both with a pre-existing root-owned ./sqlite and with no ./sqlite at all.

Suggested fix

Keep the entrypoint as root only long enough to fix ownership, then drop privileges. In run.sh, run chown -R node:node /app/sqlite and the template copy, then exec setpriv --reuid=node --regid=node --init-groups node ./dist/main.js. setpriv ships in the Debian base. Alternatively, add a one-shot init service in compose that chowns ./sqlite, and set USER node in the image. Port 80 stays bindable without root, as it already is for the api.

Activity

  1. added
    BugType: existing behavior is wrong
    Difficulty: MediumStraightforward change, about a day or less
    Area: InfrastructureDeployment, Docker, CI, logging, CLIs, build and test tooling
    SecurityAuthentication, authorization, data exposure or a vulnerable dependency
    on Oct 7, 2026
  2. joshunrau commented on Oct 26, 2026

    @joshunrau
    CollaboratorAuthor

    Still reproduces on main (d86740e). No open PR touches apps/gateway/Dockerfile, apps/gateway/scripts/run.sh or docker-compose.yaml.

    Correction: the bind mount came later

    7c40422 (2024-02-29) predates ./sqlite. At that point the database was baked into the image (COPY --from=installer /app/gateway.db /app/gateway.db). Both the file and /app were owned by root, so node could neither write the file nor create a journal next to it. The bind mount, run.sh and the template copy came in 5c7a9f5 (2024-12-06), after the image already ran as root. So on every existing deployment, ./sqlite/gateway.db was created by the root run.sh with cp and is owned by root. The fix has to hand that file to node, not only the directory.

    Recommendation: drop privileges in run.sh

    Take the setpriv option, not USER node plus a compose init service. The fix then lives in the image and covers every way the image is run. With the compose option, an admin who runs docker compose pull without git pull (compose tags ${RELEASE_CHANNEL}, which is latest in .env.template) gets a USER node image under a compose file with no init service. The gateway boots, passes /api/healthcheck and answers the API's GET /api/assignments poll from the root-owned file. It fails only on the first write (api.router.ts:50). The mongo entrypoint in the same stack works this way: it chowns /data/db as root, then re-execs through gosu. With this route, the rustfs-init service in #1752 does not need to cover ./sqlite.

    Insert this between the variable assignments and the template copy (run.sh:5-10):

    DATABASE_DIR=$(dirname "$DATABASE_FILE")
    
    if [ "$(id -u)" = '0' ]; then
      mkdir -p "$DATABASE_DIR"
      find "$DATABASE_DIR" \! -user node -exec chown node:node '{}' +
      exec setpriv --reuid=node --regid=node --init-groups --no-new-privs "$0" "$@"
    fi
    • After the re-exec, cp runs as node, so a fresh copy is owned by node.
    • The database directory is the only path the server writes. Cap.js runs with noFSState: true (cap.ts:4), and the production server only reads dist/, so /app/dist stays root-owned.
    • When the script starts as non-root (docker run --user, a Kubernetes runAsUser), check that the directory and any existing file are writable. If not, exit with a message naming the path and the uid. Otherwise a root-owned file again fails only on the first write.
    • Do not add --reset-env. It clears every variable except TERM, including GATEWAY_API_KEY and GATEWAY_DATABASE_URL, and set -u then aborts the re-exec'd script.
    • setpriv is in util-linux, which is Essential in Debian, so it is still there after the move to node:24-slim in API and gateway runtime images ship the full Node build image with gcc, git and turbo, tripling their size #1794. No new dependency.
    • Record in apps/gateway/AGENTS.md that the image starts as root on purpose and drops privileges in run.sh.

    Verification

    After this fix, both checks in the issue still print root. docker compose exec runs as the image's user, and Config.User stays "". Check PID 1 instead: docker compose exec gateway grep ^Uid /proc/1/status should print 1000. Besides a fresh start with no ./sqlite, cover:

    • a ./sqlite/gateway.db created by the current image (the upgrade path);
    • docker run --user 1000:1000 with a writable mount;
    • docker run --user 1000:1000 with a root-owned mount, which should exit with the message.

    The root test rule exempts only scripts/, and #1752 and #1794 have the same gap. Record one exception for image-only changes in .agents/docs/architecture/testing-strategy.md instead of arguing it in each PR.

    Same pattern elsewhere

    caddy:2.7-alpine sets no USER, so the caddy proxy and the web and playground images also run as root. playground is published directly on PLAYGROUND_PORT (docker-compose.yaml:163). web cannot just add USER, because its CMD rewrites /srv/index.html with import-meta-env at startup (apps/web/Dockerfile:30). That belongs in a separate issue.

    Triage note written with Claude Code. Code references point at main @ d86740e.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Area: InfrastructureDeployment, Docker, CI, logging, CLIs, build and test toolingBugType: existing behavior is wrongDifficulty: MediumStraightforward change, about a day or lessPriority: MediumReal value; schedule normallySecurityAuthentication, authorization, data exposure or a vulnerable dependency

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions