Runner: Eingabepruefung gehaertet (R8)

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
ClaudeandClaude Sonnet 5.5 committed 2026-09-30 21:53:41 +00:00
1 parent aeb08580bb
commit 8588e63124
3 files changed
+28 -9

No files matched your search

+3 -1
View File
@@ -1,7 +1,7 @@
import http from 'node:http'; import http from 'node:http';
import crypto from 'node:crypto'; import crypto from 'node:crypto';
import { readFileSync } from 'node:fs'; import { readFileSync } from 'node:fs';
import { validateJob, isValidSub } from './validate.js'; import { validateJob, isValidSub, isValidRunId } from './validate.js';
const MAX_BODY = 256 * 1024; const MAX_BODY = 256 * 1024;
@@ -63,6 +63,7 @@ export function createApiServer({ config, jobs, dev, mem = readMemAvailable }) {
return send(res, 200, jobs.submit(v.job)); return send(res, 200, jobs.submit(v.job));
} }
if (method === 'GET' && parts[0] === 'jobs' && parts.length === 2) { if (method === 'GET' && parts[0] === 'jobs' && parts.length === 2) {
if (!isValidRunId(parts[1])) return send(res, 400, { error: 'Ungültige Lauf-ID' });
const run = jobs.get(parts[1]); const run = jobs.get(parts[1]);
return run ? send(res, 200, run) : send(res, 404, { error: 'Lauf nicht gefunden' }); return run ? send(res, 200, run) : send(res, 404, { error: 'Lauf nicht gefunden' });
} }
@@ -81,6 +82,7 @@ export function createApiServer({ config, jobs, dev, mem = readMemAvailable }) {
return send(res, 404, { error: 'Nicht gefunden' }); return send(res, 404, { error: 'Nicht gefunden' });
} catch (e) { } catch (e) {
const status = e.status || 500; const status = e.status || 500;
if (status >= 500) console.error('Runner-Fehler:', e);
if (!res.headersSent) send(res, status, { error: status === 500 ? 'Interner Fehler' : e.message }); if (!res.headersSent) send(res, status, { error: status === 500 ? 'Interner Fehler' : e.message });
} }
}); });
+6 -4
View File
@@ -6,15 +6,16 @@ const RE_BRANCH = /^[\w./-]{1,100}$/;
const RE_ENV = /^[A-Z_][A-Z0-9_]*$/; const RE_ENV = /^[A-Z_][A-Z0-9_]*$/;
const MODES = ['auto', 'build', 'dev']; const MODES = ['auto', 'build', 'dev'];
export const isValidRunId = (s) => typeof s === 'string' && /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(s);
export const isValidSub = (s) => typeof s === 'string' && RE_SUB.test(s); export const isValidSub = (s) => typeof s === 'string' && RE_SUB.test(s);
export function validateJob(body) { export function validateJob(body) {
if (!body || typeof body !== 'object' || Array.isArray(body)) return { ok: false, error: 'Ungültige Anfrage' }; if (!body || typeof body !== 'object' || Array.isArray(body)) return { ok: false, error: 'Ungültige Anfrage' };
const { sub, repo, sha, branch } = body; const { sub, repo, sha, branch } = body;
if (!isValidSub(sub)) return { ok: false, error: 'Ungültige Subdomain' }; if (!isValidSub(sub)) return { ok: false, error: 'Ungültige Subdomain' };
if (typeof repo !== 'string' || !RE_REPO.test(repo)) return { ok: false, error: 'Ungültiges Repository' }; if (typeof repo !== 'string' || !RE_REPO.test(repo) || repo.split('/').some((x) => x === '.' || x === '..' || x.startsWith('-'))) return { ok: false, error: 'Ungültiges Repository' };
if (typeof sha !== 'string' || !RE_SHA.test(sha)) return { ok: false, error: 'Ungültiger Commit' }; if (typeof sha !== 'string' || !RE_SHA.test(sha)) return { ok: false, error: 'Ungültiger Commit' };
if (typeof branch !== 'string' || !RE_BRANCH.test(branch) || branch.includes('..')) return { ok: false, error: 'Ungültiger Branch' }; if (typeof branch !== 'string' || !RE_BRANCH.test(branch) || branch.startsWith('-') || branch.includes('..') || branch.endsWith('/') || branch.endsWith('.lock')) return { ok: false, error: 'Ungültiger Branch' };
const mode = body.mode === undefined || body.mode === '' ? 'auto' : body.mode; const mode = body.mode === undefined || body.mode === '' ? 'auto' : body.mode;
if (!MODES.includes(mode)) return { ok: false, error: 'Ungültiger Modus' }; if (!MODES.includes(mode)) return { ok: false, error: 'Ungültiger Modus' };
let index; let index;
@@ -37,7 +38,8 @@ export function validateJob(body) {
env[n] = v; env[n] = v;
} }
} }
const job = { ...body, sub, repo, sha, branch, mode, env }; // Whitelist: keine ungeprüften Client-Felder im Job
if (index !== undefined) job.index = index; else delete job.index; const job = { sub, repo, sha, branch, mode, env };
if (index !== undefined) job.index = index;
return { ok: true, job }; return { ok: true, job };
} }
+19 -4
View File
@@ -9,10 +9,11 @@ import { validateJob } from '../src/validate.js';
const config = { ...loadConfig({ PREVIEW_RUNNER_TOKEN: 'geheim' }) }; const config = { ...loadConfig({ PREVIEW_RUNNER_TOKEN: 'geheim' }) };
const RID = '123e4567-e89b-42d3-a456-426614174000';
const submitted = []; const submitted = [];
const jobs = { const jobs = {
submit: (j) => { submitted.push(j); return { runId: 'r1' }; }, submit: (j) => { submitted.push(j); return { runId: RID }; },
get: (id) => (id === 'r1' ? { runId: 'r1', state: 'building' } : null), get: (id) => (id === RID ? { runId: RID, state: 'building' } : null),
queueLength: () => 0, queueLength: () => 0,
}; };
const stopped = []; const stopped = [];
@@ -73,7 +74,7 @@ test('POST /jobs/build: 400 bei ungültiger Eingabe, 200 bei gültiger', async (
} }
const ok = await fetch(`${base}/jobs/build`, { method: 'POST', headers: auth, body: JSON.stringify(good) }); const ok = await fetch(`${base}/jobs/build`, { method: 'POST', headers: auth, body: JSON.stringify(good) });
assert.equal(ok.status, 200); assert.equal(ok.status, 200);
assert.deepEqual(await ok.json(), { runId: 'r1' }); assert.deepEqual(await ok.json(), { runId: RID });
assert.equal(submitted[0].sub, 'demo'); assert.equal(submitted[0].sub, 'demo');
const nojson = await fetch(`${base}/jobs/build`, { method: 'POST', headers: auth, body: '{kaputt' }); const nojson = await fetch(`${base}/jobs/build`, { method: 'POST', headers: auth, body: '{kaputt' });
assert.equal(nojson.status, 400); assert.equal(nojson.status, 400);
@@ -83,7 +84,7 @@ test('POST /jobs/build: 400 bei ungültiger Eingabe, 200 bei gültiger', async (
test('GET /jobs/:runId, Dev-Start/Stop', async () => { test('GET /jobs/:runId, Dev-Start/Stop', async () => {
await withServer(config, async (base) => { await withServer(config, async (base) => {
assert.equal((await fetch(`${base}/jobs/r1`, { headers: auth })).status, 200); assert.equal((await fetch(`${base}/jobs/r1`, { headers: auth })).status, 200);
assert.equal((await fetch(`${base}/jobs/nix`, { headers: auth })).status, 404); assert.equal((await fetch(`${base}/jobs/223e4567-e89b-42d3-a456-426614174000`, { headers: auth })).status, 404);
const s = await fetch(`${base}/dev/demo/start`, { method: 'POST', headers: auth, body: JSON.stringify(good) }); const s = await fetch(`${base}/dev/demo/start`, { method: 'POST', headers: auth, body: JSON.stringify(good) });
assert.equal(s.status, 200); assert.equal(s.status, 200);
assert.equal((await fetch(`${base}/dev/..x/start`, { method: 'POST', headers: auth, body: JSON.stringify(good) })).status, 400); assert.equal((await fetch(`${base}/dev/..x/start`, { method: 'POST', headers: auth, body: JSON.stringify(good) })).status, 400);
@@ -113,6 +114,20 @@ test('validateJob: env, index, mode', () => {
assert.equal(validateJob(null).ok, false); assert.equal(validateJob(null).ok, false);
}); });
test('R8: Repo/Branch-Härtung, Whitelist, Sub, Lauf-ID', async () => {
for (const repo of ['../..', './.', '-x/y', 'a/..']) assert.equal(validateJob({ ...good, repo }).ok, false, repo);
for (const branch of ['-x', '--upload-pack=x', 'a..b', 'x.lock', 'x/']) assert.equal(validateJob({ ...good, branch }).ok, false, branch);
const v = validateJob({ ...good, devCmd: 'rm -rf /', image: 'evil' });
assert.equal(v.ok, true);
assert.deepEqual(Object.keys(v.job).sort(), ['branch', 'env', 'mode', 'repo', 'sha', 'sub']);
assert.equal(validateJob({ ...good, sub: 'demo\n' }).ok, false);
await withServer(config, async (base) => {
const r = await fetch(`${base}/dev/demo/start`, { method: 'POST', headers: auth, body: JSON.stringify({ ...good, sub: 'other' }) });
assert.equal(r.status, 400);
assert.equal((await fetch(`${base}/jobs/not-a-uuid`, { headers: auth })).status, 400);
});
});
test('readMemAvailable liest MemAvailable in Bytes', () => { test('readMemAvailable liest MemAvailable in Bytes', () => {
const p = join(mkdtempSync(join(tmpdir(), 'mem-')), 'meminfo'); const p = join(mkdtempSync(join(tmpdir(), 'mem-')), 'meminfo');
writeFileSync(p, 'MemTotal: 8000000 kB\nMemFree: 100000 kB\nMemAvailable: 2048000 kB\n'); writeFileSync(p, 'MemTotal: 8000000 kB\nMemFree: 100000 kB\nMemAvailable: 2048000 kB\n');