Repository navigation
Gateway container runs its internet-facing server as root #1792
Description
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
- Run
docker compose up -d gateway. - 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