Repository navigation
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
joshunrau commentedon Oct 26, 2026
Still reproduces on
main(d86740e). No open PR touchesapps/gateway/Dockerfile,apps/gateway/scripts/run.shordocker-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/appwere owned by root, sonodecould neither write the file nor create a journal next to it. The bind mount,run.shand the template copy came in 5c7a9f5 (2024-12-06), after the image already ran as root. So on every existing deployment,./sqlite/gateway.dbwas created by the rootrun.shwithcpand is owned by root. The fix has to hand that file tonode, not only the directory.Recommendation: drop privileges in
run.shTake the
setprivoption, notUSER nodeplus 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 runsdocker compose pullwithoutgit pull(compose tags${RELEASE_CHANNEL}, which islatestin.env.template) gets aUSER nodeimage under a compose file with no init service. The gateway boots, passes/api/healthcheckand answers the API'sGET /api/assignmentspoll from the root-owned file. It fails only on the first write (api.router.ts:50). Themongoentrypoint in the same stack works this way: itchowns/data/dbas root, then re-execs throughgosu. With this route, therustfs-initservice in #1752 does not need to cover./sqlite.Insert this between the variable assignments and the template copy (
run.sh:5-10):cpruns asnode, so a fresh copy is owned bynode.noFSState: true(cap.ts:4), and the production server only readsdist/, so/app/diststays root-owned.docker run --user, a KubernetesrunAsUser), 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.--reset-env. It clears every variable exceptTERM, includingGATEWAY_API_KEYandGATEWAY_DATABASE_URL, andset -uthen aborts the re-exec'd script.setprivis inutil-linux, which is Essential in Debian, so it is still there after the move tonode:24-slimin API and gateway runtime images ship the full Node build image with gcc, git and turbo, tripling their size #1794. No new dependency.apps/gateway/AGENTS.mdthat the image starts as root on purpose and drops privileges inrun.sh.Verification
After this fix, both checks in the issue still print root.
docker compose execruns as the image's user, andConfig.Userstays"". Check PID 1 instead:docker compose exec gateway grep ^Uid /proc/1/statusshould print1000. Besides a fresh start with no./sqlite, cover:./sqlite/gateway.dbcreated by the current image (the upgrade path);docker run --user 1000:1000with a writable mount;docker run --user 1000:1000with 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.mdinstead of arguing it in each PR.Same pattern elsewhere
caddy:2.7-alpinesets noUSER, so thecaddyproxy and thewebandplaygroundimages also run as root.playgroundis published directly onPLAYGROUND_PORT(docker-compose.yaml:163).webcannot just addUSER, because itsCMDrewrites/srv/index.htmlwithimport-meta-envat startup (apps/web/Dockerfile:30). That belongs in a separate issue.Triage note written with Claude Code. Code references point at
main@d86740e.