mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-12 15:53:46 -04:00
- Remove tests that only check markup, CSS, static text, wire:key strings, framework behavior, copied app logic, or expect(true) - Replace source-grep and Reflection checks of security fixes with tests that run the real code (shell quoting, locked properties, 403 checks, webhook fork trust, log redaction, Sentinel token cleanup) - Assert response bodies and unchanged data on API error paths - Run the Traefik version job through handle() instead of private methods - Update stale tests for scheduled task props, the default Postgres image, and the disabled transfer link of unvalidated servers Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
109 lines
5.2 KiB
PHP
109 lines
5.2 KiB
PHP
<?php
|
|
|
|
use App\Models\Application;
|
|
use App\Models\Service;
|
|
use Symfony\Component\Yaml\Yaml;
|
|
|
|
/**
|
|
* Coolify writes the `content:` of a Compose bind volume to the host. Only administrators can edit
|
|
* a Compose file, and they can mount any host path, so the source can be any host path. It must
|
|
* still be safe to use in a shell command.
|
|
*/
|
|
function contentVolumeCompose(string $source, array $extra = ['content' => "key=value\n"]): string
|
|
{
|
|
return Yaml::dump([
|
|
'services' => [
|
|
'app' => [
|
|
'image' => 'nginx:alpine',
|
|
'volumes' => [
|
|
array_merge(['type' => 'bind', 'source' => $source, 'target' => '/etc/app.conf'], $extra),
|
|
],
|
|
],
|
|
],
|
|
], 10, 2);
|
|
}
|
|
|
|
it('accepts content volumes with any host path, like Coolify v4.3.23', function (string $source) {
|
|
expect(fn () => validateDockerComposeForInjection(contentVolumeCompose($source)))->not->toThrow(Exception::class);
|
|
})->with([
|
|
'absolute host file' => ['/etc/myapp/app.conf'],
|
|
'home directory' => ['~/x/app.conf'],
|
|
'bare relative path' => ['app.conf'],
|
|
'parent directory after ./' => ['./../shared/app.conf'],
|
|
'parent directory' => ['../shared/app.conf'],
|
|
'relative path' => ['./config/app.conf'],
|
|
'braced variable with a path' => ['${DATA_DIR}/app.conf'],
|
|
'single quote' => ["/etc/my'app/app.conf"],
|
|
]);
|
|
|
|
it('rejects shell injection in a content volume source', function (string $source) {
|
|
expect(fn () => validateDockerComposeForInjection(contentVolumeCompose($source)))
|
|
->toThrow(Exception::class, 'Invalid Docker volume definition (array syntax)');
|
|
})->with([
|
|
'command substitution' => ['/etc/$(id)/app.conf'],
|
|
'backtick' => ['/etc/`id`/app.conf'],
|
|
'command separator' => ['/etc/app.conf;id'],
|
|
'pipe' => ['/etc/app.conf|id'],
|
|
'background operator' => ['/etc/app.conf&id'],
|
|
'redirect' => ['/etc/app.conf>/root/x'],
|
|
'newline' => ["/etc/app.conf\nid"],
|
|
'control character' => ["/etc/app\x01.conf"],
|
|
'command substitution in a variable default' => ['${DATA:-/etc/$(id)}'],
|
|
]);
|
|
|
|
it('rejects a content volume without a source', function () {
|
|
$compose = "services:\n app:\n image: nginx\n volumes:\n - type: bind\n target: /etc/app.conf\n content: x\n";
|
|
|
|
expect(fn () => validateDockerComposeForInjection($compose))
|
|
->toThrow(Exception::class, 'A bind volume with content needs a source path.');
|
|
});
|
|
|
|
it('does not change bind volumes without content', function (string $compose) {
|
|
expect(fn () => validateDockerComposeForInjection($compose))->not->toThrow(Exception::class);
|
|
})->with([
|
|
'docker socket short syntax' => ["services:\n app:\n image: nginx\n volumes:\n - /var/run/docker.sock:/var/run/docker.sock\n"],
|
|
'docker socket long syntax' => ["services:\n app:\n image: nginx\n volumes:\n - type: bind\n source: /var/run/docker.sock\n target: /var/run/docker.sock\n"],
|
|
'absolute host directory' => ["services:\n app:\n image: nginx\n volumes:\n - type: bind\n source: /srv/shared\n target: /shared\n is_directory: true\n"],
|
|
'home directory' => ["services:\n app:\n image: nginx\n volumes:\n - type: bind\n source: ~/booklore\n target: /bookdrop\n is_directory: true\n"],
|
|
'variable with default' => ["services:\n app:\n image: nginx\n volumes:\n - type: bind\n source: \${FOUNDRY_DATA:-/data/foundryvtt}\n target: /data\n is_directory: true\n"],
|
|
'parent directory short syntax' => ["services:\n app:\n image: nginx\n volumes:\n - ../shared:/shared\n"],
|
|
]);
|
|
|
|
it('ignores content on named volumes because Coolify does not write it', function () {
|
|
expect(fn () => validateDockerComposeForInjection(contentVolumeCompose('app-data', ['type' => 'volume', 'content' => 'x'])))->not->toThrow(Exception::class);
|
|
});
|
|
|
|
it('resolves the directory the parsers use for each resource', function () {
|
|
$service = new Service;
|
|
$service->uuid = 'service-uuid';
|
|
$service->compose_parsing_version = '5';
|
|
$legacyService = new Service;
|
|
$legacyService->uuid = 'legacy-uuid';
|
|
$legacyService->compose_parsing_version = '3';
|
|
$application = new Application;
|
|
$application->uuid = 'application-uuid';
|
|
|
|
expect(composeResourceDirectory($service))->toBe('/data/coolify/services/service-uuid')
|
|
->and(composeResourceDirectory($legacyService))->toBe('/data/coolify/applications/legacy-uuid')
|
|
->and(composeResourceDirectory($application))->toBe('/data/coolify/applications/application-uuid');
|
|
});
|
|
|
|
/**
|
|
* @return array<string, array{string}>
|
|
*/
|
|
function composeTemplatesWithContentVolumes(): array
|
|
{
|
|
$templates = [];
|
|
foreach (glob(dirname(__DIR__, 2).'/templates/compose/*.{yaml,yml}', GLOB_BRACE) as $file) {
|
|
if (preg_match('/^\s+content:/m', file_get_contents($file))) {
|
|
$templates[basename($file)] = [$file];
|
|
}
|
|
}
|
|
|
|
return $templates;
|
|
}
|
|
|
|
it('accepts every service template with content volumes', function (string $file) {
|
|
expect(fn () => validateDockerComposeForInjection(file_get_contents($file)))->not->toThrow(Exception::class);
|
|
})->with(composeTemplatesWithContentVolumes());
|