diff --git a/server/services/previewEnv.js b/server/services/previewEnv.js new file mode 100644 index 0000000..805cc40 --- /dev/null +++ b/server/services/previewEnv.js @@ -0,0 +1,71 @@ +// Preview-Variablen: Verschluesselung (AES-256-GCM), Validierung, Log-Maskierung +import crypto from 'node:crypto' + +const NAME_RE = /^[A-Z_][A-Z0-9_]*$/ +const MAX_VARS = 30 +const MAX_BYTES = 4096 + +// Schluessel aus PREVIEW_ENV_KEY (base64, genau 32 Byte) oder null +export function envKey() { + const raw = process.env.PREVIEW_ENV_KEY + if (!raw) return null + const buf = Buffer.from(raw, 'base64') + return buf.length === 32 ? buf : null +} + +export function encryptEnv(obj, key) { + const iv = crypto.randomBytes(12) + const cipher = crypto.createCipheriv('aes-256-gcm', key, iv) + const ct = Buffer.concat([cipher.update(JSON.stringify(obj || {}), 'utf8'), cipher.final()]) + const tag = cipher.getAuthTag() + return ['v1', iv, tag, ct].map((p) => (typeof p === 'string' ? p : p.toString('base64'))).join(':') +} + +export function decryptEnv(str, key) { + if (!str) return {} + const parts = String(str).split(':') + if (parts.length !== 4 || parts[0] !== 'v1') throw new Error('Ungueltiges Variablenformat') + const [, iv, tag, ct] = parts.map((p, i) => (i === 0 ? p : Buffer.from(p, 'base64'))) + const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv) + decipher.setAuthTag(tag) + const plain = Buffer.concat([decipher.update(ct), decipher.final()]).toString('utf8') + return JSON.parse(plain) +} + +export function envNames(str, key) { + return Object.keys(decryptEnv(str, key)).sort() +} + +export function validateEnvChange(current, { set = {}, remove = [] } = {}) { + const env = { ...(current || {}) } + for (const name of remove || []) delete env[name] + for (const [name, value] of Object.entries(set || {})) { + if (!NAME_RE.test(name)) { + return { ok: false, error: `Ungültiger Variablenname „${name}“ (erlaubt: Großbuchstaben, Ziffern, Unterstrich; nicht mit Ziffer beginnen).` } + } + if (typeof value !== 'string') { + return { ok: false, error: `Der Wert von ${name} muss Text sein.` } + } + if (Buffer.byteLength(value, 'utf8') > MAX_BYTES) { + return { ok: false, error: `Der Wert von ${name} ist zu lang (maximal ${MAX_BYTES} Byte).` } + } + if (/[\0\r\n]/.test(value)) { + return { ok: false, error: `Der Wert von ${name} darf keine Zeilenumbrüche oder Nullzeichen enthalten.` } + } + env[name] = value + } + if (Object.keys(env).length > MAX_VARS) { + return { ok: false, error: `Zu viele Variablen (maximal ${MAX_VARS}).` } + } + return { ok: true, env } +} + +// Werte (>= 4 Zeichen) im Protokoll durch ••• ersetzen, laengste zuerst +export function maskLog(text, env) { + let out = String(text ?? '') + const values = Object.values(env || {}) + .filter((v) => typeof v === 'string' && v.length >= 4) + .sort((a, b) => b.length - a.length) + for (const v of values) out = out.split(v).join('•••') + return out +} diff --git a/test/previewEnv.test.js b/test/previewEnv.test.js new file mode 100644 index 0000000..86063c2 --- /dev/null +++ b/test/previewEnv.test.js @@ -0,0 +1,81 @@ +import { test } from 'node:test' +import assert from 'node:assert/strict' +import crypto from 'node:crypto' +import { + encryptEnv, decryptEnv, validateEnvChange, envNames, maskLog, envKey, +} from '../server/services/previewEnv.js' + +const key = crypto.randomBytes(32) + +test('Round-Trip und Format', () => { + const s = encryptEnv({ A: 'x', B: 'ü' }, key) + assert.match(s, /^v1:[^:]+:[^:]+:[^:]+$/) + assert.deepEqual(decryptEnv(s, key), { A: 'x', B: 'ü' }) + assert.notEqual(encryptEnv({ A: 'x' }, key), encryptEnv({ A: 'x' }, key)) +}) + +test('leer', () => { + assert.deepEqual(decryptEnv(encryptEnv({}, key), key), {}) + assert.deepEqual(decryptEnv('', key), {}) + assert.deepEqual(decryptEnv(null, key), {}) +}) + +test('falscher Schluessel und manipulierter Tag => Fehler', () => { + const s = encryptEnv({ A: 'geheim' }, key) + assert.throws(() => decryptEnv(s, crypto.randomBytes(32))) + const p = s.split(':') + const tag = Buffer.from(p[2], 'base64') + tag[0] ^= 1 + p[2] = tag.toString('base64') + assert.throws(() => decryptEnv(p.join(':'), key)) + assert.throws(() => decryptEnv('v2:a:b:c', key)) +}) + +test('envNames sortiert', () => { + assert.deepEqual(envNames(encryptEnv({ B: '1', A: '2' }, key), key), ['A', 'B']) + assert.deepEqual(envNames('', key), []) +}) + +test('envKey', () => { + const old = process.env.PREVIEW_ENV_KEY + try { + delete process.env.PREVIEW_ENV_KEY + assert.equal(envKey(), null) + process.env.PREVIEW_ENV_KEY = Buffer.alloc(16).toString('base64') + assert.equal(envKey(), null) + process.env.PREVIEW_ENV_KEY = key.toString('base64') + assert.ok(envKey().equals(key)) + } finally { + if (old === undefined) delete process.env.PREVIEW_ENV_KEY + else process.env.PREVIEW_ENV_KEY = old + } +}) + +test('validateEnvChange', () => { + let r = validateEnvChange({ A: '1' }, { set: { B: '2' }, remove: ['A', 'NIX'] }) + assert.deepEqual(r, { ok: true, env: { B: '2' } }) + assert.equal(validateEnvChange({}, { set: { abc: '1' } }).ok, false) + assert.equal(validateEnvChange({}, { set: { '1A': '1' } }).ok, false) + assert.equal(validateEnvChange({}, { set: { A: 'x'.repeat(4097) } }).ok, false) + assert.equal(validateEnvChange({}, { set: { A: 'x'.repeat(4096) } }).ok, true) + assert.equal(validateEnvChange({}, { set: { A: 'ü'.repeat(2049) } }).ok, false) + assert.equal(validateEnvChange({}, { set: { A: 1 } }).ok, false) + assert.equal(validateEnvChange({}, { set: { A: 'a\nb' } }).ok, false) + assert.equal(validateEnvChange({}, { set: { A: 'a\0b' } }).ok, false) + const many = Object.fromEntries(Array.from({ length: 31 }, (_, i) => [`V${i}`, '1'])) + r = validateEnvChange({}, { set: many }) + assert.equal(r.ok, false) + assert.match(r.error, /30/) + const thirty = Object.fromEntries(Array.from({ length: 30 }, (_, i) => [`V${i}`, '1'])) + assert.equal(validateEnvChange(thirty, { set: { V0: '2' } }).ok, true) + assert.equal(validateEnvChange(thirty, { set: { NEU: '2' }, remove: ['V1'] }).ok, true) + assert.equal(validateEnvChange(thirty, { set: { NEU: '2' } }).ok, false) + assert.equal(validateEnvChange({}, {}).ok, true) +}) + +test('maskLog', () => { + const env = { A: 'geheim', B: 'abc', C: 'geheimer' , D: 'a.b+'} + assert.equal(maskLog('x geheimer y geheim z abc a.b+ axbb', env), 'x ••• y ••• z abc ••• axbb') + assert.equal(maskLog('', env), '') + assert.equal(maskLog('text', {}), 'text') +})