[Security][Go-live] Sandbox untrusted student code execution (C#/Python/JS) #405

Closed
opened 2026-07-05 20:24:35 +00:00 by spikerj · 3 comments
Owner

Context: Real code fix regardless of environment — highest blast radius in the review.

Problem: Free-play student submissions in C#/Python/JS run unsandboxed (in-process Roslyn Assembly.Load + Invoke; bare interpreter subprocesses) inside a container that holds RabbitMQ/Mongo/Redis/Gitea credentials. Guardrails are substring blocklists, bypassable via reflection, string concatenation, or __import__. A runaway loop cannot be cancelled (the invoke ignores the token) and unbounded output OOMs the worker. (C/C++ ARE correctly sandboxed via wasmtime — the model exists, just not applied to C#/Python/JS.)

Evidence:

  • SpikerSoft.Business/Domain/CodeExecution/Execution/RoslynCodeExecutor.cs:235-259 (Assembly.Load + entry.Invoke)
  • PythonLessonExecutor.cs:101-165 (raw CPython subprocess)
  • Blocklists: CodeExecutionWorkerService.cs:812-814, :652-686

Fix: Execute untrusted code in a real sandbox — locked-down container / gVisor / Firecracker with no secrets, no network, seccomp, memory/CPU cgroups, non-root — plus a hard wall-clock kill and output-size caps. Keep blocklists only as defense-in-depth.

Acceptance criteria:

  • No untrusted code runs in-process or with network/secret access
  • Wall-clock timeout hard-kills execution; output is bounded
  • Reflection / __import__ bypass no longer yields host or secret access

Effort: L

**Context:** Real code fix regardless of environment — highest blast radius in the review. **Problem:** Free-play student submissions in C#/Python/JS run unsandboxed (in-process Roslyn `Assembly.Load` + `Invoke`; bare interpreter subprocesses) inside a container that holds RabbitMQ/Mongo/Redis/Gitea credentials. Guardrails are substring blocklists, bypassable via reflection, string concatenation, or `__import__`. A runaway loop cannot be cancelled (the invoke ignores the token) and unbounded output OOMs the worker. (C/C++ ARE correctly sandboxed via wasmtime — the model exists, just not applied to C#/Python/JS.) **Evidence:** - `SpikerSoft.Business/Domain/CodeExecution/Execution/RoslynCodeExecutor.cs:235-259` (`Assembly.Load` + `entry.Invoke`) - `PythonLessonExecutor.cs:101-165` (raw CPython subprocess) - Blocklists: `CodeExecutionWorkerService.cs:812-814`, `:652-686` **Fix:** Execute untrusted code in a real sandbox — locked-down container / gVisor / Firecracker with no secrets, no network, seccomp, memory/CPU cgroups, non-root — plus a hard wall-clock kill and output-size caps. Keep blocklists only as defense-in-depth. **Acceptance criteria:** - No untrusted code runs in-process or with network/secret access - Wall-clock timeout hard-kills execution; output is bounded - Reflection / `__import__` bypass no longer yields host or secret access **Effort:** L
spikerj added the agentic label 2026-07-05 20:24:35 +00:00
Author
Owner

Current-state audit (2026-07-17) — partial mitigation landed since filing; core gap stands

Re-verified the per-language safety boundary against master. Confirms this ticket's analysis and records what has changed since 2026-07-05:

Partial mitigation now in place (post-dates this ticket): the coderunner stack scoped its OpenBao role to secret/services/codeexecution only (#546 / epic #543). Per spikersoft-coderunner/docker-stack.yml, a sandbox escape reading /run/secrets now exposes only this worker's own git password — not the broad bao_services_* vault. That partially satisfies the "no secret access" acceptance criterion. The container also runs non-root (Dockerfile: USER $APP_UID).

Still open (the core ask is unchanged):

  • The container remains attached to the mongo, redis, and rabbitmq overlay networks and holds its own runtime connection strings in-process, so an escape can still reach those data stores over the network even without vault access.
  • Python/JS/C# safety boundary is still a substring blocklist only (PythonForbiddenFragments = __import__, eval(, os.system, ... — evaded by getattr(__builtins__,'ev'+'al'), ().__class__.__bases__[0].__subclasses__(), exec(bytes.fromhex(...))). Python runs as a raw python3 "<script>" subprocess (no -I, no restricted env).

Confirmed already-solid (no action needed) — the model to copy:

  • C / C++: compiled to wasm32-wasi, run via wasmtime run --dir={workDir}::/ main.wasm — per-run temp dir as the only mount, no network, stdio only, wall-clock timeout with Kill(entireProcessTree:true). (ClangLessonExecutor.cs)
  • SQL: in-process DuckDB, ephemeral in-memory, no fs/network.
  • No shell-injection surface anywhere: all subprocess spawns use UseShellExecute=false + ArgumentList (argv), not shell strings.

So the remaining work is squarely: bring C#/Python/JS up to the C/C++ wasmtime (or gVisor/Firecracker) isolation model + network egress removal from the untrusted-exec container. Effort L as noted.

### Current-state audit (2026-07-17) — partial mitigation landed since filing; core gap stands Re-verified the per-language safety boundary against `master`. Confirms this ticket's analysis and records what has changed since 2026-07-05: **Partial mitigation now in place (post-dates this ticket):** the coderunner stack scoped its OpenBao role to `secret/services/codeexecution` only (#546 / epic #543). Per `spikersoft-coderunner/docker-stack.yml`, a sandbox escape reading `/run/secrets` now exposes only this worker's own git password — **not** the broad `bao_services_*` vault. That partially satisfies the "no secret access" acceptance criterion. The container also runs non-root (`Dockerfile`: `USER $APP_UID`). **Still open (the core ask is unchanged):** - The container remains attached to the `mongo`, `redis`, and `rabbitmq` overlay networks and holds its own runtime connection strings in-process, so an escape can still reach those data stores over the network even without vault access. - Python/JS/C# safety boundary is still a substring blocklist only (`PythonForbiddenFragments` = `__import__`, `eval(`, `os.system`, ... — evaded by `getattr(__builtins__,'ev'+'al')`, `().__class__.__bases__[0].__subclasses__()`, `exec(bytes.fromhex(...))`). Python runs as a raw `python3 "<script>"` subprocess (no `-I`, no restricted env). **Confirmed already-solid (no action needed) — the model to copy:** - **C / C++**: compiled to `wasm32-wasi`, run via `wasmtime run --dir={workDir}::/ main.wasm` — per-run temp dir as the only mount, no network, stdio only, wall-clock timeout with `Kill(entireProcessTree:true)`. (`ClangLessonExecutor.cs`) - **SQL**: in-process DuckDB, ephemeral in-memory, no fs/network. - No shell-injection surface anywhere: all subprocess spawns use `UseShellExecute=false` + `ArgumentList` (argv), not shell strings. So the remaining work is squarely: bring **C#/Python/JS** up to the C/C++ wasmtime (or gVisor/Firecracker) isolation model + network egress removal from the untrusted-exec container. Effort **L** as noted.
Author
Owner

Board-sweep status (2026-07-22): core sandbox NOT built (07-17 audit stands); only blast-radius mitigation landed (#546 scoped codeexec AppRole). REMAINING: the actual wasmtime-style sandbox for C#/Python/JS.

Board-sweep status (2026-07-22): core sandbox NOT built (07-17 audit stands); only blast-radius mitigation landed (#546 scoped codeexec AppRole). REMAINING: the actual wasmtime-style sandbox for C#/Python/JS.
Author
Owner

Migrated to spikerj/spikersoft-backend#565 and spikerj/spikersoft-infrastructure#179 as part of the umbrella-tracker breakup.

This ticket needed changes in more than one repo, so it became one issue per repo:

Verified 2026-08-07 against spikersoft-backend@98102023 and spikersoft-infrastructure@86d03ff6, and live: docker service ls → spikersoft-coderunner_spikersoft-coderunner 4/4 running, so this path executes untrusted submissions in production today.

Status: core sandbox not built. RoslynCodeExecutor.cs still does Assembly.Load(...) at :115/:235 with .Invoke(...) at :137/:259 — untrusted C# still runs in-process; PythonLessonExecutor.cs still spawns a raw ProcessStartInfo on PythonExecutable with no -I and no restricted env; the only Python guardrail is still the PythonForbiddenFragments substring blocklist. (Paths moved with the #566 decomposition — the code is now under SpikerSoft.Business.CodeExecution/.) wasmtime remains confined to the Clang/C++ executors.

Blast-radius mitigations that did land are recorded on the children: the container runs non-root and its Bao AppRole is scoped to secret/services/codeexecution only. The still-open network half — the container is attached to the mongo, redis and rabbitmq overlays — is the infrastructure child.

Closing here. Work now lives in the repos that hold the fix, so fixes #<M> in a PR will auto-close it on merge. The umbrella tracker keeps cross-repo epics only.

— Opus 5 Agent

Migrated to **spikerj/spikersoft-backend#565** and **spikerj/spikersoft-infrastructure#179** as part of the umbrella-tracker breakup. This ticket needed changes in more than one repo, so it became one issue per repo: - Backend (sandbox the C#/Python/JS executors — the C/C++ wasmtime model): spikerj/spikersoft-backend#565 - Infrastructure (remove the coderunner container's mongo/redis/rabbitmq network reach): spikerj/spikersoft-infrastructure#179 Verified 2026-08-07 against `spikersoft-backend@98102023` and `spikersoft-infrastructure@86d03ff6`, and live: `docker service ls` → `spikersoft-coderunner_spikersoft-coderunner` **4/4 running**, so this path executes untrusted submissions in production today. Status: **core sandbox not built.** `RoslynCodeExecutor.cs` still does `Assembly.Load(...)` at :115/:235 with `.Invoke(...)` at :137/:259 — untrusted C# still runs in-process; `PythonLessonExecutor.cs` still spawns a raw `ProcessStartInfo` on `PythonExecutable` with no `-I` and no restricted env; the only Python guardrail is still the `PythonForbiddenFragments` substring blocklist. (Paths moved with the #566 decomposition — the code is now under `SpikerSoft.Business.CodeExecution/`.) wasmtime remains confined to the Clang/C++ executors. Blast-radius mitigations that *did* land are recorded on the children: the container runs non-root and its Bao AppRole is scoped to `secret/services/codeexecution` only. The still-open network half — the container is attached to the `mongo`, `redis` and `rabbitmq` overlays — is the infrastructure child. Closing here. Work now lives in the repos that hold the fix, so `fixes #<M>` in a PR will auto-close it on merge. The umbrella tracker keeps cross-repo epics only. — Opus 5 Agent
Sign in to join this conversation.