diff --git a/runner/src/docker.js b/runner/src/docker.js index e347756..0a12801 100644 --- a/runner/src/docker.js +++ b/runner/src/docker.js @@ -1,49 +1,71 @@ // Docker-Adapter: baut `docker run`-Argumente mit festem Sicherheitsprofil und kapselt die docker-CLI (spawn, nie Shell). import { spawn } from 'node:child_process'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; const SUB_RE = /^[a-z0-9-]{1,63}$/; const ENV_NAME_RE = /^[A-Z_][A-Z0-9_]*$/; const NAME_RE = /^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$/; -const NETWORK = 'preview-sandbox'; +const NETWORK = 'preview-sandbox'; // fest (R10), kein Parameter +const IMAGES = ['node:20', 'oven/bun:1']; +const MEMORIES = ['1g', '512m']; +const MAX_LINE = 64 * 1024; // Feste, unkritische Umgebung im Container (R9): Caches landen im gemounteten Cache-Verzeichnis. const FIXED_ENV = ['HOME=/cache/home', 'npm_config_cache=/cache/npm', 'COREPACK_HOME=/cache/corepack']; -const FIXED_NAMES = new Set(FIXED_ENV.map((e) => e.split('=')[0])); +const FIXED_NAMES = new Set(['HOME', 'COREPACK_HOME']); -function securityArgs({ memory, network, name, workDir, cacheDir, env }) { - if (name !== undefined && !NAME_RE.test(name)) throw new Error('Ungültiger Containername'); - const args = []; - if (name !== undefined) args.push('--name', name); - args.push( +// Host-Pfad: absolut, keine Sonderzeichen für -v, keine '..'-Segmente, unterhalb der Wurzel (R10). +function checkPath(p, root, what) { + if (typeof p !== 'string' || typeof root !== 'string' || !p.startsWith('/') || /[:,\0\n\r]/.test(p) + || p.split('/').includes('..') || !(p === root || p.startsWith(root.replace(/\/+$/, '') + '/'))) { + throw new Error(`Ungültiger Pfad (${what})`); + } +} + +function checkEnv(env) { + for (const [k, v] of Object.entries(env || {})) { + if (!ENV_NAME_RE.test(k) || FIXED_NAMES.has(k)) throw new Error(`Ungültiger Variablenname: ${k}`); + if (/[\0\n\r]/.test(String(v))) throw new Error(`Ungültiger Wert für ${k}`); + } +} + +function securityArgs({ image, memory, name, workDir, cacheDir, workRoot, cacheRoot, env }) { + if (!IMAGES.includes(image)) throw new Error('Image nicht erlaubt'); + if (!MEMORIES.includes(memory)) throw new Error('Ungültiges Speicherlimit'); + if (typeof name !== 'string' || !NAME_RE.test(name)) throw new Error('Ungültiger Containername'); + checkPath(workDir, workRoot, 'workDir'); + checkPath(cacheDir, cacheRoot, 'cacheDir'); + checkEnv(env); // Werte selbst laufen nie über die Kommandozeile, siehe run() (--env-file) + const args = [ + '--name', name, '--init', '--memory', memory, + '--memory-swap', memory, '--cpus', '1', '--pids-limit', '512', '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', '--user', '1000:1000', - '--network', network, + '--network', NETWORK, '-v', `${workDir}:/work`, '-v', `${cacheDir}:/cache`, '-w', '/work', - ); + ]; for (const e of FIXED_ENV) args.push('-e', e); - for (const [k, v] of Object.entries(env || {})) { - if (!ENV_NAME_RE.test(k) || FIXED_NAMES.has(k)) throw new Error(`Ungültiger Variablenname: ${k}`); - if (String(v).includes('\0')) throw new Error(`Ungültiger Wert für ${k}`); - args.push('-e', k); // nur der Name; der Wert reist über die Umgebung des docker-Prozesses - } return args; } -export function buildRunArgs({ image = 'node:20', name, workDir, cacheDir, env, cmd = [], memory = '1g', network = NETWORK }) { - return ['run', '--rm', ...securityArgs({ memory, network, name, workDir, cacheDir, env }), image, ...cmd]; +// name ist Pflicht (Aufrufer: 'preview-build-'), damit ein Zeitlimit den Container sicher entfernen kann. +export function buildRunArgs({ image = 'node:20', name, workDir, cacheDir, workRoot, cacheRoot, env, cmd = [], memory = '1g' }) { + return ['run', '--rm', ...securityArgs({ image, memory, name, workDir, cacheDir, workRoot, cacheRoot, env }), image, ...cmd]; } -export function devRunArgs({ image = 'node:20', sub, workDir, cacheDir, env, cmd = [], memory = '512m', network = NETWORK }) { +export function devRunArgs({ image = 'node:20', sub, workDir, cacheDir, workRoot, cacheRoot, env, cmd = [], memory = '512m' }) { if (typeof sub !== 'string' || !SUB_RE.test(sub)) throw new Error('Ungültige Subdomain'); return [ 'run', '-d', '--restart', 'no', '--label', `webklar.preview=${sub}`, - ...securityArgs({ memory, network, name: `preview-dev-${sub}`, workDir, cacheDir, env }), + ...securityArgs({ image, memory, name: `preview-dev-${sub}`, workDir, cacheDir, workRoot, cacheRoot, env }), image, ...cmd, ]; } @@ -56,8 +78,8 @@ function lineSplitter(onLine) { push(chunk) { buf += chunk; const parts = buf.split(/\r?\n/); - buf = parts.pop(); - for (const l of parts) onLine(l); + buf = parts.pop().slice(0, MAX_LINE); + for (const l of parts) onLine(l.length > MAX_LINE ? l.slice(0, MAX_LINE) : l); }, flush() { if (buf !== '') onLine(buf); buf = ''; }, }; @@ -65,19 +87,53 @@ function lineSplitter(onLine) { const checkName = (n) => { if (typeof n !== 'string' || !NAME_RE.test(n)) throw new Error('Ungültiger Containername'); }; -export function createDocker({ exec = spawnDocker, baseEnv = process.env } = {}) { - // Minimale Basis-Umgebung: die Runner-Geheimnisse (Tokens) dürfen nie in docker/Container gelangen. +// Name aus dem Optionsteil (vor dem Image) – nie aus dem Container-Befehl. +function optionName(args) { + const end = args.findIndex((a) => IMAGES.includes(a)); + const opts = end === -1 ? args : args.slice(0, end); + const i = opts.indexOf('--name'); + return i !== -1 && NAME_RE.test(opts[i + 1] || '') ? opts[i + 1] : null; +} + +export function createDocker({ exec = spawnDocker, baseEnv = process.env, envDir = tmpdir(), rmCapMs = 5000 } = {}) { + // CLI-Umgebung NUR aus PATH/DOCKER_HOST des Runners; Job-Variablen gelangen ausschließlich per --env-file (R10). const minimalBase = {}; for (const k of ['PATH', 'DOCKER_HOST']) if (baseEnv[k] !== undefined) minimalBase[k] = baseEnv[k]; + function writeEnvFile(env) { + checkEnv(env); + const dir = mkdtempSync(join(envDir, 'preview-env-')); + const file = join(dir, 'env'); + writeFileSync(file, Object.entries(env).map(([k, v]) => `${k}=${v}\n`).join(''), { mode: 0o600 }); + return { file, cleanup: () => { try { rmSync(dir, { recursive: true, force: true }); } catch { /* egal */ } } }; + } + + // docker rm -f mit kurzer Obergrenze abwarten. + function forceRemove(name) { + return new Promise((resolve) => { + const t = setTimeout(resolve, rmCapMs); + try { + const c = exec(['rm', '-f', name], { env: minimalBase }); + c.on?.('error', () => { clearTimeout(t); resolve(); }); + c.on?.('close', () => { clearTimeout(t); resolve(); }); + } catch { clearTimeout(t); resolve(); } + }); + } + + // onLine(zeile, 'out' | 'err') function run(args, { env = {}, timeoutMs, onLine } = {}) { - for (const [k, v] of Object.entries(env)) { - if (String(v).includes('\0')) return Promise.reject(new Error(`Ungültiger Wert für ${k}`)); + let envFile = null; + let finalArgs = args; + if (Object.keys(env).length > 0) { + if (args[0] !== 'run') return Promise.reject(new Error('Variablen nur für docker run')); + try { envFile = writeEnvFile(env); } catch (e) { return Promise.reject(e); } + finalArgs = ['run', '--env-file', envFile.file, ...args.slice(1)]; } return new Promise((resolve, reject) => { - const child = exec(args, { env: { ...minimalBase, ...env } }); - const split = lineSplitter((l) => { if (onLine) onLine(l); }); - const split2 = lineSplitter((l) => { if (onLine) onLine(l); }); + let child; + try { child = exec(finalArgs, { env: { ...minimalBase } }); } catch (e) { envFile?.cleanup(); reject(e); return; } + const split = lineSplitter((l) => { if (onLine) onLine(l, 'out'); }); + const split2 = lineSplitter((l) => { if (onLine) onLine(l, 'err'); }); child.stdout?.setEncoding?.('utf8'); child.stderr?.setEncoding?.('utf8'); child.stdout?.on('data', (d) => split.push(String(d))); @@ -88,28 +144,26 @@ export function createDocker({ exec = spawnDocker, baseEnv = process.env } = {}) if (done) return; done = true; clearTimeout(timer); + envFile?.cleanup(); split.flush(); split2.flush(); resolve(res); }; - child.on('error', (e) => { if (!done) { done = true; clearTimeout(timer); reject(e); } }); + child.on('error', (e) => { if (!done) { done = true; clearTimeout(timer); envFile?.cleanup(); reject(e); } }); child.on('close', (code) => finish({ code: code ?? 1 })); if (timeoutMs > 0) { - timer = setTimeout(() => { + timer = setTimeout(async () => { try { child.kill('SIGKILL'); } catch { /* schon beendet */ } - const i = args.indexOf('--name'); - if (i !== -1 && args[i + 1]) { - try { exec(['rm', '-f', args[i + 1]], { env: minimalBase })?.on?.('error', () => {}); } catch { /* egal */ } - } + const name = optionName(args); + if (name) await forceRemove(name); finish({ code: 124 }); }, timeoutMs); } }); } - // Kurze docker-Aufrufe: stdout einsammeln. async function capture(args) { const out = []; - const r = await run(args, { onLine: (l) => out.push(l) }); + const r = await run(args, { onLine: (l, st) => { if (st === 'out') out.push(l); } }); return { code: r.code, lines: out }; } @@ -118,7 +172,7 @@ export function createDocker({ exec = spawnDocker, baseEnv = process.env } = {}) async ps(label) { const { code, lines } = await capture(['ps', '--filter', `label=${label}`, '--format', '{{.Names}}']); if (code !== 0) throw new Error(`docker ps fehlgeschlagen (${code})`); - return lines.map((l) => l.trim()).filter(Boolean); + return lines.map((l) => l.trim()).filter((l) => NAME_RE.test(l)); }, async rm(name) { checkName(name); return capture(['rm', '-f', name]); }, async stop(name) { checkName(name); return capture(['rm', '-f', name]); }, diff --git a/runner/test/docker.test.js b/runner/test/docker.test.js index 5f4951b..0af14b2 100644 --- a/runner/test/docker.test.js +++ b/runner/test/docker.test.js @@ -1,43 +1,52 @@ import test from 'node:test'; import assert from 'node:assert/strict'; +import { readFileSync, existsSync, statSync } from 'node:fs'; import { EventEmitter } from 'node:events'; import { PassThrough } from 'node:stream'; import { buildRunArgs, devRunArgs, createDocker, spawnDocker } from '../src/docker.js'; -const base = { image: 'node:20', workDir: '/srv/w/build/1', cacheDir: '/srv/c/repo', cmd: ['npm', 'ci'] }; +const base = { image: 'node:20', name: 'preview-build-1', workDir: '/srv/w/build/1', cacheDir: '/srv/c/repo', workRoot: '/srv/w', cacheRoot: '/srv/c', cmd: ['npm', 'ci'] }; const pair = (args, flag, val) => args.some((a, i) => a === flag && args[i + 1] === val); test('buildRunArgs: komplettes Sicherheitsprofil', () => { const a = buildRunArgs(base); assert.deepEqual(a.slice(0, 2), ['run', '--rm']); assert.ok(a.includes('--init')); - for (const [f, v] of [['--memory', '1g'], ['--cpus', '1'], ['--pids-limit', '512'], ['--cap-drop', 'ALL'], ['--security-opt', 'no-new-privileges'], ['--user', '1000:1000'], ['--network', 'preview-sandbox'], ['-w', '/work'], ['-v', '/srv/w/build/1:/work'], ['-v', '/srv/c/repo:/cache'], ['-e', 'HOME=/cache/home'], ['-e', 'npm_config_cache=/cache/npm'], ['-e', 'COREPACK_HOME=/cache/corepack']]) { + for (const [f, v] of [['--memory', '1g'], ['--memory-swap', '1g'], ['--name', 'preview-build-1'], ['--cpus', '1'], ['--pids-limit', '512'], ['--cap-drop', 'ALL'], ['--security-opt', 'no-new-privileges'], ['--user', '1000:1000'], ['--network', 'preview-sandbox'], ['-w', '/work'], ['-v', '/srv/w/build/1:/work'], ['-v', '/srv/c/repo:/cache'], ['-e', 'HOME=/cache/home'], ['-e', 'npm_config_cache=/cache/npm'], ['-e', 'COREPACK_HOME=/cache/corepack']]) { assert.ok(pair(a, f, v), `${f} ${v}`); } assert.equal(a.filter((x) => x === '-v').length, 2); assert.deepEqual(a.slice(-3), ['node:20', 'npm', 'ci']); }); -test('buildRunArgs: Variablen nur als Name, Werte nie in args', () => { - const a = buildRunArgs({ ...base, env: { API_KEY: 'geheim-wert', X: 'abc' }, memory: '1g' }); - assert.ok(pair(a, '-e', 'API_KEY') && pair(a, '-e', 'X')); +test('buildRunArgs: Variablenwerte nie in args, ungültige Werte/Namen abgelehnt', () => { + const a = buildRunArgs({ ...base, env: { API_KEY: 'geheim-wert' } }); assert.ok(!a.join(' ').includes('geheim-wert')); -}); - -test('buildRunArgs: ungültiger Name/NUL/Name-Validierung', () => { assert.throws(() => buildRunArgs({ ...base, env: { 'a b': '1' } })); assert.throws(() => buildRunArgs({ ...base, env: { A: 'x\0y' } })); + assert.throws(() => buildRunArgs({ ...base, env: { A: 'x\ny' } })); assert.throws(() => buildRunArgs({ ...base, env: { HOME: 'x' } })); - assert.throws(() => buildRunArgs({ ...base, name: '-x' })); }); -test('buildRunArgs: memory/name optional', () => { - const a = buildRunArgs({ ...base, name: 'job-1', memory: '2g' }); - assert.ok(pair(a, '--memory', '2g') && pair(a, '--name', 'job-1')); +test('buildRunArgs: Name Pflicht, Memory/Image Allowlist, Network fest', () => { + assert.throws(() => buildRunArgs({ ...base, name: undefined })); + assert.throws(() => buildRunArgs({ ...base, name: '-x' })); + assert.throws(() => buildRunArgs({ ...base, memory: '64g' })); + assert.throws(() => buildRunArgs({ ...base, image: 'evil/img' })); + assert.ok(buildRunArgs({ ...base, image: 'oven/bun:1', memory: '512m' })); + assert.ok(pair(buildRunArgs({ ...base, network: 'host' }), '--network', 'preview-sandbox')); +}); + +test('Pfade: absolut, unter Wurzel, keine Sonderzeichen', () => { + for (const workDir of ['rel/x', '/etc', '/srv/w/../x', '/srv/w/a:b', '/srv/w/a,b', '/srv/w/a\nb', '/srv/wx/y', '/srv/w/a\0b', '/srv/w/x/../../etc']) { + assert.throws(() => buildRunArgs({ ...base, workDir }), workDir); + } + assert.throws(() => buildRunArgs({ ...base, cacheDir: '/srv/w/x' })); + assert.throws(() => buildRunArgs({ ...base, workRoot: undefined })); }); test('devRunArgs: detached, Name, Label, ohne --rm, 512m', () => { - const a = devRunArgs({ ...base, sub: 'demo-1', cmd: ['npm', 'run', 'dev'] }); + const a = devRunArgs({ ...base, sub: 'demo-1', memory: undefined, cmd: ['npm', 'run', 'dev'] }); assert.ok(!a.includes('--rm')); assert.ok(a.includes('-d')); assert.ok(pair(a, '--name', 'preview-dev-demo-1')); @@ -60,12 +69,19 @@ function fakeChild() { return c; } -test('run: streamt Zeilen, liefert Exit-Code, Umgebung minimal + Job-Env', async () => { +test('run: streamt Zeilen, CLI-Umgebung nur minimalBase, Werte nur in env-file (0600, danach gelöscht)', async () => { const calls = []; + let content; let mode; let filePath; const child = fakeChild(); - const docker = createDocker({ exec: (args, opts) => { calls.push({ args, opts }); return child; }, baseEnv: { PATH: '/bin', DOCKER_HOST: 'unix:///x', PREVIEW_RUNNER_TOKEN: 'leak' } }); + const exec = (args, opts) => { + calls.push({ args, opts }); + const i = args.indexOf('--env-file'); + filePath = args[i + 1]; content = readFileSync(filePath, 'utf8'); mode = statSync(filePath).mode & 0o777; + return child; + }; + const docker = createDocker({ exec, baseEnv: { PATH: '/bin', DOCKER_HOST: 'unix:///x', PREVIEW_RUNNER_TOKEN: 'leak' } }); const lines = []; - const p = docker.run(['run', '-e', 'K'], { env: { K: 'v' }, onLine: (l) => lines.push(l) }); + const p = docker.run(['run', '--rm', 'img'], { env: { K: 'v', DOCKER_HOST: 'x', PATH: 'y' }, onLine: (l) => lines.push(l) }); child.stdout.write('eins\nzw'); child.stderr.write('fehler\n'); child.stdout.write('ei\n'); @@ -74,27 +90,62 @@ test('run: streamt Zeilen, liefert Exit-Code, Umgebung minimal + Job-Env', async child.emit('close', 3); assert.deepEqual(await p, { code: 3 }); assert.deepEqual(lines.sort(), ['eins', 'fehler', 'zwei']); - assert.deepEqual(calls[0].opts.env, { PATH: '/bin', DOCKER_HOST: 'unix:///x', K: 'v' }); + assert.deepEqual(calls[0].opts.env, { PATH: '/bin', DOCKER_HOST: 'unix:///x' }); + assert.equal(content, 'K=v\nDOCKER_HOST=x\nPATH=y\n'); + assert.equal(mode, 0o600); + assert.ok(!calls[0].args.join(' ').includes('=v')); + assert.equal(calls[0].args[0], 'run'); + assert.equal(existsSync(filePath), false); }); -test('run: NUL im Wert ⇒ Fehler vor dem Start', async () => { +test('run: NUL/Newline im Wert ⇒ Fehler vor dem Start', async () => { let n = 0; const docker = createDocker({ exec: () => { n++; return fakeChild(); } }); await assert.rejects(docker.run(['run'], { env: { A: 'x\0' } })); + await assert.rejects(docker.run(['run'], { env: { A: 'x\ny' } })); assert.equal(n, 0); }); test('run: Zeitlimit ⇒ Kill, docker rm -f, code 124', async () => { const calls = []; const child = fakeChild(); - const docker = createDocker({ exec: (args) => { calls.push(args); return calls.length === 1 ? child : Object.assign(fakeChild(), { __rm: true }); } }); - const p = docker.run(['run', '--name', 'job-9', 'img'], { timeoutMs: 20 }); + const docker = createDocker({ rmCapMs: 30, exec: (args) => { calls.push(args); return calls.length === 1 ? child : Object.assign(fakeChild(), { __rm: true }); } }); + const p = docker.run(['run', '--name', 'job-9', 'node:20', 'x', '--name', 'fake'], { timeoutMs: 20 }); const r = await p; assert.deepEqual(r, { code: 124 }); assert.deepEqual(child.killed, ['SIGKILL']); assert.deepEqual(calls[1], ['rm', '-f', 'job-9']); }); +test('Zeitlimit: Name nur aus Optionsteil, ohne Namen kein rm', async () => { + const calls = []; + const docker = createDocker({ rmCapMs: 10, exec: (args) => { calls.push(args); return fakeChild(); } }); + assert.deepEqual(await docker.run(['run', 'node:20', 'x', '--name', 'fake'], { timeoutMs: 10 }), { code: 124 }); + assert.equal(calls.length, 1); +}); + +test('ps: nur stdout, nur gültige Namen', async () => { + const exec = () => { + const c = fakeChild(); + setImmediate(() => { c.stderr.write('Warnung: foo\n'); c.stdout.write('preview-dev-a\n'); c.stdout.end(); c.stderr.end(); c.emit('close', 0); }); + return c; + }; + assert.deepEqual(await createDocker({ exec }).ps('webklar.preview'), ['preview-dev-a']); +}); + +test('lineSplitter: überlange Zeilen werden gekappt', async () => { + const child = fakeChild(); + const docker = createDocker({ exec: () => child }); + const lines = []; + const p = docker.run(['ps'], { onLine: (l) => lines.push(l) }); + child.stdout.write('a'.repeat(200000) + '\n'); + child.stdout.end(); child.stderr.end(); + await new Promise((r) => setImmediate(r)); + child.emit('close', 0); + await p; + assert.ok(lines[0].length <= 65536); +}); + test('ps/stop/rm', async () => { const calls = []; const exec = (args) => {