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

Open
opened 2026-07-05 20:24:35 +00:00 by spikerj · 2 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.
Sign in to join this conversation.