From e9727596dc549892f8480be8d2dfec8e94c9faf3 Mon Sep 17 00:00:00 2001 From: Joe Licata Date: Fri, 13 Mar 2026 15:57:47 +0000 Subject: [PATCH] fix: make sandbox uid configurable across all languages --- .env.example | 2 ++ README.md | 2 +- docs/ARCHITECTURE.md | 2 +- docs/CONFIGURATION.md | 5 +++- docs/SECURITY.md | 2 +- src/config/languages.py | 48 ++++++++++++++++++++++-------- tests/unit/test_language_config.py | 37 ++++++++++++++++++----- tests/unit/test_sandbox_manager.py | 5 ++-- 8 files changed, 76 insertions(+), 27 deletions(-) diff --git a/.env.example b/.env.example index b792bc2..329c6af 100644 --- a/.env.example +++ b/.env.example @@ -27,7 +27,9 @@ MINIO_SECRET_KEY=minioadmin # ── Sandbox Pool (Python REPL) ───────────────────────────────── # SANDBOX_POOL_ENABLED=true # SANDBOX_POOL_PY=5 # Number of pre-warmed Python REPLs +# SANDBOX_POOL_PARALLEL_BATCH=1 # Safer on busy multi-tenant hosts # REPL_ENABLED=true +# SANDBOX_UID=1002 # Shared host UID for all sandbox languages # ── Port ────────────────────────────────────────────────────── # PORT=8000 # External port the API is reachable on diff --git a/README.md b/README.md index b43e7ca..02e38d2 100644 --- a/README.md +++ b/README.md @@ -136,7 +136,7 @@ For comprehensive testing details, see [TESTING.md](docs/TESTING.md). - Seccomp syscall filtering restricts available system calls - Cgroup-based resource limits prevent CPU, memory, and process exhaustion - rlimits restrict file sizes, open file descriptors, etc. -- Code runs as non-root user (uid 1001) +- Code runs as a shared non-root sandbox user (default uid `1001`, configurable with `SANDBOX_UID`) - Read-only bind mounts for language runtimes and libraries - API key authentication protects all endpoints - Input validation prevents injection attacks diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 188e67c..cc1b7e7 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -363,7 +363,7 @@ settings.max_memory_mb │ 4. Seccomp Filtering : Restricted syscalls │ │ 5. Cgroup Limits : Memory, CPU, pids │ │ 6. rlimits : File size, open files, stack size │ -│ 7. Non-root Execution : Code runs as uid 1001 (codeuser) │ +│ 7. Non-root Execution : Code runs as shared uid 1001 by default │ │ │ └─────────────────────────────────────────────────────────────────────────────┘ ``` diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 2e00b5b..63581c3 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -147,7 +147,8 @@ nsjail is used for secure code execution in isolated sandboxes. **Security Notes:** - nsjail provides PID, mount, and network namespace isolation -- Code runs as non-root user (uid 1001) inside the sandbox +- Code runs as a shared non-root UID inside the sandbox +- All sandbox languages default to UID `1001`, and can be moved with `SANDBOX_UID` - The API container requires `SYS_ADMIN` capability for nsjail namespace creation ### Resource Limits @@ -184,6 +185,8 @@ Pre-warmed Python REPL sandboxes reduce execution latency by eliminating interpr | `SANDBOX_POOL_ENABLED` | `true` | Enable Python REPL pool | | `SANDBOX_POOL_WARMUP_ON_STARTUP` | `true` | Pre-warm Python REPLs at startup | | `SANDBOX_POOL_PY` | `5` | Number of pre-warmed Python REPLs | +| `SANDBOX_POOL_PARALLEL_BATCH` | `5` | Number of warmup sandboxes started concurrently | +| `SANDBOX_UID` | `1001` | Shared host UID used by all sandbox languages | **Note:** Sandboxes are destroyed immediately after execution. The pool is automatically replenished in the background. Non-Python languages do not use pooling. diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 27d0725..09f6e9b 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -106,7 +106,7 @@ Code is analyzed for potentially dangerous patterns: - **Seccomp filtering**: Restricts available system calls - **Cgroup limits**: Memory, CPU, and PID limits enforced - **rlimits**: File size, open files, and stack size restricted -- **Non-root execution**: Code runs as uid 1001 (codeuser) +- **Non-root execution**: Code runs as a shared non-root sandbox UID (default `1001`, configurable with `SANDBOX_UID`) **Note**: The API container requires `SYS_ADMIN` capability for nsjail to create namespaces and cgroups. No Docker socket is mounted. diff --git a/src/config/languages.py b/src/config/languages.py index e5546f4..683bc55 100644 --- a/src/config/languages.py +++ b/src/config/languages.py @@ -4,6 +4,7 @@ execution settings (commands, resource multipliers, user IDs, etc.). """ +import os from dataclasses import dataclass, field from typing import Dict, Optional @@ -26,12 +27,33 @@ class LanguageConfig: environment: Dict[str, str] = field(default_factory=dict) +def _get_sandbox_user_id(default: int = 1001) -> int: + """Read the shared sandbox UID override from the environment.""" + raw_value = os.getenv("SANDBOX_UID") + if raw_value in (None, ""): + return default + assert raw_value is not None + + try: + user_id = int(raw_value) + except ValueError as exc: + raise ValueError(f"SANDBOX_UID must be an integer, got: {raw_value}") from exc + + if user_id < 0: + raise ValueError(f"SANDBOX_UID must be >= 0, got: {user_id}") + + return user_id + + +SANDBOX_USER_ID = _get_sandbox_user_id() + + # All 13 supported languages with complete configuration LANGUAGES: Dict[str, LanguageConfig] = { "py": LanguageConfig( code="py", name="Python", - user_id=999, + user_id=SANDBOX_USER_ID, file_extension="py", execution_command="python3 -", uses_stdin=True, @@ -41,7 +63,7 @@ class LanguageConfig: "js": LanguageConfig( code="js", name="JavaScript", - user_id=1001, + user_id=SANDBOX_USER_ID, file_extension="js", execution_command="node", uses_stdin=True, @@ -51,7 +73,7 @@ class LanguageConfig: "ts": LanguageConfig( code="ts", name="TypeScript", - user_id=1001, + user_id=SANDBOX_USER_ID, file_extension="ts", execution_command="tsc code.ts --outDir . --module commonjs " "--target ES2019 && node code.js", @@ -62,7 +84,7 @@ class LanguageConfig: "go": LanguageConfig( code="go", name="Go", - user_id=1001, + user_id=SANDBOX_USER_ID, file_extension="go", execution_command="go build -o code code.go && ./code", uses_stdin=False, @@ -72,7 +94,7 @@ class LanguageConfig: "java": LanguageConfig( code="java", name="Java", - user_id=999, + user_id=SANDBOX_USER_ID, file_extension="java", execution_command="javac Code.java && java Code", uses_stdin=False, @@ -82,7 +104,7 @@ class LanguageConfig: "c": LanguageConfig( code="c", name="C", - user_id=1001, + user_id=SANDBOX_USER_ID, file_extension="c", execution_command="gcc -o code code.c && ./code", uses_stdin=False, @@ -92,7 +114,7 @@ class LanguageConfig: "cpp": LanguageConfig( code="cpp", name="C++", - user_id=1001, + user_id=SANDBOX_USER_ID, file_extension="cpp", execution_command="g++ -o code code.cpp && ./code", uses_stdin=False, @@ -102,7 +124,7 @@ class LanguageConfig: "php": LanguageConfig( code="php", name="PHP", - user_id=1001, + user_id=SANDBOX_USER_ID, file_extension="php", execution_command="php", uses_stdin=True, @@ -112,7 +134,7 @@ class LanguageConfig: "rs": LanguageConfig( code="rs", name="Rust", - user_id=1001, + user_id=SANDBOX_USER_ID, file_extension="rs", execution_command="rustc code.rs -o code && ./code", uses_stdin=False, @@ -122,7 +144,7 @@ class LanguageConfig: "r": LanguageConfig( code="r", name="R", - user_id=1001, + user_id=SANDBOX_USER_ID, file_extension="r", execution_command="Rscript code.r", uses_stdin=False, @@ -132,7 +154,7 @@ class LanguageConfig: "f90": LanguageConfig( code="f90", name="Fortran", - user_id=1001, + user_id=SANDBOX_USER_ID, file_extension="f90", execution_command="gfortran -o code code.f90 && ./code", uses_stdin=False, @@ -142,7 +164,7 @@ class LanguageConfig: "d": LanguageConfig( code="d", name="D", - user_id=0, + user_id=SANDBOX_USER_ID, file_extension="d", execution_command="ldc2 code.d -of=code && ./code", uses_stdin=False, @@ -152,7 +174,7 @@ class LanguageConfig: "bash": LanguageConfig( code="bash", name="Bash", - user_id=1001, + user_id=SANDBOX_USER_ID, file_extension="sh", execution_command="bash", uses_stdin=True, diff --git a/tests/unit/test_language_config.py b/tests/unit/test_language_config.py index 7ca25e1..0b9fa3f 100644 --- a/tests/unit/test_language_config.py +++ b/tests/unit/test_language_config.py @@ -4,11 +4,12 @@ functions, and correctness of all 13 supported languages. """ +import importlib import pytest +import src.config.languages as languages_module from src.config.languages import ( LANGUAGES, - LanguageConfig, get_language, get_supported_languages, is_supported_language, @@ -52,7 +53,7 @@ def test_language_code_present(self, code): def test_language_config_is_frozen_dataclass(self, code): """Each language config must be a frozen LanguageConfig dataclass.""" lang = LANGUAGES[code] - assert isinstance(lang, LanguageConfig) + assert isinstance(lang, languages_module.LanguageConfig) with pytest.raises(AttributeError): lang.code = "modified" @@ -104,7 +105,7 @@ class TestPythonLanguage: def test_python_user_id(self): lang = get_language("py") - assert lang.user_id == 999 + assert lang.user_id == 1001 def test_python_uses_stdin(self): assert uses_stdin("py") is True @@ -112,6 +113,16 @@ def test_python_uses_stdin(self): def test_python_extension(self): assert get_file_extension("py") == "py" + def test_python_user_id_can_be_overridden(self, monkeypatch): + monkeypatch.setenv("SANDBOX_UID", "1002") + reloaded = importlib.reload(languages_module) + try: + assert reloaded.get_user_id_for_language("py") == 1002 + assert reloaded.get_user_id_for_language("js") == 1002 + finally: + monkeypatch.delenv("SANDBOX_UID", raising=False) + importlib.reload(languages_module) + class TestStdinVsFileLanguages: """Test that stdin and file-based language sets are correct.""" @@ -137,7 +148,7 @@ class TestGetLanguage: def test_returns_config_for_known_code(self, code): result = get_language(code) assert result is not None - assert isinstance(result, LanguageConfig) + assert isinstance(result, languages_module.LanguageConfig) assert result.code == code def test_returns_none_for_unknown(self): @@ -187,16 +198,28 @@ class TestGetUserIdForLanguage: """Test get_user_id_for_language() function.""" def test_python_user_id(self): - assert get_user_id_for_language("py") == 999 + assert get_user_id_for_language("py") == 1001 def test_java_user_id(self): - assert get_user_id_for_language("java") == 999 + assert get_user_id_for_language("java") == 1001 + + def test_all_languages_share_overridden_user_id(self, monkeypatch): + monkeypatch.setenv("SANDBOX_UID", "1003") + reloaded = importlib.reload(languages_module) + try: + assert reloaded.get_user_id_for_language("py") == 1003 + assert reloaded.get_user_id_for_language("java") == 1003 + assert reloaded.get_user_id_for_language("bash") == 1003 + assert reloaded.get_user_id_for_language("d") == 1003 + finally: + monkeypatch.delenv("SANDBOX_UID", raising=False) + importlib.reload(languages_module) def test_bash_user_id(self): assert get_user_id_for_language("bash") == 1001 def test_d_user_id(self): - assert get_user_id_for_language("d") == 0 + assert get_user_id_for_language("d") == 1001 def test_raises_for_unknown(self): with pytest.raises(ValueError, match="Unsupported language"): diff --git a/tests/unit/test_sandbox_manager.py b/tests/unit/test_sandbox_manager.py index ef27e36..1be081f 100644 --- a/tests/unit/test_sandbox_manager.py +++ b/tests/unit/test_sandbox_manager.py @@ -291,9 +291,8 @@ def test_get_user_id_for_language(self): manager._base_dir = Path("/tmp/test") manager._initialization_error = None - # Python user ID is 999 - assert manager.get_user_id_for_language("py") == 999 - # JS user ID is 1001 + # All sandbox languages share the same non-root UID by default + assert manager.get_user_id_for_language("py") == 1001 assert manager.get_user_id_for_language("js") == 1001 def test_close_is_noop(self):