Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
│ │
└─────────────────────────────────────────────────────────────────────────────┘
```
Expand Down
5 changes: 4 additions & 1 deletion docs/CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion docs/SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
48 changes: 35 additions & 13 deletions src/config/languages.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
execution settings (commands, resource multipliers, user IDs, etc.).
"""

import os
from dataclasses import dataclass, field
from typing import Dict, Optional

Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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",
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand All @@ -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,
Expand Down
37 changes: 30 additions & 7 deletions tests/unit/test_language_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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"

Expand Down Expand Up @@ -104,14 +105,24 @@ 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

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."""
Expand All @@ -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):
Expand Down Expand Up @@ -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"):
Expand Down
5 changes: 2 additions & 3 deletions tests/unit/test_sandbox_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
Loading