Skip to content

Commit 672ae0f

Browse files
committed
feat: implement execution sandboxing for Java and Python; enhance security and add related environment configurations
1 parent ecf8fbb commit 672ae0f

12 files changed

Lines changed: 256 additions & 6 deletions

File tree

README.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,17 @@ Then open:
6868

6969
For local development details, environment variables, architecture notes, deployment, and contributor workflow, see [CONTRIBUTING.md](./CONTRIBUTING.md).
7070

71+
## Execution Safety
72+
73+
Browser-triggered Java and Python execution runs arbitrary user code on the relay host, so deployment defaults matter.
74+
75+
- The Docker image is configured to require sandboxed execution and drops Java/Python child processes to a dedicated low-privilege OS user.
76+
- If the server is started as `root` without `EXEC_SANDBOX_UID` and `EXEC_SANDBOX_GID`, execution is now disabled by default. You can explicitly override that with `EXEC_ALLOW_UNSANDBOXED_ROOT=1`, but that is not recommended.
77+
- Local development on platforms without POSIX `uid`/`gid` privilege dropping can still run unsandboxed unless you require sandboxing with `EXEC_REQUIRE_SANDBOX=1`.
78+
- The app's Help -> About tab and the server health endpoint report whether execution is `Sandboxed`, `Unsandboxed`, or `Disabled`.
79+
80+
This is a hardening step, not a full container-per-run sandbox. If you expose execution to untrusted users, isolate the relay further at the deployment level as well.
81+
7182
## What You Can Do
7283

7384
- Create or join a room from the landing page in a few seconds.

