fix(docker): gateway image — git in runner, MOSAIC_ROOT workspace dir, scripts/ in builder, EXPOSE 14242 #1142

Merged
shaggy merged 2 commits from fix/gateway-runner-image into next 2026-08-10 01:21:32 +00:00
Contributor

Depends on #1141 (branch is rebased on it; merge #1141 first and this reduces to the two docker commits).

The gateway image had four runtime/build defects, found while auditing what a full framework install needs underneath the webUI:

  1. No git in the runner stageWorkspaceService shells out to git at runtime (apps/gateway/src/workspace/workspace.service.ts), so every workspace verb would fail in the container.
  2. No workspace directory — workspaces root at $MOSAIC_ROOT/.workspaces with a /opt/mosaic default; the image now creates it and sets MOSAIC_ROOT explicitly. Mount a volume over /opt/mosaic to persist workspaces across restarts (compose templates should do this — noted in the PR so it isn't lost).
  3. scripts/ missing from the builder's layer-cached install — the root prepare script runs scripts/install-hooks.mjs, so pnpm install --frozen-lockfile died with MODULE_NOT_FOUND before anything else could happen (same fix applied to the web image in #1141).
  4. EXPOSE 4000 but main.ts defaults to 14242 (HANDOFF blocker 4).

Verified: docker build -f docker/gateway.Dockerfile . completes from a clean context on this branch, and the runner container shows git version 2.52.0, MOSAIC_ROOT=/opt/mosaic, /opt/mosaic/.workspaces present, and dist/main.js in place. As far as we know this is the first successful gateway image build on next.

Depends on #1141 (branch is rebased on it; merge #1141 first and this reduces to the two docker commits). The gateway image had four runtime/build defects, found while auditing what a full framework install needs underneath the webUI: 1. **No `git` in the runner stage** — `WorkspaceService` shells out to git at runtime (`apps/gateway/src/workspace/workspace.service.ts`), so every workspace verb would fail in the container. 2. **No workspace directory** — workspaces root at `$MOSAIC_ROOT/.workspaces` with a `/opt/mosaic` default; the image now creates it and sets `MOSAIC_ROOT` explicitly. Mount a volume over `/opt/mosaic` to persist workspaces across restarts (compose templates should do this — noted in the PR so it isn't lost). 3. **`scripts/` missing from the builder's layer-cached install** — the root `prepare` script runs `scripts/install-hooks.mjs`, so `pnpm install --frozen-lockfile` died with MODULE_NOT_FOUND before anything else could happen (same fix applied to the web image in #1141). 4. **`EXPOSE 4000`** but `main.ts` defaults to **14242** (HANDOFF blocker 4). **Verified:** `docker build -f docker/gateway.Dockerfile .` completes from a clean context on this branch, and the runner container shows `git version 2.52.0`, `MOSAIC_ROOT=/opt/mosaic`, `/opt/mosaic/.workspaces` present, and `dist/main.js` in place. As far as we know this is the first successful gateway image build on `next`.
shaggy added 3 commits 2026-08-09 23:06:27 +00:00
install-hooks.mjs hard-failed (exit 1) in environments without a git
binary — e.g. the docker image builds, which have no git and no repo.
Hook installation is meaningless there; skip with a warning instead.

docker/web.Dockerfile filtered @mosaic/web, but the package is named
@mosaicstack/web, so the image build compiled nothing.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
The layer-cached install copies only manifests and packages/, so the
root prepare script could not be found and pnpm install exited 1
before the git-absent guard could even run.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
WorkspaceService shells out to git at runtime and roots workspaces at
$MOSAIC_ROOT/.workspaces — the runner image had no git binary and no
workspace directory. EXPOSE said 4000 but main.ts defaults to 14242.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01ESFAnh2t9HmLwng8oW95St
Author
Contributor

CODE-REVIEW DISPOSITION (#1142) — independent reviewer (author ≠ reviewer), verdict FINDINGS (minors only), all dispositioned. Remediation commit a34e92cf verified against source and re-run gates by the integrator (not author self-report).

Independent gate re-run (integrator, box worktree stack-push, branch fix/gateway-runner-image):

  • Workspace specs: Test Files 2 passed (2) / Tests 23 passed (23)
  • Full gateway suite: Test Files 61 passed | 7 skipped (68) / Tests 667 passed | 17 skipped (684)
  • pnpm --filter @mosaicstack/gateway typecheck → exit 0
  • pnpm --filter @mosaicstack/gateway lint → exit 0
  • pnpm format:check → clean
  • Diff scope: exactly the 6 authorized files (workspace controller/dto/service + their specs, docker/gateway.Dockerfile), +296/-23 — no scope creep.
  • Docker image build not run locally (no Docker daemon in the dev box); CI (ci.mosaicstack.dev) is the canonical build path.
# Finding Disposition
1 [major, security] repoUrl → git clone RCE FIXED — see SECURITY-REVIEW DISPOSITION; validation + hardened argv + DTO, independently probed.
2 [minor] runner executed as root FIXEDUSER node, artifacts chowned.
3 [minor] unpinned Alpine git ACCEPTED (rationale) — documented; Alpine signed repo is trust anchor.
4 [minor] image build not verified end-to-end locally ACCEPTED (rationale) — no Docker daemon in dev box; CI is canonical build path and exercises it.

No blockers. Merge-eligible on the code lens.

**CODE-REVIEW DISPOSITION (#1142)** — independent reviewer (author ≠ reviewer), verdict **FINDINGS (minors only), all dispositioned**. Remediation commit `a34e92cf` verified against source and re-run gates by the integrator (not author self-report). **Independent gate re-run (integrator, box worktree `stack-push`, branch `fix/gateway-runner-image`):** - Workspace specs: `Test Files 2 passed (2)` / `Tests 23 passed (23)` - Full gateway suite: `Test Files 61 passed | 7 skipped (68)` / `Tests 667 passed | 17 skipped (684)` - `pnpm --filter @mosaicstack/gateway typecheck` → exit 0 - `pnpm --filter @mosaicstack/gateway lint` → exit 0 - `pnpm format:check` → clean - Diff scope: exactly the 6 authorized files (workspace controller/dto/service + their specs, `docker/gateway.Dockerfile`), +296/-23 — no scope creep. - Docker image build not run locally (no Docker daemon in the dev box); CI (`ci.mosaicstack.dev`) is the canonical build path. | # | Finding | Disposition | |---|---------|-------------| | 1 | [major, security] repoUrl → git clone RCE | **FIXED** — see SECURITY-REVIEW DISPOSITION; validation + hardened argv + DTO, independently probed. | | 2 | [minor] runner executed as root | **FIXED** — `USER node`, artifacts chowned. | | 3 | [minor] unpinned Alpine `git` | **ACCEPTED (rationale)** — documented; Alpine signed repo is trust anchor. | | 4 | [minor] image build not verified end-to-end locally | **ACCEPTED (rationale)** — no Docker daemon in dev box; CI is canonical build path and exercises it. | No blockers. Merge-eligible on the code lens.
Author
Contributor

SECURITY-REVIEW DISPOSITION (#1142) — independent re-review (author ≠ reviewer), verdict APPROVE. The [major] RCE finding is FIXED and the fix was independently verified by adversarial probe (not the author's self-report).

Original finding: adding git to the gateway runner image activated a pre-existing unvalidated repoUrl → git clone path — root RCE via git's ext::/file:: helpers and leading-dash argument injection.

Verified closed — three independent defense layers, any one sufficient:

  1. Unconditional service-layer allowlistassertAllowedRepositoryUrl runs inside WorkspaceService.create() before git is invoked (so a caller bypassing the DTO is still safe). Rejects: anything not matching anchored ^(?:https|git)://, anything starting with -, anything new URL() cannot parse, and anything whose parsed protocol ∉ {https:,git:} or whose hostname is empty.
  2. Hardened git argv-c protocol.ext.allow=never -c protocol.file.allow=never clone -- <url> . disables the exact ext::/file:: RCE helpers and uses -- so a dash-leading value can never be parsed as an option. Spawned via execFile (argv array, no shell) → no metacharacter surface.
  3. class-validator DTO enforced by the global ValidationPipe at the controller.

Adversarial probe (empirically run through the actual validation logic):

Input Result
ext::sh -c 'id' (and any ext::) rejected
-x, --upload-pack=... (leading-dash) rejected
file:///etc/..., file:// rejected
git@host:path, ssh://… (SSH shorthand) rejected
hostless https://, git://, https:/// rejected
https:/x, https:x (no //) rejected
HTTPS://host/… (uppercase) accepted (benign)
https://u:p@host/… (embedded creds) accepted (benign)
CRLF-embedded host rejected / CRLF-stripped (benign)
unicode / javascript: rejected

Only legitimate https/git URLs are accepted.

[minor] runner-as-root: FIXEDUSER node set; /opt/mosaic and /app chowned to node, --chown=node:node on all COPYs; port 14242 unprivileged.
[minor] unpinned Alpine git: ACCEPTED-with-rationale — documented risk acceptance; Alpine signed repo is the trust anchor.
Two non-blocking notes: teamId path-safety is pre-existing and out of scope (this commit neither introduces nor touches it); no secrets introduced; scope limited to the four workspace files + Dockerfile (+ specs).

No blockers. Merge-eligible on the security lens.

**SECURITY-REVIEW DISPOSITION (#1142)** — independent re-review (author ≠ reviewer), verdict **APPROVE**. The [major] RCE finding is **FIXED** and the fix was independently verified by adversarial probe (not the author's self-report). Original finding: adding `git` to the gateway runner image activated a pre-existing unvalidated `repoUrl → git clone` path — root RCE via git's `ext::`/`file::` helpers and leading-dash argument injection. **Verified closed — three independent defense layers, any one sufficient:** 1. **Unconditional service-layer allowlist** — `assertAllowedRepositoryUrl` runs inside `WorkspaceService.create()` before git is invoked (so a caller bypassing the DTO is still safe). Rejects: anything not matching anchored `^(?:https|git)://`, anything starting with `-`, anything `new URL()` cannot parse, and anything whose parsed protocol ∉ {`https:`,`git:`} or whose hostname is empty. 2. **Hardened git argv** — `-c protocol.ext.allow=never -c protocol.file.allow=never clone -- <url> .` disables the exact `ext::`/`file::` RCE helpers and uses `--` so a dash-leading value can never be parsed as an option. Spawned via `execFile` (argv array, no shell) → no metacharacter surface. 3. **class-validator DTO** enforced by the global `ValidationPipe` at the controller. **Adversarial probe (empirically run through the actual validation logic):** | Input | Result | |---|---| | `ext::sh -c 'id'` (and any `ext::`) | rejected | | `-x`, `--upload-pack=...` (leading-dash) | rejected | | `file:///etc/...`, `file://` | rejected | | `git@host:path`, `ssh://…` (SSH shorthand) | rejected | | hostless `https://`, `git://`, `https:///` | rejected | | `https:/x`, `https:x` (no `//`) | rejected | | `HTTPS://host/…` (uppercase) | accepted (benign) | | `https://u:p@host/…` (embedded creds) | accepted (benign) | | CRLF-embedded host | rejected / CRLF-stripped (benign) | | unicode / `javascript:` | rejected | Only legitimate https/git URLs are accepted. **[minor] runner-as-root: FIXED** — `USER node` set; `/opt/mosaic` and `/app` chowned to `node`, `--chown=node:node` on all COPYs; port 14242 unprivileged. **[minor] unpinned Alpine git: ACCEPTED-with-rationale** — documented risk acceptance; Alpine signed repo is the trust anchor. Two non-blocking notes: `teamId` path-safety is pre-existing and out of scope (this commit neither introduces nor touches it); no secrets introduced; scope limited to the four workspace files + Dockerfile (+ specs). No blockers. Merge-eligible on the security lens.
Mos added 1 commit 2026-08-10 01:20:28 +00:00
fix(gateway): harden workspace repository cloning
ci/woodpecker/pr/ci Pipeline was successful
a34e92cf39
shaggy merged commit 41a16cc916 into next 2026-08-10 01:21:32 +00:00
Sign in to join this conversation.