Files
dockhand-upstream/tests/backup-restic-hardening.test.ts

236 lines
10 KiB
TypeScript

/**
* Regression tests for the restic-subprocess hardening from the 2026-06-26
* HIGH audit: env allowlist (#1), envVars injection block (#32), and the
* restic flag allowlist (#34). Pure helpers → exercised directly here.
*/
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { buildResticEnv, sanitizeResticFlags, filterCloudEnvVars, isSafeWebhookUrl, fireWebhook } from '../src/lib/server/backups/helpers';
describe('isSafeWebhookUrl — SSRF guard (audit #46)', () => {
it('allows public http/https URLs', () => {
assert.equal(isSafeWebhookUrl('https://hooks.example.com/x').ok, true);
assert.equal(isSafeWebhookUrl('http://93.184.216.34/notify').ok, true);
});
it('blocks the cloud metadata IP and private/loopback/link-local', () => {
for (const u of [
'http://169.254.169.254/latest/meta-data/',
'http://127.0.0.1:6379/',
'http://localhost/x',
'http://10.0.0.5/x',
'http://192.168.1.1/x',
'http://172.16.0.9/x',
'http://[::1]/x'
]) {
assert.equal(isSafeWebhookUrl(u).ok, false, `should block ${u}`);
}
});
it('blocks non-http schemes and garbage', () => {
assert.equal(isSafeWebhookUrl('file:///etc/passwd').ok, false);
assert.equal(isSafeWebhookUrl('gopher://x').ok, false);
assert.equal(isSafeWebhookUrl('not a url').ok, false);
});
});
describe('filterCloudEnvVars — container envVars allowlist (audit #30)', () => {
it('keeps cloud credential vars, drops PATH/LD_PRELOAD injection', () => {
const out = filterCloudEnvVars({
AWS_ACCESS_KEY_ID: 'AKIA', B2_ACCOUNT_KEY: 'k', AZURE_ACCOUNT_KEY: 'z',
PATH: '/evil', LD_PRELOAD: '/tmp/x.so', HOME: '/root',
RCLONE_CONFIG_PASS: 'p' // rclone backend removed → its prefix is no longer allowlisted
});
assert.equal(out.AWS_ACCESS_KEY_ID, 'AKIA');
assert.equal(out.B2_ACCOUNT_KEY, 'k');
assert.equal(out.AZURE_ACCOUNT_KEY, 'z');
assert.equal(out.PATH, undefined);
assert.equal(out.LD_PRELOAD, undefined);
assert.equal(out.HOME, undefined);
assert.equal(out.RCLONE_CONFIG_PASS, undefined);
});
it('handles empty/undefined input', () => {
assert.deepEqual(filterCloudEnvVars({}), {});
assert.deepEqual(filterCloudEnvVars(undefined as any), {});
});
});
describe('buildResticEnv — env allowlist (audit #1, #32)', () => {
const procEnv = {
PATH: '/usr/bin',
HOME: '/root',
HTTPS_PROXY: 'http://proxy:3128',
ENCRYPTION_KEY: 'super-secret-master-key',
DATABASE_URL: 'postgres://user:pw@db/dockhand',
SOME_OTHER_SECRET: 'nope'
};
it('passes through the allowlisted base vars', () => {
const env = buildResticEnv(procEnv, { repository: 'rest:http://x', password: 'pw' });
assert.equal(env.PATH, '/usr/bin');
assert.equal(env.HOME, '/root');
assert.equal(env.HTTPS_PROXY, 'http://proxy:3128');
});
it('NEVER leaks ENCRYPTION_KEY / DATABASE_URL / other process secrets', () => {
const env = buildResticEnv(procEnv, { repository: 'rest:http://x', password: 'pw' });
assert.equal(env.ENCRYPTION_KEY, undefined);
assert.equal(env.DATABASE_URL, undefined);
assert.equal(env.SOME_OTHER_SECRET, undefined);
});
it('sets the restic repo + password', () => {
const env = buildResticEnv(procEnv, { repository: 'rest:http://repo', password: 's3cret' });
assert.equal(env.RESTIC_REPOSITORY, 'rest:http://repo');
assert.equal(env.RESTIC_PASSWORD, 's3cret');
});
it('allows cloud credential envVars but blocks PATH/LD_PRELOAD injection', () => {
const env = buildResticEnv(procEnv, {
repository: 'r', password: 'p',
envVars: {
AWS_ACCESS_KEY_ID: 'AKIA', AWS_SECRET_ACCESS_KEY: 'shh', B2_ACCOUNT_ID: 'b2',
RCLONE_CONFIG_PASS: 'x', // rclone backend removed → prefix no longer allowlisted
PATH: '/evil/bin', LD_PRELOAD: '/tmp/evil.so', LD_LIBRARY_PATH: '/tmp'
}
});
assert.equal(env.AWS_ACCESS_KEY_ID, 'AKIA');
assert.equal(env.AWS_SECRET_ACCESS_KEY, 'shh');
assert.equal(env.B2_ACCOUNT_ID, 'b2');
assert.equal(env.RCLONE_CONFIG_PASS, undefined);
// injection attempts via envVars must NOT override the process PATH etc.
assert.equal(env.PATH, '/usr/bin');
assert.equal(env.LD_PRELOAD, undefined);
assert.equal(env.LD_LIBRARY_PATH, undefined);
});
it('points RESTIC_CACHE_DIR at DATA_DIR/.restic-cache (durable, survives redeploy)', () => {
const env = buildResticEnv({ ...procEnv, DATA_DIR: '/app/data' }, { repository: 'r', password: 'p' });
assert.equal(env.RESTIC_CACHE_DIR, '/app/data/.restic-cache');
});
it('defaults the cache dir to /app/data when DATA_DIR is unset', () => {
const env = buildResticEnv(procEnv, { repository: 'r', password: 'p' });
assert.equal(env.RESTIC_CACHE_DIR, '/app/data/.restic-cache');
});
it('an operator-set RESTIC_CACHE_DIR wins', () => {
const env = buildResticEnv({ ...procEnv, RESTIC_CACHE_DIR: '/custom/cache' }, { repository: 'r', password: 'p' });
assert.equal(env.RESTIC_CACHE_DIR, '/custom/cache');
});
});
describe('sanitizeResticFlags — flag allowlist (audit #34)', () => {
it('accepts allowlisted flags with and without values', () => {
assert.deepEqual(sanitizeResticFlags('--no-cache --limit-upload=1024'), ['--no-cache', '--limit-upload=1024']);
});
it('accepts a SPACE-separated value-taking flag (the value is not a flag)', () => {
// `--retry-lock 10m` — the `10m` is the flag's value, not a positional. This
// used to be wrongly rejected as "Disallowed restic flag: 10m".
assert.deepEqual(sanitizeResticFlags('--verbose --retry-lock 10m'), ['--verbose', '--retry-lock', '10m']);
assert.deepEqual(sanitizeResticFlags('--compression max'), ['--compression', 'max']);
});
it('a value-taking flag missing its value is rejected', () => {
assert.throws(() => sanitizeResticFlags('--retry-lock'), /missing its value/);
});
it('returns [] for empty/undefined/whitespace', () => {
assert.deepEqual(sanitizeResticFlags(''), []);
assert.deepEqual(sanitizeResticFlags(undefined), []);
assert.deepEqual(sanitizeResticFlags(' '), []);
});
it('rejects dangerous flags like --option and --password-command', () => {
assert.throws(() => sanitizeResticFlags('--option=s3.storage-class=X'), /Disallowed restic flag/);
assert.throws(() => sanitizeResticFlags('--password-command=cat /etc/shadow'), /Disallowed restic flag/);
});
it('rejects a bare non-flag argument (no leading --)', () => {
assert.throws(() => sanitizeResticFlags('snapshots'), /Disallowed restic flag/);
assert.throws(() => sanitizeResticFlags('--no-cache evilpositional'), /Disallowed restic flag/);
});
});
describe('fireWebhook — per-config backup webhook delivery (audit #6/#46/#54)', () => {
// Injectable deps so we exercise the guard/branch logic with no real network/DNS.
function harness(over: Partial<Parameters<typeof fireWebhook>[2]> = {}) {
const calls: Array<{ url: string; init?: any }> = [];
const logs: string[] = [];
const deps = {
fetch: (async (url: any, init?: any) => { calls.push({ url: String(url), init }); return new Response('ok'); }) as any,
resolveHost: async () => ['93.184.216.34'], // a public IP by default
log: (m: string) => logs.push(m),
now: () => '2026-01-01T00:00:00.000Z',
...over,
};
return { calls, logs, deps };
}
it('POSTs a JSON body (with a stamped timestamp) to a safe public URL', async () => {
const { calls, deps } = harness();
await fireWebhook('https://hooks.example.com/backup', { event: 'backup_success', target: 'web' }, deps);
assert.equal(calls.length, 1);
assert.equal(calls[0].init.method, 'POST');
const body = JSON.parse(calls[0].init.body);
assert.equal(body.event, 'backup_success');
assert.equal(body.target, 'web');
assert.equal(body.timestamp, '2026-01-01T00:00:00.000Z');
});
it('blocks a loopback / cloud-metadata literal-IP URL BEFORE any fetch (SSRF)', async () => {
for (const bad of ['http://127.0.0.1/x', 'http://169.254.169.254/latest/meta-data/']) {
const { calls, logs, deps } = harness();
await fireWebhook(bad, { event: 'backup_success' }, deps);
assert.equal(calls.length, 0, `must not fetch ${bad}`);
assert.ok(logs.some(l => /blocked/i.test(l)));
}
});
it('ALLOWS a self-hosted LAN receiver (192.168.x / 10.x) — webhook trust model', async () => {
// Webhooks are notification-grade: a LAN catcher (ntfy/healthchecks on the
// user network, e.g. webhook.lan.example -> 10.0.0.5) must be reachable.
const { calls, deps } = harness({ resolveHost: async () => ['10.0.0.5'] });
await fireWebhook('https://webhook.lan.example/backup', { event: 'backup_success' }, deps);
assert.equal(calls.length, 1, 'a LAN webhook receiver must be allowed');
assert.equal(calls[0].init.method, 'POST');
});
it('blocks a public hostname that RESOLVES to loopback/metadata (DNS rebinding #54)', async () => {
const { calls, logs, deps } = harness({ resolveHost: async () => ['169.254.169.254'] });
await fireWebhook('https://sneaky.example.com/x', { event: 'backup_success' }, deps);
assert.equal(calls.length, 0, 'must not connect when the host resolves to a dangerous IP');
assert.ok(logs.some(l => /disallowed address|blocked/i.test(l)));
});
it('falls back to a bare GET when the POST throws', async () => {
let n = 0;
const seen: string[] = [];
const deps = harness().deps;
deps.fetch = (async (_url: any, init?: any) => {
seen.push(init?.method ?? 'GET');
if (n++ === 0) throw new Error('POST refused');
return new Response('ok');
}) as any;
await fireWebhook('https://hooks.example.com/ping', { event: 'backup_success' }, deps);
assert.deepEqual(seen, ['POST', 'GET']);
});
it('never throws even when both POST and GET fail (fire-and-forget)', async () => {
const deps = harness().deps;
deps.fetch = (async () => { throw new Error('network down'); }) as any;
// Must resolve, not reject — a webhook failure can never break a backup.
await fireWebhook('https://hooks.example.com/x', { event: 'backup_success' }, deps);
});
it('blocks (fail-closed) and never fetches when DNS resolution itself throws', async () => {
// If we can't resolve the host, we can't prove it's safe → block, don't fetch.
const { calls, logs, deps } = harness({
resolveHost: async () => { throw new Error('NXDOMAIN'); },
});
await fireWebhook('https://unresolvable.example.com/x', { event: 'backup_success' }, deps);
assert.equal(calls.length, 0, 'must not fetch when the host can not be resolved');
assert.ok(logs.some(l => /could not resolve|blocked/i.test(l)));
});
});