mirror of
https://github.com/Vateron-Media/XC_VM.git
synced 2026-11-07 03:03:35 +01:00
Address the Sourcery review of the helper's certbot verb:
- Critical: certbot ran under --config-dir /home/xc_vm/bin/certbot/config, a dir
the xc_vm user owns (it owns the parent bin/, so it can recreate it however it
likes). A compromised xc_vm could plant certbot config/renewal hooks there for
root to read. Run certbot under its root-owned defaults (/etc/letsencrypt,
/var/lib/letsencrypt, /var/log/letsencrypt) instead — xc_vm cannot touch them.
- High: certbot's output is root-owned, so the xc_vm agent could not read the
private key to install it and the front kept its self-signed cert. The helper
(root) now installs the newest live cert as bin/nginx/conf/server.{crt,key},
chowned xc_vm (key 0600), and fails if no live cert was produced.
- Medium: remove a stale .certbot.lock before running, as CertbotCommand does.
The Fanout agent's syncFrontCert is now redundant (a companion change drops it).
295 lines
14 KiB
PHP
295 lines
14 KiB
PHP
<?php
|
|
|
|
use PHPUnit\Framework\TestCase;
|
|
|
|
/**
|
|
* Contract tests for the root-owned serve-only helper (src/bin/install/xcvm-noderoot).
|
|
* The script is exec'd against a sandbox root (XCVM_NODEROOT_ROOT) with hostile
|
|
* input: every invalid call must exit non-zero and change nothing, and only the
|
|
* two hardcoded ipsets may ever be named.
|
|
*/
|
|
final class XcvmNoderootHelperTest extends TestCase {
|
|
private string $root;
|
|
|
|
protected function setUp(): void {
|
|
$this->root = sys_get_temp_dir() . '/noderoot-' . bin2hex(random_bytes(6));
|
|
foreach (['etc', 'home/xc_vm/tmp/cluster', 'home/xc_vm/content/streams'] as $d) {
|
|
mkdir($this->root . '/' . $d, 0777, true);
|
|
}
|
|
file_put_contents($this->root . '/etc/sysctl.conf', "net.core.somaxconn = 1\n");
|
|
file_put_contents($this->root . '/etc/fstab', "UUID=x / ext4 defaults 0 1\n#tmpfs /home/xc_vm/content/streams tmpfs defaults,size=50% 0 0\n");
|
|
}
|
|
|
|
protected function tearDown(): void {
|
|
exec('rm -rf ' . escapeshellarg($this->root));
|
|
}
|
|
|
|
/** @return array{0:int,1:string} */
|
|
private function helper(array $args, array $env = []): array {
|
|
$cmd = array_merge(['/bin/sh', MAIN_HOME . 'bin/install/xcvm-noderoot'], $args);
|
|
$p = proc_open($cmd, [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes, null, ['XCVM_NODEROOT_ROOT' => $this->root, 'PATH' => '/usr/bin:/bin'] + $env);
|
|
$out = stream_get_contents($pipes[1]) . stream_get_contents($pipes[2]);
|
|
return [proc_close($p), $out];
|
|
}
|
|
|
|
private function stage(string $name, string $body): void {
|
|
file_put_contents($this->root . '/home/xc_vm/tmp/cluster/' . $name, $body);
|
|
}
|
|
|
|
private function calls(): string {
|
|
return is_file($this->root . '/calls.log') ? (string) file_get_contents($this->root . '/calls.log') : '';
|
|
}
|
|
|
|
public function testSysctlAcceptsNetKeysAndWritesThem(): void {
|
|
$this->stage('sysctl.pending', "# c\nnet.ipv4.tcp_syncookies = 1\nnet.core.somaxconn=4096\n");
|
|
[$rc] = $this->helper(['sysctl']);
|
|
$this->assertSame(0, $rc);
|
|
$this->assertSame("net.ipv4.tcp_syncookies = 1\nnet.core.somaxconn = 4096\n", file_get_contents($this->root . '/etc/sysctl.conf'));
|
|
$this->assertStringContainsString('sysctl -p', $this->calls());
|
|
}
|
|
|
|
/** @dataProvider badSysctl */
|
|
public function testSysctlRejectsWholeBodyAndChangesNothing(string $body): void {
|
|
$this->stage('sysctl.pending', $body);
|
|
[$rc] = $this->helper(['sysctl']);
|
|
$this->assertNotSame(0, $rc);
|
|
$this->assertSame("net.core.somaxconn = 1\n", file_get_contents($this->root . '/etc/sysctl.conf'));
|
|
$this->assertSame('', $this->calls());
|
|
}
|
|
|
|
public static function badSysctl(): array {
|
|
return [
|
|
'core_pattern' => ["kernel.core_pattern=|/x\n"],
|
|
'mixed' => ["net.ipv4.ip_forward = 1\nkernel.core_pattern = |/x\n"],
|
|
'modprobe' => ["kernel.modprobe = /evil\n"],
|
|
'dotdot' => ["net..x = 1\n"],
|
|
'slash' => ["net/../kernel/core_pattern = 1\n"],
|
|
'pipe value' => ["net.core.x = 1|/evil\n"],
|
|
'prefix trick' => ["network.x = 1\n"],
|
|
'hotplug' => ["kernel.hotplug = /x\n"],
|
|
'other kernel' => ["kernel.shmmni = 1\n"],
|
|
'kernel.shmmax prefix' => ["kernel.shmmax.evil = 1\n"],
|
|
'usermodehelper' => ["kernel.usermodehelper.bset = 1\n"],
|
|
'poweroff' => ["kernel.poweroff_cmd = /x\n"],
|
|
'mixed net fs vm + core_pattern' => ["net.core.somaxconn = 1\nfs.file-max = 5\nvm.max_map_count = 6\nkernel.core_pattern = |/x\n"],
|
|
'suid_dumpable' => ["fs.suid_dumpable = 2\n"],
|
|
'protected_symlinks' => ["fs.protected_symlinks = 0\n"],
|
|
'mmap_min_addr' => ["vm.mmap_min_addr = 0\n"],
|
|
'userfaultfd' => ["vm.unprivileged_userfaultfd = 1\n"],
|
|
'drop_caches' => ["vm.drop_caches = 3\n"],
|
|
'mixed with stray vm' => ["net.core.somaxconn = 1\nvm.mmap_min_addr = 0\n"],
|
|
'bare fs' => ["fs = 1\n"],
|
|
'continuation' => ["net.core.x = 1\\\nkernel.core_pattern = x\n"],
|
|
];
|
|
}
|
|
|
|
public function testSysctlAcceptsMainsRealBody(): void {
|
|
$body = implode("\n", ['# XC_VM', '', 'net.core.somaxconn = 655350', 'net.ipv4.route.flush=1', 'fs.file-max = 6815744', 'fs.aio-max-nr = 6815744', 'fs.nr_open = 6815744', 'net.ipv4.ip_local_port_range = 1024 65000', 'net.ipv4.tcp_rmem = 10000000 10000000 10000000', 'net.netfilter.nf_conntrack_max=1215196608', 'vm.max_map_count = 655300', 'net.ipv6.conf.all.disable_ipv6 = 1', 'kernel.shmmax=134217728', 'kernel.shmall=134217728', 'vm.overcommit_memory = 1', 'net.ipv4.tcp_tw_reuse=1']);
|
|
$this->stage('sysctl.pending', $body);
|
|
[$rc] = $this->helper(['sysctl']);
|
|
$this->assertSame(0, $rc);
|
|
$w = (string) file_get_contents($this->root . '/etc/sysctl.conf');
|
|
foreach (['fs.file-max = 6815744', 'vm.max_map_count = 655300', 'kernel.shmmax = 134217728', 'kernel.shmall = 134217728', 'vm.overcommit_memory = 1', 'net.core.somaxconn = 655350'] as $l) {
|
|
$this->assertStringContainsString($l . "\n", $w);
|
|
}
|
|
$this->assertStringNotContainsString('core_pattern', $w);
|
|
}
|
|
|
|
public function testBanSyncBuildsRestoreNamingOnlyTheTwoSets(): void {
|
|
$this->stage('bans.pending', "1.2.3.4\n8.8.0.0/16\n2001:4860::8888\n2001:4860::8888\n\n");
|
|
[$rc] = $this->helper(['ban-sync']);
|
|
$this->assertSame(0, $rc);
|
|
$r = (string) file_get_contents($this->root . '/ipset.restore');
|
|
$this->assertStringContainsString("add xcvm_block4_new 1.2.3.4\n", $r);
|
|
$this->assertStringContainsString("add xcvm_block4_new 8.8.0.0/16\n", $r);
|
|
$this->assertSame(1, substr_count($r, "add xcvm_block6_new 2001:4860::8888\n"));
|
|
foreach (explode("\n", trim($r)) as $line) {
|
|
preg_match_all('/xcvm_block\S*/', $line, $m);
|
|
foreach ($m[0] as $name) {
|
|
$this->assertMatchesRegularExpression('/^xcvm_block[46](_new)?$/', $name);
|
|
}
|
|
$this->assertMatchesRegularExpression('/^(create|flush|add|swap|destroy) /', $line);
|
|
}
|
|
$this->assertStringContainsString('-C INPUT -m set --match-set xcvm_block4 src -j DROP', $this->calls());
|
|
$this->assertStringContainsString('-C INPUT -m set --match-set xcvm_block6 src -j DROP', $this->calls());
|
|
}
|
|
|
|
/** @dataProvider badBans */
|
|
public function testBanSyncRejectsWholeFileAndTouchesNothing(string $body): void {
|
|
$this->stage('bans.pending', "1.2.3.4\n" . $body);
|
|
[$rc] = $this->helper(['ban-sync']);
|
|
$this->assertNotSame(0, $rc);
|
|
$this->assertFileDoesNotExist($this->root . '/ipset.restore');
|
|
$this->assertSame('', $this->calls());
|
|
}
|
|
|
|
public static function badBans(): array {
|
|
return [
|
|
'default route' => ["0.0.0.0/0\n"],
|
|
'v6 default' => ["::/0\n"],
|
|
'wide cidr' => ["8.0.0.0/8\n"],
|
|
'non-ip' => ["example.com\n"],
|
|
'ipset directive' => ["flush othergset\n"],
|
|
'injected add' => ["1.1.1.1 othergset\n"],
|
|
'octet' => ["256.1.1.1\n"],
|
|
'leading zero' => ["01.1.1.1\n"],
|
|
'v6 cidr' => ["2001:4860::/32\n"],
|
|
'v6 junk' => ["2001:::1\n"],
|
|
'comment' => ["# x\n"],
|
|
];
|
|
}
|
|
|
|
public function testBanFlushNamesOnlyTheTwoSets(): void {
|
|
[$rc] = $this->helper(['ban-flush']);
|
|
$this->assertSame(0, $rc);
|
|
$this->assertSame("ipset flush xcvm_block4\nipset flush xcvm_block6\n", $this->calls());
|
|
}
|
|
|
|
public function testRamdiskOnUncommentsTheLineAndMounts(): void {
|
|
[$rc] = $this->helper(['ramdisk', 'on']);
|
|
$this->assertSame(0, $rc);
|
|
$this->assertStringContainsString("\ntmpfs /home/xc_vm/content/streams tmpfs defaults,size=50% 0 0\n", file_get_contents($this->root . '/etc/fstab'));
|
|
$this->assertSame("mount /home/xc_vm/content/streams\nchown -hR xc_vm:xc_vm /home/xc_vm/content/streams\n", $this->calls());
|
|
$this->assertSame(0, $this->helper(['ramdisk', 'off'])[0]);
|
|
$this->assertStringContainsString("\n#tmpfs /home/xc_vm/content/streams tmpfs defaults,size=50% 0 0\n", file_get_contents($this->root . '/etc/fstab'));
|
|
$this->assertStringContainsString('umount -l /home/xc_vm/content/streams', $this->calls());
|
|
}
|
|
|
|
public function testRamdiskFailsWithoutExactlyOneLine(): void {
|
|
file_put_contents($this->root . '/etc/fstab', "UUID=x / ext4 defaults 0 1\n");
|
|
[$rc] = $this->helper(['ramdisk', 'on']);
|
|
$this->assertNotSame(0, $rc);
|
|
$this->assertSame("UUID=x / ext4 defaults 0 1\n", file_get_contents($this->root . '/etc/fstab'));
|
|
$this->assertSame('', $this->calls());
|
|
}
|
|
|
|
public function testGovernorOnlyValidKnownNames(): void {
|
|
file_put_contents($this->root . '/cpufreq-info.out', "performance powersave\n");
|
|
$this->assertSame(0, $this->helper(['governor', 'performance'])[0]);
|
|
$this->assertStringContainsString('cpufreq-set -c 0 -g performance', $this->calls());
|
|
$before = $this->calls();
|
|
$this->assertNotSame(0, $this->helper(['governor', 'x;reboot'])[0]);
|
|
$this->assertNotSame(0, $this->helper(['governor', 'Performance'])[0]);
|
|
$this->assertSame(0, $this->helper(['governor', 'ondemand'])[0], 'unknown governor is a no-op');
|
|
$this->assertSame($before, $this->calls());
|
|
}
|
|
|
|
/** @dataProvider badCalls */
|
|
public function testMalformedCallsRejectedWithNoEffect(array $args): void {
|
|
[$rc] = $this->helper($args);
|
|
$this->assertNotSame(0, $rc);
|
|
$this->assertSame('', $this->calls());
|
|
$this->assertSame("net.core.somaxconn = 1\n", file_get_contents($this->root . '/etc/sysctl.conf'));
|
|
}
|
|
|
|
public static function badCalls(): array {
|
|
return [
|
|
'no verb' => [[]],
|
|
'unknown verb' => [['rm']],
|
|
'ramdisk ..' => [['ramdisk', '../..']],
|
|
'ramdisk evil' => [['ramdisk', 'evil']],
|
|
'service bogus' => [['service', 'bogus']],
|
|
'service extra' => [['service', 'start', 'reboot']],
|
|
'reboot extra' => [['reboot', 'now']],
|
|
'ban-flush extra' => [['ban-flush', 'x']],
|
|
'ban-sync extra' => [['ban-sync', 'x']],
|
|
'sysctl extra' => [['sysctl', '/etc/passwd']],
|
|
'ramdisk extra' => [['ramdisk', 'on', 'off']],
|
|
];
|
|
}
|
|
|
|
public function testHostileTmpdirIsIgnored(): void {
|
|
// Nonexistent: a helper that honors TMPDIR fails its mktemp; one that pins /tmp does not.
|
|
$evil = $this->root . '/evil-tmp-missing';
|
|
$this->stage('sysctl.pending', "net.core.somaxconn = 9\n");
|
|
$this->stage('bans.pending', "1.2.3.4\n");
|
|
foreach ([['sysctl'], ['ban-sync']] as $a) {
|
|
$this->assertSame(0, $this->helper($a, ['TMPDIR' => $evil])[0]);
|
|
}
|
|
$this->assertDirectoryDoesNotExist($evil);
|
|
}
|
|
|
|
public function testServiceAndRebootRunTheirFixedCommands(): void {
|
|
$this->assertSame(0, $this->helper(['service', 'restart'])[0]);
|
|
$this->assertSame(0, $this->helper(['reboot'])[0]);
|
|
$this->assertSame("systemctl restart xc_vm\nreboot\n", $this->calls());
|
|
}
|
|
|
|
/** Stage a certbot live/ dir the (logged, not executed) certbot run would produce. */
|
|
private function stageLive(string $domain, string $cert, string $key): void {
|
|
$live = $this->root . '/etc/letsencrypt/live/' . $domain;
|
|
mkdir($live, 0755, true);
|
|
file_put_contents($live . '/fullchain.pem', $cert);
|
|
file_put_contents($live . '/privkey.pem', $key);
|
|
}
|
|
|
|
// certbot verb (XC_VM_Fanout#129): runs certbot --webroot, one -d per validated
|
|
// domain, under certbot's ROOT-OWNED default dirs — never a --config-dir under
|
|
// the xc_vm-writable /home/xc_vm, so a compromised xc_vm cannot plant certbot
|
|
// config/hooks for root to read. The cert is then installed for the xc_vm front.
|
|
public function testCertbotRunsWithFixedPathsAndValidatedDomains(): void {
|
|
$this->stageLive('cdn1.example.com', 'CERTDATA', 'KEYDATA');
|
|
[$rc, $out] = $this->helper(['certbot', 'cdn1.example.com', 'cdn2.example.com']);
|
|
$this->assertSame(0, $rc, $out);
|
|
$c = $this->calls();
|
|
$this->assertStringContainsString('certbot certonly', $c);
|
|
$this->assertStringContainsString('--webroot -w /home/xc_vm/certbot-webroot', $c);
|
|
$this->assertStringContainsString('-d cdn1.example.com -d cdn2.example.com', $c);
|
|
$this->assertStringContainsString('--non-interactive', $c);
|
|
}
|
|
|
|
public function testCertbotUsesRootOwnedDefaultDirsNotTheXcVmTree(): void {
|
|
// Critical (Sourcery): root certbot must not read xc_vm-writable config.
|
|
$this->stageLive('cdn1.example.com', 'CERTDATA', 'KEYDATA');
|
|
[$rc] = $this->helper(['certbot', 'cdn1.example.com']);
|
|
$this->assertSame(0, $rc);
|
|
$c = $this->calls();
|
|
$this->assertStringNotContainsString('/home/xc_vm/bin/certbot', $c, 'certbot config must not live under the xc_vm-writable tree');
|
|
$this->assertStringNotContainsString('--config-dir', $c, 'use certbot root-owned defaults');
|
|
}
|
|
|
|
public function testCertbotClearsAStaleLockFirst(): void {
|
|
// Medium (Sourcery): a stale lock from a killed run makes certbot refuse.
|
|
$this->stageLive('cdn1.example.com', 'CERTDATA', 'KEYDATA');
|
|
[$rc] = $this->helper(['certbot', 'cdn1.example.com']);
|
|
$this->assertSame(0, $rc);
|
|
$this->assertStringContainsString('.certbot.lock', $this->calls(), 'the stale lock is removed before certbot runs');
|
|
}
|
|
|
|
public function testCertbotInstallsTheIssuedCertForTheXcVmFront(): void {
|
|
// High (Sourcery): certbot's files are root-owned; the xc_vm front cannot
|
|
// read the key. The helper must hand server.{crt,key} to xc_vm.
|
|
$this->stageLive('cdn1.example.com', 'CERTDATA', 'KEYDATA');
|
|
[$rc, $out] = $this->helper(['certbot', 'cdn1.example.com']);
|
|
$this->assertSame(0, $rc, $out);
|
|
$conf = $this->root . '/home/xc_vm/bin/nginx/conf/';
|
|
$this->assertSame('CERTDATA', (string) @file_get_contents($conf . 'server.crt'), 'the front cert is installed');
|
|
$this->assertSame('KEYDATA', (string) @file_get_contents($conf . 'server.key'), 'the front key is installed');
|
|
$this->assertSame(0600, fileperms($conf . 'server.key') & 0777, 'the key is private (0600)');
|
|
$this->assertStringContainsString('chown xc_vm:xc_vm', $this->calls(), 'the cert is handed to the xc_vm agent');
|
|
}
|
|
|
|
public function testCertbotFailsWhenNoLiveCertWasProduced(): void {
|
|
// No live dir (issuance really failed): the helper must not report success.
|
|
[$rc] = $this->helper(['certbot', 'cdn1.example.com']);
|
|
$this->assertNotSame(0, $rc);
|
|
}
|
|
|
|
public function testCertbotNeedsAtLeastOneDomain(): void {
|
|
[$rc] = $this->helper(['certbot']);
|
|
$this->assertNotSame(0, $rc);
|
|
$this->assertSame('', $this->calls());
|
|
}
|
|
|
|
/** @dataProvider badDomains */
|
|
public function testCertbotRejectsBadDomainsAndRunsNothing(string $bad): void {
|
|
[$rc] = $this->helper(['certbot', 'good.example', $bad]);
|
|
$this->assertNotSame(0, $rc);
|
|
$this->assertSame('', $this->calls(), 'a bad domain must run certbot with nothing');
|
|
}
|
|
|
|
public static function badDomains(): array {
|
|
return [['-x'], ['--server=evil'], ['a;rm -rf /'], ['a b'], ['a/../b'], ['a..b'], ['$(id)'], ['a`id`'], ['a|b'], ['']];
|
|
}
|
|
}
|