'router1', 'hostname' => '10.0.0.1']); $this->assertSame('router1', thold_substitute_host_data('|host_description|', '|', '|', 2)); // The second call must not query again: it is served from the cache // populated by the first. $this->assertCount(1, CactiStubs::callsTo('db_fetch_row_prepared')); $this->assertSame('router1', thold_substitute_host_data('|host_description|', '|', '|', 2)); $this->assertCount(1, CactiStubs::callsTo('db_fetch_row_prepared')); } /** * @return void */ public function testCachedHostColumnIsEscapedInShellMode(): void { $malicious = "evil'; touch /tmp/pwned; echo '"; CactiStubs::willReturn('db_fetch_row_prepared', ['description' => $malicious, 'hostname' => '10.0.0.1']); $result = thold_substitute_host_data('|host_description|', '|', '|', 3, true); $this->assertSame(escapeshellarg($malicious), $result); } /** * |host_management_ip| is not a plain device column: it falls through to * the hostname-substitution path rather than the cache-hit return. * * @return void */ public function testManagementIpFallsThroughToTheHostnameSubstitution(): void { CactiStubs::willReturn('db_fetch_row_prepared', ['description' => 'router1', 'hostname' => '10.0.0.1']); $this->assertSame('ping 10.0.0.1', thold_substitute_host_data('ping |host_management_ip|', '|', '|', 4)); } /** * @return void */ public function testManagementIpIsEscapedInShellMode(): void { $malicious = "10.0.0.1'; touch /tmp/pwned; echo '"; CactiStubs::willReturn('db_fetch_row_prepared', ['description' => 'router1', 'hostname' => $malicious]); $result = thold_substitute_host_data('ping |host_management_ip|', '|', '|', 5, true); $this->assertSame('ping ' . escapeshellarg($malicious), $result); } /** * The custom_* tokens come from data source input fields (e.g. an SNMP * community string), which is data an operator, not an admin, controls. * * @return void */ public function testCustomDataValueIsEscapedInShellMode(): void { $malicious = "public'; touch /tmp/pwned; echo '"; CactiStubs::willReturn('db_fetch_assoc', [['id' => 9]]); CactiStubs::willReturn('db_fetch_assoc_prepared', [ ['name' => 'snmp_community', 'value' => $malicious], ]); $result = thold_substitute_custom_data('alert |custom_snmp_community|', '|', '|', 4, true); $this->assertSame('alert ' . escapeshellarg($malicious), $result); } /** * @return void */ public function testCustomDataValueIsUnescapedOutsideShellMode(): void { CactiStubs::willReturn('db_fetch_assoc', [['id' => 9]]); CactiStubs::willReturn('db_fetch_assoc_prepared', [ ['name' => 'snmp_community', 'value' => 'public'], ]); $result = thold_substitute_custom_data('alert |custom_snmp_community|', '|', '|', 4, false); $this->assertSame('alert public', $result); } /** * A single strtr() pass replaces against the original string only and * never re-scans inserted values, so one field's value containing * another field's literal token can't be substituted a second time * outside of its own quoting. * * @return void */ public function testShellModeDoesNotReSubstituteATokenLiteralInsideAnotherValue(): void { $malicious = "'; touch /tmp/pwned; echo '"; CactiStubs::willReturn('db_fetch_assoc', [['id' => 9]]); CactiStubs::willReturn('db_fetch_assoc_prepared', [ ['name' => 'description', 'value' => '|custom_secret|'], ['name' => 'secret', 'value' => $malicious], ]); $result = thold_substitute_custom_data('cmd |custom_description| |custom_secret|', '|', '|', 4, true); $this->assertSame( 'cmd ' . escapeshellarg('|custom_secret|') . ' ' . escapeshellarg($malicious), $result ); } }