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