server/Dockerfile

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,15 @@ FROM node:20-slim
33
# Install JDK and Python for interactive code execution
44
RUN apt-get update && \
55
apt-get install -y default-jdk-headless python3 && \
6+
groupadd --system --gid 10001 collabexec && \
7+
useradd --system --uid 10001 --gid 10001 --create-home --home-dir /home/collabexec collabexec && \
68
rm -rf /var/lib/apt/lists/* && \
79
javac -version && java -version && python3 --version
810

11+
ENV EXEC_SANDBOX_UID=10001
12+
ENV EXEC_SANDBOX_GID=10001
13+
ENV EXEC_REQUIRE_SANDBOX=1
14+
915
WORKDIR /app
1016

1117
COPY package.json ./

server/exec/index.cjs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,19 @@ const {
55
isPythonAvailable,
66
getPythonRuntimeVersion,
77
} = require('./runtimeRegistry.cjs');
8+
const {
9+
getExecutionSandboxStatus,
10+
isExecutionAllowed,
11+
isExecutionSandboxed,
12+
} = require('./sandbox.cjs');
813

914
module.exports = {
1015
handleExecConnection,
1116
isJavaAvailable,
1217
getJavaRuntimeVersion,
1318
isPythonAvailable,
1419
getPythonRuntimeVersion,
20+
isExecutionAllowed,
21+
isExecutionSandboxed,
22+
getExecutionSandboxStatus,
1523
};

server/exec/processRunner.cjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ function runProcess(context) {
88
command,
99
args,
1010
cwd,
11+
spawnOptions,
1112
files,
1213
ignoredDirs,
1314
ignoredExtensions,
@@ -22,6 +23,7 @@ function runProcess(context) {
2223
const child = spawn(command, args, {
2324
cwd,
2425
stdio: ['pipe', 'pipe', 'pipe'],
26+
...spawnOptions,
2527
});
2628
setActiveProcess(child);
2729

server/exec/runtimes/java.cjs

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ const javaRunner = {
6363
files,
6464
entryPoint,
6565
tmpDir,
66+
execSpawnOptions,
6667
runProcess,
6768
send,
6869
cleanup,
@@ -88,11 +89,18 @@ const javaRunner = {
8889
const mainClass = resolveMainClass(sourceByClass, entryPoint || 'Main');
8990
const outDir = path.join(tmpDir, '__out__');
9091
fs.mkdirSync(outDir, { recursive: true });
92+
if (typeof execSpawnOptions?.uid === 'number' && typeof execSpawnOptions?.gid === 'number') {
93+
fs.chownSync(outDir, execSpawnOptions.uid, execSpawnOptions.gid);
94+
fs.chmodSync(outDir, 0o700);
95+
}
9196

9297
console.log(`[exec] Compiling ${javaFiles.length} Java file(s) in ${tmpDir}, main class: ${mainClass}`);
9398
send({ type: 'compile-start' });
9499

95-
const javac = spawn('javac', ['-d', outDir, ...javaFiles]);
100+
const javac = spawn('javac', ['-d', outDir, ...javaFiles], {
101+
cwd: tmpDir,
102+
...execSpawnOptions,
103+
});
96104
let compileErr = '';
97105

98106
javac.stderr.on('data', (data) => {
@@ -111,8 +119,9 @@ const javaRunner = {
111119
send({ type: 'compile-ok' });
112120
runProcess({
113121
command: 'java',
114-
args: ['-cp', outDir, mainClass],
122+
args: [`-Djava.io.tmpdir=${tmpDir}`, `-Duser.home=${tmpDir}`, '-cp', outDir, mainClass],
115123
cwd: tmpDir,
124+
spawnOptions: execSpawnOptions,
116125
files,
117126
ignoredDirs: new Set(['__out__']),
118127
ignoredExtensions: new Set(['.class']),

server/exec/runtimes/python.cjs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ const pythonRunner = {
5555
files,
5656
entryPoint,
5757
tmpDir,
58+
execSpawnOptions,
5859
runProcess,
5960
send,
6061
cleanup,
@@ -94,8 +95,9 @@ const pythonRunner = {
9495

9596
runProcess({
9697
command: pythonRuntime.command,
97-
args: [...pythonRuntime.args, runtimeEntryPoint],
98+
args: [...pythonRuntime.args, '-I', '-B', runtimeEntryPoint],
9899
cwd: tmpDir,
100+
spawnOptions: execSpawnOptions,
99101
files,
100102
ignoredDirs: new Set(['__pycache__']),
101103
ignoredExtensions: new Set(['.pyc']),

server/exec/sandbox.cjs

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
const fs = require('fs');
2+
const path = require('path');
3+
4+
function parseOptionalInteger(value) {
5+
if (value == null || value === '') {
6+
return null;
7+
}
8+
9+
const parsed = Number.parseInt(String(value), 10);
10+
return Number.isFinite(parsed) ? parsed : null;
11+
}
12+
13+
function readBooleanEnv(name, fallback = false) {
14+
const value = process.env[name];
15+
if (value == null) {
16+
return fallback;
17+
}
18+
19+
const normalized = String(value).trim().toLowerCase();
20+
return normalized === '1' || normalized === 'true' || normalized === 'yes' || normalized === 'on';
21+
}
22+
23+
function detectExecutionSandbox() {
24+
const sandboxRequired = readBooleanEnv('EXEC_REQUIRE_SANDBOX', false);
25+
const allowUnsandboxedRoot = readBooleanEnv('EXEC_ALLOW_UNSANDBOXED_ROOT', false);
26+
27+
if (process.platform === 'win32' || typeof process.getuid !== 'function') {
28+
return {
29+
enabled: false,
30+
allowed: !sandboxRequired,
31+
uid: null,
32+
gid: null,
33+
reason: 'POSIX uid/gid privilege dropping is unavailable on this platform',
34+
};
35+
}
36+
37+
if (process.getuid() !== 0) {
38+
return {
39+
enabled: false,
40+
allowed: !sandboxRequired,
41+
uid: null,
42+
gid: null,
43+
reason: 'Server is not running as root, so execution cannot be dropped to a separate OS user',
44+
};
45+
}
46+
47+
const uid = parseOptionalInteger(process.env.EXEC_SANDBOX_UID);
48+
const gid = parseOptionalInteger(process.env.EXEC_SANDBOX_GID);
49+
if (uid == null || gid == null) {
50+
const allowed = sandboxRequired ? false : allowUnsandboxedRoot;
51+
return {
52+
enabled: false,
53+
allowed,
54+
uid: null,
55+
gid: null,
56+
reason: allowed
57+
? 'Server is running as root without a dedicated execution user'
58+
: 'Server is running as root without a dedicated execution user. Configure EXEC_SANDBOX_UID and EXEC_SANDBOX_GID, or set EXEC_ALLOW_UNSANDBOXED_ROOT=1 to override',
59+
};
60+
}
61+
62+
return {
63+
enabled: true,
64+
allowed: true,
65+
uid,
66+
gid,
67+
reason: `Execution runs as uid ${uid}, gid ${gid}`,
68+
};
69+
}
70+
71+
const executionSandbox = detectExecutionSandbox();
72+
73+
if (executionSandbox.enabled) {
74+
console.log(`[exec] Sandbox enabled - ${executionSandbox.reason}`);
75+
} else if (executionSandbox.allowed) {
76+
console.warn(`[exec] WARNING: execution is not sandboxed - ${executionSandbox.reason}`);
77+
} else {
78+
console.error(`[exec] Execution disabled - ${executionSandbox.reason}`);
79+
}
80+
81+
function applyOwnership(targetPath, uid, gid) {
82+
const stats = fs.lstatSync(targetPath);
83+
if (stats.isSymbolicLink()) {
84+
return;
85+
}
86+
87+
fs.chownSync(targetPath, uid, gid);
88+
if (stats.isDirectory()) {
89+
fs.chmodSync(targetPath, 0o700);
90+
for (const entry of fs.readdirSync(targetPath, { withFileTypes: true })) {
91+
applyOwnership(path.join(targetPath, entry.name), uid, gid);
92+
}
93+
return;
94+
}
95+
96+
fs.chmodSync(targetPath, 0o600);
97+
}
98+
99+
function prepareExecutionWorkspace(tmpDir) {
100+
if (!executionSandbox.enabled) {
101+
return;
102+
}
103+
104+
applyOwnership(tmpDir, executionSandbox.uid, executionSandbox.gid);
105+
}
106+
107+
function buildExecutionEnv(tmpDir) {
108+
if (process.platform === 'win32') {
109+
return {
110+
...process.env,
111+
HOME: tmpDir,
112+
USERPROFILE: tmpDir,
113+
TMPDIR: tmpDir,
114+
TEMP: tmpDir,
115+
TMP: tmpDir,
116+
};
117+
}
118+
119+
const env = {
120+
PATH: process.env.PATH || '',
121+
HOME: tmpDir,
122+
TMPDIR: tmpDir,
123+
TEMP: tmpDir,
124+
TMP: tmpDir,
125+
LANG: process.env.LANG || 'C.UTF-8',
126+
LC_ALL: process.env.LC_ALL || process.env.LANG || 'C.UTF-8',
127+
};
128+
129+
if (process.env.JAVA_HOME) {
130+
env.JAVA_HOME = process.env.JAVA_HOME;
131+
}
132+
133+
return env;
134+
}
135+
136+
function getExecutionSpawnOptions(tmpDir) {
137+
const spawnOptions = {
138+
env: buildExecutionEnv(tmpDir),
139+
windowsHide: true,
140+
};
141+
142+
if (executionSandbox.enabled) {
143+
spawnOptions.uid = executionSandbox.uid;
144+
spawnOptions.gid = executionSandbox.gid;
145+
}
146+
147+
return spawnOptions;
148+
}
149+
150+
function isExecutionAllowed() {
151+
return executionSandbox.allowed;
152+
}
153+
154+
function isExecutionSandboxed() {
155+
return executionSandbox.enabled;
156+
}
157+
158+
function getExecutionSandboxStatus() {
159+
if (executionSandbox.enabled) {
160+
return `Sandboxed - ${executionSandbox.reason}`;
161+
}
162+
163+
if (!executionSandbox.allowed) {
164+
return `Disabled - ${executionSandbox.reason}`;
165+
}
166+
167+
return `Unsandboxed - ${executionSandbox.reason}`;
168+
}
169+
170+
module.exports = {
171+
getExecutionSpawnOptions,
172+
getExecutionSandboxStatus,
173+
isExecutionAllowed,
174+
isExecutionSandboxed,
175+
prepareExecutionWorkspace,
176+
};

server/exec/session.cjs

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,12 @@ const path = require('path');
44
const WebSocket = require('ws');
55
const { runProcess } = require('./processRunner.cjs');
66
const { getRunner, resolveExecutionLanguage } = require('./runtimeRegistry.cjs');
7+
const {
8+
getExecutionSandboxStatus,
9+
getExecutionSpawnOptions,
10+
isExecutionAllowed,
11+
prepareExecutionWorkspace,
12+
} = require('./sandbox.cjs');
713
const {
814
sanitizeRelativePath,
915
normalizeProjectFiles,
@@ -42,6 +48,11 @@ function handleExecConnection(ws) {
4248
return;
4349
}
4450

51+
if (!isExecutionAllowed()) {
52+
send({ type: 'error', data: getExecutionSandboxStatus() });
53+
return;
54+
}
55+
4556
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'collab-exec-'));
4657
const files = normalizeProjectFiles(message);
4758
const language = resolveExecutionLanguage(message, files);
@@ -60,10 +71,20 @@ function handleExecConnection(ws) {
6071
}
6172

6273
writeProjectFiles(tmpDir, files);
74+
try {
75+
prepareExecutionWorkspace(tmpDir);
76+
} catch (err) {
77+
send({ type: 'error', data: `Failed to prepare execution sandbox: ${err.message}` });
78+
cleanup();
79+
return;
80+
}
81+
82+
const execSpawnOptions = getExecutionSpawnOptions(tmpDir);
6383
runner.start({
6484
files,
6585
entryPoint: sanitizeRelativePath(message.entryPoint || message.mainClass || ''),
6686
tmpDir,
87+
execSpawnOptions,
6788
runProcess,
6889
send,
6990
cleanup,

server/exec/workspace.cjs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@ function collectSyncedFiles(rootDir, originalFiles, ignoredDirs, ignoredExtensio
4646
const fullPath = path.join(directory, entry.name);
4747
const relativePath = relativePrefix ? `${relativePrefix}/${entry.name}` : entry.name;
4848

49+
if (entry.isSymbolicLink()) {
50+
continue;
51+
}
52+
4953
if (entry.isDirectory()) {
5054
if (ignoredDirs.has(entry.name)) {
5155
continue;
@@ -54,12 +58,16 @@ function collectSyncedFiles(rootDir, originalFiles, ignoredDirs, ignoredExtensio
5458
continue;
5559
}
5660

61+
const stat = fs.lstatSync(fullPath);
62+
if (!stat.isFile()) {
63+
continue;
64+
}
65+
5766
const extension = path.extname(entry.name);
5867
if (ignoredExtensions.has(extension)) {
5968
continue;
6069
}
6170

62-
const stat = fs.statSync(fullPath);
6371
if (stat.size > 1024 * 256) {
6472
continue;
6573
}

0 commit comments

Comments
 (0)