Files

203 lines
9.1 KiB
TypeScript

/**
* Unit tests for backups/security.ts — the path-containment + snapshot-path
* guards, plus a sanity check that the re-exported hardened primitives still
* reject the known-dangerous inputs (env leak, flag injection, SSRF).
*/
import { describe, it, expect } from 'bun:test';
import {
isSafeVolumeInclude,
assertSafeVolumeInclude,
isPathWithin,
assertTargetWithin,
isSafePathSegment,
buildResticEnv,
sanitizeResticFlags,
filterCloudEnvVars,
isSafeWebhookUrl,
unsafeRestoreTargetReason,
assertSafeRestoreTarget,
} from '../../src/lib/server/backups/security';
describe('isSafeVolumeInclude (command-injection guard on restore includes)', () => {
it('accepts plain /volumes/<name>', () => {
expect(isSafeVolumeInclude('/volumes/data')).toBe(true);
expect(isSafeVolumeInclude('/volumes/my-vol_1.2')).toBe(true);
});
it('accepts the bare /metadata dir (config-only snapshot restore)', () => {
expect(isSafeVolumeInclude('/metadata')).toBe(true);
// only the exact literal — no arbitrary subpath under /metadata
expect(isSafeVolumeInclude('/metadata/evil')).toBe(false);
expect(isSafeVolumeInclude('/metadata/')).toBe(false);
});
it('rejects anything with shell metacharacters or a different root', () => {
expect(isSafeVolumeInclude('/volumes/$(id)')).toBe(false);
expect(isSafeVolumeInclude('/volumes/a;rm -rf /')).toBe(false);
expect(isSafeVolumeInclude('/volumes/a b')).toBe(false);
expect(isSafeVolumeInclude('/etc/passwd')).toBe(false);
expect(isSafeVolumeInclude('/volumes/')).toBe(false);
expect(isSafeVolumeInclude('/volumes/../etc')).toBe(false);
});
it('assert variant throws on bad input', () => {
expect(() => assertSafeVolumeInclude('/volumes/ok')).not.toThrow();
expect(() => assertSafeVolumeInclude('/volumes/$(x)')).toThrow(/Unsafe restore include/);
});
});
describe('isPathWithin (restore-target containment)', () => {
it('accepts a path under the root, including the root itself', () => {
expect(isPathWithin('/data/stacks', '/data/stacks')).toBe(true);
expect(isPathWithin('/data/stacks', '/data/stacks/web')).toBe(true);
expect(isPathWithin('/data/stacks', '/data/stacks/web/compose.yaml')).toBe(true);
});
it('rejects a sibling-prefix escape (the classic containment bug)', () => {
// /data/stacks-evil is NOT under /data/stacks even though it shares the prefix
expect(isPathWithin('/data/stacks', '/data/stacks-evil')).toBe(false);
});
it('rejects a traversal escape', () => {
expect(isPathWithin('/data/stacks', '/data/stacks/../../etc/cron.d')).toBe(false);
expect(isPathWithin('/data/stacks', '/etc/passwd')).toBe(false);
});
it('assert variant throws with a clear message', () => {
expect(() => assertTargetWithin('/data/stacks', '/data/stacks/ok')).not.toThrow();
expect(() => assertTargetWithin('/data/stacks', '/etc', 'stack dir')).toThrow(/stack dir.*outside/);
});
});
describe('unsafeRestoreTargetReason: /usr and .ssh takeover targets', () => {
it('blocks anywhere under /usr (PATH hijack, systemd units) - it holds no user data', () => {
for (const p of [
'/usr/local/bin/docker-entrypoint',
'/usr/lib/systemd/system/evil.service',
'/usr/local/share/app',
'/usr',
]) {
expect(unsafeRestoreTargetReason(p)).not.toBeNull();
}
});
it('blocks any .ssh directory segment (SSH-key takeover under any home)', () => {
for (const p of [
'/home/user/.ssh/authorized_keys',
'/root/.ssh/authorized_keys',
'/data/.ssh/keys',
]) {
expect(unsafeRestoreTargetReason(p)).not.toBeNull();
}
});
it('still allows a user-home data path and an .ssh-lookalike segment', () => {
expect(unsafeRestoreTargetReason('/home/user/backup')).toBeNull();
expect(unsafeRestoreTargetReason('/home/user/data/.sshconfig')).toBeNull();
expect(unsafeRestoreTargetReason('/mnt/.ssh-backups/x')).toBeNull();
});
it('boundary-safe: a sibling that merely shares a forbidden prefix is allowed', () => {
expect(unsafeRestoreTargetReason('/usr-data/x')).toBeNull();
expect(unsafeRestoreTargetReason('/home2/restore')).toBeNull();
});
});
describe('isSafePathSegment (name traversal guard)', () => {
it('accepts a plain name', () => {
expect(isSafePathSegment('web')).toBe(true);
expect(isSafePathSegment('my-stack_1')).toBe(true);
});
it('rejects empty, separators, and dot-dot', () => {
expect(isSafePathSegment('')).toBe(false);
expect(isSafePathSegment('a/b')).toBe(false);
expect(isSafePathSegment('a\\b')).toBe(false);
expect(isSafePathSegment('..')).toBe(false);
expect(isSafePathSegment('.')).toBe(false);
expect(isSafePathSegment('a..b')).toBe(false);
});
});
describe('re-exported hardened primitives still reject dangerous inputs', () => {
it('buildResticEnv never leaks the master key / arbitrary process env', () => {
const env = buildResticEnv(
{ ENCRYPTION_KEY: 'secret', DATABASE_URL: 'postgres://x', PATH: '/usr/bin' },
{ repository: 'repo', password: 'pw', envVars: {} }
);
expect(env.ENCRYPTION_KEY).toBeUndefined();
expect(env.DATABASE_URL).toBeUndefined();
expect(env.RESTIC_REPOSITORY).toBe('repo');
expect(env.RESTIC_PASSWORD).toBe('pw');
});
it('buildResticEnv drops PATH/LD_PRELOAD injection via destination envVars', () => {
const env = buildResticEnv(
{},
{ repository: 'r', password: 'p', envVars: { LD_PRELOAD: '/evil.so', PATH: '/evil', AWS_ACCESS_KEY_ID: 'ok' } }
);
expect(env.LD_PRELOAD).toBeUndefined();
// a real cloud credential still passes the allowlist
expect(env.AWS_ACCESS_KEY_ID).toBe('ok');
});
it('filterCloudEnvVars keeps cloud creds, drops the rest', () => {
const out = filterCloudEnvVars({ AWS_SECRET_ACCESS_KEY: 'k', EVIL: 'x', PATH: '/evil' });
expect(out.AWS_SECRET_ACCESS_KEY).toBe('k');
expect(out.EVIL).toBeUndefined();
expect(out.PATH).toBeUndefined();
});
it('sanitizeResticFlags rejects dangerous flags, accepts allowlisted ones', () => {
expect(sanitizeResticFlags('--limit-upload=1000')).toEqual(['--limit-upload=1000']);
expect(() => sanitizeResticFlags('--password-command=evil')).toThrow();
expect(() => sanitizeResticFlags('--option=x')).toThrow();
});
it('sanitizeResticFlags handles space-separated values without treating them as flags', () => {
// The value token after a space-separated value-taking flag legitimately
// does not start with `--` and must NOT be rejected as a bare arg.
expect(sanitizeResticFlags('--retry-lock 10m')).toEqual(['--retry-lock', '10m']);
// A value-taking flag followed by another flag: value consumed, flag kept.
expect(sanitizeResticFlags('--limit-upload 500 --no-cache')).toEqual(['--limit-upload', '500', '--no-cache']);
expect(sanitizeResticFlags('--compression max --json')).toEqual(['--compression', 'max', '--json']);
});
it('sanitizeResticFlags rejects a value-taking flag with no value, and bare non-flag args', () => {
expect(() => sanitizeResticFlags('--retry-lock')).toThrow(/missing its value/);
// A bare token that isn't the value of a preceding value-taking flag is an arg, not a flag.
expect(() => sanitizeResticFlags('evil')).toThrow(/Disallowed/);
// The allowlist is case-sensitive: an upper-cased known flag is NOT allowed.
expect(() => sanitizeResticFlags('--LIMIT-UPLOAD=1')).toThrow(/Disallowed/);
});
it('sanitizeResticFlags treats empty / whitespace / nullish input as no flags', () => {
expect(sanitizeResticFlags('')).toEqual([]);
expect(sanitizeResticFlags(' ')).toEqual([]);
expect(sanitizeResticFlags(null)).toEqual([]);
expect(sanitizeResticFlags(undefined)).toEqual([]);
});
it('isSafeWebhookUrl blocks loopback/private/metadata and non-http', () => {
expect(isSafeWebhookUrl('https://example.com/hook').ok).toBe(true);
expect(isSafeWebhookUrl('http://169.254.169.254/latest').ok).toBe(false);
expect(isSafeWebhookUrl('http://127.0.0.1/x').ok).toBe(false);
expect(isSafeWebhookUrl('http://10.0.0.5/x').ok).toBe(false);
expect(isSafeWebhookUrl('file:///etc/passwd').ok).toBe(false);
});
});
describe('unsafeRestoreTargetReason (new-location targetPath containment)', () => {
it('accepts legitimate operator-chosen restore locations', () => {
// /usr/local/share is NOT here: /usr is blocked wholesale (host-takeover surface).
for (const p of ['/mnt/restore', '/srv/appA/data', '/home/user/backup', '/opt/data', '/data/x']) {
expect(unsafeRestoreTargetReason(p)).toBeNull();
}
});
it('rejects protected system roots and their children', () => {
for (const p of ['/', '/etc', '/etc/cron.d', '/root/.ssh', '/var/run/docker.sock', '/bin', '/usr/../etc', '/var/lib/docker/volumes']) {
expect(unsafeRestoreTargetReason(p)).not.toBeNull();
}
});
it('rejects relative, empty, and traversal-escaping paths', () => {
expect(unsafeRestoreTargetReason('')).not.toBeNull();
expect(unsafeRestoreTargetReason('relative/path')).not.toBeNull();
expect(unsafeRestoreTargetReason('/srv/../etc')).not.toBeNull(); // resolves into /etc
expect(unsafeRestoreTargetReason('/etc/')).not.toBeNull(); // trailing slash normalizes
});
it('assertSafeRestoreTarget throws for unsafe, passes for safe', () => {
expect(() => assertSafeRestoreTarget('/etc/passwd')).toThrow();
expect(() => assertSafeRestoreTarget('/')).toThrow();
expect(() => assertSafeRestoreTarget('/mnt/restore')).not.toThrow();
});
});