mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
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
286 lines
11 KiB
PHP
286 lines
11 KiB
PHP
<?php
|
|
|
|
/**
|
|
* validateDockerComposeForInjection() must block shell injection without rejecting valid Compose files.
|
|
* Git-based Docker Compose applications and Services use the same rules.
|
|
*/
|
|
function composeTemplateFiles(): array
|
|
{
|
|
$files = glob(dirname(__DIR__, 2).'/templates/compose/*.{yaml,yml}', GLOB_BRACE);
|
|
|
|
return collect($files)->mapWithKeys(fn (string $file): array => [basename($file) => [$file]])->all();
|
|
}
|
|
|
|
test('every service template passes the Compose injection validator', function (string $file) {
|
|
validateDockerComposeForInjection(file_get_contents($file));
|
|
|
|
expect(true)->toBeTrue();
|
|
})->with(composeTemplateFiles());
|
|
|
|
test('the service template corpus is not empty', function () {
|
|
expect(count(composeTemplateFiles()))->toBeGreaterThan(300);
|
|
});
|
|
|
|
test('realistic application Compose files pass the injection validator', function (string $compose) {
|
|
validateDockerComposeForInjection($compose);
|
|
|
|
expect(true)->toBeTrue();
|
|
})->with([
|
|
'long syntax volumes, bind mounts and named volumes' => <<<'YAML'
|
|
services:
|
|
app:
|
|
build:
|
|
context: ./apps/web
|
|
dockerfile: Dockerfile.prod
|
|
args:
|
|
NODE_ENV: production
|
|
volumes:
|
|
- type: bind
|
|
source: ./config/app.conf
|
|
target: /etc/app/app.conf
|
|
read_only: true
|
|
- type: bind
|
|
source: /srv/shared/uploads
|
|
target: /app/uploads
|
|
- type: volume
|
|
source: app-cache
|
|
target: /app/cache
|
|
volume:
|
|
nocopy: true
|
|
- type: tmpfs
|
|
target: /app/tmp
|
|
- ./data:/app/data
|
|
- ./logs:/app/logs:rw
|
|
- /var/run/docker.sock:/var/run/docker.sock:ro
|
|
- ~/backups:/backups
|
|
- app-data:/var/lib/app
|
|
- "./my data:/app/spaced"
|
|
volumes:
|
|
app-cache:
|
|
app-data:
|
|
driver: local
|
|
YAML,
|
|
'variables in images, environment, ports, labels, build contexts and volumes' => <<<'YAML'
|
|
services:
|
|
api:
|
|
image: ${REGISTRY:-ghcr.io}/acme/api:${API_TAG:-latest}
|
|
build:
|
|
context: ${BUILD_CONTEXT:-.}
|
|
args:
|
|
- VERSION=${VERSION}
|
|
environment:
|
|
DATABASE_URL: postgres://${DB_USER}:${DB_PASS:-secret}@db:5432/${DB_NAME}
|
|
FEATURE_FLAGS: "${FLAGS:-a,b;c|d&e}"
|
|
SERVICE_FQDN_API_3000: /api
|
|
ports:
|
|
- "${API_PORT:-3000}:3000"
|
|
labels:
|
|
- traefik.http.routers.api.rule=Host(`${API_HOST}`) && PathPrefix(`/api`)
|
|
- "com.example.description=Backend > frontend | edge"
|
|
volumes:
|
|
- ${DATA_DIR:-./data}:/data
|
|
- ${HOME}/.config/api:/config:ro
|
|
- type: bind
|
|
source: ${CERT_DIR:-./certs}
|
|
target: /certs
|
|
- type: bind
|
|
source: ${PWD}/secrets
|
|
target: /run/secrets/app
|
|
YAML,
|
|
'network names that mix variables and text' => <<<'YAML'
|
|
services:
|
|
web:
|
|
image: nginx:1.27
|
|
networks:
|
|
- project
|
|
- env
|
|
- plain
|
|
networks:
|
|
project:
|
|
name: ${COMPOSE_PROJECT_NAME}_default
|
|
env:
|
|
name: app-${APP_ENV:-prod}-net
|
|
plain:
|
|
external: true
|
|
name: $SHARED_PREFIX.edge
|
|
YAML,
|
|
'networks with variable name field and service network forms' => <<<'YAML'
|
|
services:
|
|
web:
|
|
image: nginx:1.27
|
|
networks:
|
|
- frontend
|
|
- backend
|
|
worker:
|
|
image: acme/worker
|
|
networks:
|
|
backend:
|
|
aliases:
|
|
- queue-worker
|
|
ipv4_address: 172.20.0.10
|
|
shared:
|
|
networks:
|
|
frontend:
|
|
backend:
|
|
driver: bridge
|
|
ipam:
|
|
config:
|
|
- subnet: 172.20.0.0/16
|
|
shared:
|
|
external: true
|
|
name: ${SHARED_NETWORK:-edge.proxy_net}
|
|
YAML,
|
|
'anchors, merge keys, x- extensions and profiles' => <<<'YAML'
|
|
x-logging: &default-logging
|
|
driver: json-file
|
|
options:
|
|
max-size: "10m"
|
|
x-common: &common
|
|
restart: unless-stopped
|
|
logging: *default-logging
|
|
environment: &common-env
|
|
TZ: UTC
|
|
volumes:
|
|
- ./shared:/shared:ro
|
|
services:
|
|
web:
|
|
<<: *common
|
|
image: nginx
|
|
profiles: [frontend]
|
|
debug:
|
|
<<: *common
|
|
image: busybox
|
|
profiles:
|
|
- debug
|
|
environment:
|
|
<<: *common-env
|
|
DEBUG: "1"
|
|
YAML,
|
|
'depends_on conditions, healthchecks, commands and entrypoints with shell syntax' => <<<'YAML'
|
|
services:
|
|
db:
|
|
image: postgres:16
|
|
healthcheck:
|
|
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB} || exit 1"]
|
|
interval: 5s
|
|
timeout: 5s
|
|
retries: 10
|
|
cache:
|
|
image: redis:7
|
|
command: redis-server --requirepass "$${REDIS_PASSWORD}" --save 60 1 > /dev/null
|
|
healthcheck:
|
|
test: redis-cli -a "$$REDIS_PASSWORD" ping | grep PONG && echo ok; exit 0
|
|
app:
|
|
image: acme/app
|
|
entrypoint: ["/bin/sh", "-c"]
|
|
command:
|
|
- |
|
|
until nc -z db 5432; do sleep 1; done
|
|
php artisan migrate --force && exec php-fpm < /dev/null
|
|
working_dir: /var/www
|
|
depends_on:
|
|
db:
|
|
condition: service_healthy
|
|
cache:
|
|
condition: service_started
|
|
restart: true
|
|
cron:
|
|
image: acme/app
|
|
command: sh -c 'echo "$(date) start" >> /tmp/cron.log & crond -f'
|
|
depends_on: [app]
|
|
YAML,
|
|
'configs, secrets, service names with dots and dashes' => <<<'YAML'
|
|
services:
|
|
api.v2:
|
|
image: acme/api
|
|
secrets:
|
|
- db_password
|
|
configs:
|
|
- source: app_config
|
|
target: /etc/app/config.yml
|
|
web-frontend_1:
|
|
image: acme/web
|
|
container_name: my-web
|
|
secrets:
|
|
db_password:
|
|
file: ./secrets/db_password.txt
|
|
configs:
|
|
app_config:
|
|
content: |
|
|
key: value; other | thing
|
|
YAML,
|
|
]);
|
|
|
|
test('Compose injection payloads are rejected', function (string $compose, string $message) {
|
|
expect(fn () => validateDockerComposeForInjection($compose))->toThrow(Exception::class, $message);
|
|
})->with([
|
|
'service name command substitution' => ["services:\n web\$(touch /tmp/pwned):\n image: nginx\n", 'Invalid Docker Compose service name'],
|
|
'service name command separator' => ["services:\n 'web;touch /tmp/pwned':\n image: nginx\n", 'Invalid Docker Compose service name'],
|
|
'service name backtick' => ["services:\n 'web`id`':\n image: nginx\n", 'Invalid Docker Compose service name'],
|
|
'string volume source' => ["services:\n web:\n image: nginx\n volumes:\n - './data\$(touch /tmp/pwned):/data'\n", 'Invalid Docker volume definition'],
|
|
'long volume source' => ["services:\n web:\n image: nginx\n volumes:\n - type: bind\n source: '/srv;touch /tmp/pwned'\n target: /data\n", 'Invalid Docker volume definition'],
|
|
'long volume variable default' => ["services:\n web:\n image: nginx\n volumes:\n - type: bind\n source: '\${DATA:-/srv\$(touch /tmp/pwned)}'\n target: /data\n", 'Invalid Docker volume definition'],
|
|
'service network key' => ["services:\n web:\n image: nginx\n networks:\n 'net\$(touch /tmp/pwned)': {}\n", 'Invalid Docker Compose service network'],
|
|
'service network list item' => ["services:\n web:\n image: nginx\n networks:\n - 'net;touch /tmp/pwned'\n", 'Invalid Docker Compose service network'],
|
|
'top-level network key' => ["services:\n web:\n image: nginx\nnetworks:\n 'net\$(touch /tmp/pwned)':\n driver: bridge\n", 'Invalid Docker Compose network name'],
|
|
'top-level network name field' => ["services:\n web:\n image: nginx\nnetworks:\n edge:\n name: 'edge;touch /tmp/pwned'\n", 'Invalid Docker Compose network name field'],
|
|
'mixed network name with command substitution' => ["services:\n web:\n image: nginx\nnetworks:\n edge:\n name: '\${PREFIX}\$(touch /tmp/pwned)'\n", 'Invalid Docker Compose network name field'],
|
|
'mixed network name with a separator' => ["services:\n web:\n image: nginx\nnetworks:\n edge:\n name: '\${PREFIX}_net;touch /tmp/pwned'\n", 'Invalid Docker Compose network name field'],
|
|
'mixed network name with an unsafe default' => ["services:\n web:\n image: nginx\nnetworks:\n edge:\n name: 'app-\${ENV:-x y}'\n", 'Invalid Docker Compose network name field'],
|
|
'network name variable with an unsafe default' => ["services:\n web:\n image: nginx\nnetworks:\n edge:\n name: '\${NET:-x\$(touch /tmp/pwned)}'\n", 'Invalid Docker Compose network name field'],
|
|
]);
|
|
|
|
test('service network list entries accept the same variables as network names', function (string $network) {
|
|
$compose = "services:\n web:\n image: nginx\n networks:\n - '{$network}'\nnetworks:\n proxy:\n external: true\n";
|
|
|
|
expect(fn () => validateDockerComposeForInjection($compose))->not->toThrow(Exception::class);
|
|
})->with([
|
|
'variable with a default' => ['${NET:-proxy}'],
|
|
'variable with an unset-only default' => ['${NET-proxy}'],
|
|
'braced variable' => ['${NET}'],
|
|
'plain variable' => ['$NET'],
|
|
'variable mixed with text' => ['${COMPOSE_PROJECT_NAME}_default'],
|
|
'text with a variable default' => ['app-${ENV:-prod}'],
|
|
'required variable' => ['${NET?error}'],
|
|
'required non-empty variable with a message' => ['${NET:?NET must be set}'],
|
|
'variable with an alternative value' => ['${NET:+proxy}'],
|
|
'text with a required variable' => ['app-${ENV?error}'],
|
|
]);
|
|
|
|
test('a network name field can be a required variable', function () {
|
|
$compose = "services:\n web:\n image: nginx\n networks:\n - example\nnetworks:\n example:\n name: '\${CONTAINER_NAME?error}'\n";
|
|
|
|
expect(fn () => validateDockerComposeForInjection($compose))->not->toThrow(Exception::class);
|
|
});
|
|
|
|
test('unsafe service network list entries with variables are rejected', function (string $network) {
|
|
$compose = "services:\n web:\n image: nginx\n networks:\n - '{$network}'\n";
|
|
|
|
expect(fn () => validateDockerComposeForInjection($compose))->toThrow(Exception::class, 'Invalid Docker Compose service network');
|
|
})->with([
|
|
'command substitution' => ['$(id)'],
|
|
'backticks' => ['`id`'],
|
|
'variable with command substitution' => ['${NET}$(id)'],
|
|
'default with command substitution' => ['${NET:-$(id)}'],
|
|
'default with a space' => ['${NET:-x y}'],
|
|
'default with backticks' => ['${NET:-`id`}'],
|
|
'error message with command substitution' => ['${NET?$(id)}'],
|
|
'error message with a separator' => ['${NET:?x;id}'],
|
|
'alternative value with a space' => ['${NET:+x y}'],
|
|
]);
|
|
|
|
test('a service network key with a variable is rejected because Compose does not interpolate keys', function () {
|
|
$compose = "services:\n web:\n image: nginx\n networks:\n '\${NET:-proxy}': {}\n";
|
|
|
|
expect(fn () => validateDockerComposeForInjection($compose))->toThrow(Exception::class, 'Invalid Docker Compose service network');
|
|
});
|
|
|
|
test('network errors name the service and the invalid value', function () {
|
|
$compose = "services:\n web:\n image: nginx\n networks:\n - 'net;id'\n";
|
|
|
|
expect(fn () => validateDockerComposeForInjection($compose))
|
|
->toThrow(Exception::class, 'Invalid Docker Compose service network "net;id" in service web. Network names must start with an alphanumeric character and contain only alphanumeric characters, dots, hyphens, and underscores, and can use variables such as ${NETWORK:-default}.');
|
|
expect(fn () => validateDockerComposeForInjection("services:\n web:\n image: nginx\nnetworks:\n 'net;id': {}\n"))
|
|
->toThrow(Exception::class, 'Invalid Docker Compose network name "net;id".');
|
|
});
|