Sandboxing

This commit is contained in:
Joey Yakimowich-Payne 2026-02-03 08:24:48 -07:00
commit 70df689701
7 changed files with 324 additions and 34 deletions

23
server/sandbox/Dockerfile Normal file
View file

@ -0,0 +1,23 @@
FROM node:22-alpine
RUN apk add --no-cache libreoffice curl \
&& addgroup -g 1000 sandbox \
&& adduser -u 1000 -G sandbox -s /bin/sh -D sandbox \
&& mkdir -p /app /tmp/convert \
&& chown -R sandbox:sandbox /app /tmp/convert
WORKDIR /app
COPY --chown=sandbox:sandbox package.json ./
RUN npm install --omit=dev
COPY --chown=sandbox:sandbox convert.js ./
USER sandbox
EXPOSE 3002
HEALTHCHECK --interval=30s --timeout=5s --start-period=5s --retries=3 \
CMD curl -f http://localhost:3002/health || exit 1
CMD ["node", "convert.js"]

168
server/sandbox/convert.js Normal file
View file

@ -0,0 +1,168 @@
import { createServer } from 'http';
import { exec } from 'child_process';
import { writeFile, readFile, unlink, mkdir, readdir } from 'fs/promises';
import { existsSync } from 'fs';
import { join } from 'path';
import { randomUUID } from 'crypto';
const PORT = process.env.PORT || 3002;
const TEMP_DIR = '/tmp/convert';
const TIMEOUT_MS = 30000;
const MAX_FILE_SIZE = 50 * 1024 * 1024;
const MAX_CONCURRENT = parseInt(process.env.MAX_CONCURRENT || '3', 10);
const EXTENSION_TO_OUTPUT = {
'.ppt': 'pptx',
'.doc': 'docx',
'.xls': 'xlsx',
};
let activeJobs = 0;
let queuedJobs = 0;
async function cleanup(dir) {
if (!existsSync(dir)) return;
try {
for (const file of await readdir(dir)) {
await unlink(join(dir, file)).catch(() => {});
}
await unlink(dir).catch(() => {});
} catch {}
}
function runLibreOffice(inputPath, outputDir, outputExt) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
reject(new Error('timeout'));
}, TIMEOUT_MS);
exec(
`libreoffice --headless --convert-to ${outputExt} --outdir "${outputDir}" "${inputPath}"`,
{ maxBuffer: 10 * 1024 * 1024 },
(error) => {
clearTimeout(timer);
if (error) reject(error);
else resolve();
}
);
});
}
async function processConversion(buffer, ext) {
const jobId = randomUUID();
const jobDir = join(TEMP_DIR, jobId);
const inputPath = join(jobDir, `input${ext}`);
const outputExt = EXTENSION_TO_OUTPUT[ext];
const outputPath = join(jobDir, `input.${outputExt}`);
try {
await mkdir(jobDir, { mode: 0o700 });
await writeFile(inputPath, buffer, { mode: 0o600 });
await runLibreOffice(inputPath, jobDir, outputExt);
if (!existsSync(outputPath)) {
throw new Error('no_output');
}
return await readFile(outputPath);
} finally {
await cleanup(jobDir);
}
}
async function handleConvert(req, res) {
if (activeJobs >= MAX_CONCURRENT) {
queuedJobs++;
if (queuedJobs > MAX_CONCURRENT * 2) {
queuedJobs--;
res.writeHead(503, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Server busy, try again later' }));
return;
}
await new Promise((resolve) => {
const check = setInterval(() => {
if (activeJobs < MAX_CONCURRENT) {
clearInterval(check);
queuedJobs--;
resolve();
}
}, 100);
});
}
activeJobs++;
const chunks = [];
let size = 0;
req.on('data', (chunk) => {
size += chunk.length;
if (size <= MAX_FILE_SIZE) {
chunks.push(chunk);
}
});
req.on('end', async () => {
if (size > MAX_FILE_SIZE) {
activeJobs--;
res.writeHead(413, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'File too large' }));
return;
}
const url = new URL(req.url, `http://localhost:${PORT}`);
const ext = url.searchParams.get('ext');
if (!ext || !EXTENSION_TO_OUTPUT[ext]) {
activeJobs--;
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Invalid or missing extension parameter' }));
return;
}
try {
const converted = await processConversion(Buffer.concat(chunks), ext);
res.writeHead(200, {
'Content-Type': 'application/octet-stream',
'Content-Length': converted.length,
});
res.end(converted);
} catch (err) {
const message = err.message === 'timeout'
? 'Conversion timed out'
: err.message === 'no_output'
? 'Conversion produced no output'
: 'Conversion failed';
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: message }));
} finally {
activeJobs--;
}
});
}
const server = createServer((req, res) => {
if (req.method === 'GET' && req.url === '/health') {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ status: 'ok', active: activeJobs, queued: queuedJobs }));
return;
}
if (req.method === 'POST' && req.url?.startsWith('/convert')) {
handleConvert(req, res);
return;
}
res.writeHead(404, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Not found' }));
});
if (!existsSync(TEMP_DIR)) {
await mkdir(TEMP_DIR, { recursive: true, mode: 0o700 });
}
server.listen(PORT, '0.0.0.0', () => {
console.log(`Sandbox running on port ${PORT} (max ${MAX_CONCURRENT} concurrent)`);
});

View file

@ -0,0 +1,9 @@
{
"name": "kaboot-sandbox",
"version": "1.0.0",
"type": "module",
"private": true,
"scripts": {
"start": "node convert.js"
}
}