diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index b3f9793c7..04b11b9b4 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -2779,12 +2779,23 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); $secrets_hash = $this->generate_secrets_hash($variables); } - $this->build_args = $variables->map(function ($value, $key) { - $value = escapeshellarg($value); + $env_vars = $this->pull_request_id === 0 + ? $this->application->environment_variables()->where('is_buildtime', true)->get() + : $this->application->environment_variables_preview()->where('is_buildtime', true)->get(); - return "--build-arg {$key}={$value}"; + // Map variables to include is_multiline flag + $vars_with_metadata = $variables->map(function ($value, $key) use ($env_vars) { + $env = $env_vars->firstWhere('key', $key); + + return [ + 'key' => $key, + 'value' => $value, + 'is_multiline' => $env ? $env->is_multiline : false, + ]; }); + $this->build_args = generateDockerBuildArgs($vars_with_metadata); + if ($secrets_hash) { $this->build_args->push("--build-arg COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}"); } @@ -2807,14 +2818,17 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } $secrets_hash = $this->generate_secrets_hash($variables); - $env_flags = $variables - ->map(function ($env) { - $escaped_value = escapeshellarg($env->real_value); - return "-e {$env->key}={$escaped_value}"; - }) - ->implode(' '); + // Map to simple array format for the helper function + $vars_array = $variables->map(function ($env) { + return [ + 'key' => $env->key, + 'value' => $env->real_value, + 'is_multiline' => $env->is_multiline, + ]; + }); + $env_flags = generateDockerEnvFlags($vars_array); $env_flags .= " -e COOLIFY_BUILD_SECRETS_HASH={$secrets_hash}"; return $env_flags; diff --git a/bootstrap/helpers/docker.php b/bootstrap/helpers/docker.php index 1491e4712..af26c97bd 100644 --- a/bootstrap/helpers/docker.php +++ b/bootstrap/helpers/docker.php @@ -1119,3 +1119,64 @@ function escapeDollarSign($value) return str_replace($search, $replace, $value); } + +/** + * Generate Docker build arguments from environment variables collection + * + * @param \Illuminate\Support\Collection|array $variables Collection of variables with 'key', 'value', and optionally 'is_multiline' + * @return \Illuminate\Support\Collection Collection of formatted --build-arg strings + */ +function generateDockerBuildArgs($variables): \Illuminate\Support\Collection +{ + $variables = collect($variables); + + return $variables->map(function ($var) { + $key = is_array($var) ? data_get($var, 'key') : $var->key; + $value = is_array($var) ? data_get($var, 'value') : $var->value; + $isMultiline = is_array($var) ? data_get($var, 'is_multiline', false) : ($var->is_multiline ?? false); + + if ($isMultiline) { + // For multiline variables, strip surrounding quotes and escape for bash + $raw_value = trim($value, "'"); + $escaped_value = str_replace(['\\', '"', '$', '`'], ['\\\\', '\\"', '\\$', '\\`'], $raw_value); + + return "--build-arg {$key}=\"{$escaped_value}\""; + } + + // For regular variables, use escapeshellarg for security + $value = escapeshellarg($value); + + return "--build-arg {$key}={$value}"; + }); +} + +/** + * Generate Docker environment flags from environment variables collection + * + * @param \Illuminate\Support\Collection|array $variables Collection of variables with 'key', 'value', and optionally 'is_multiline' + * @return string Space-separated environment flags + */ +function generateDockerEnvFlags($variables): string +{ + $variables = collect($variables); + + return $variables + ->map(function ($var) { + $key = is_array($var) ? data_get($var, 'key') : $var->key; + $value = is_array($var) ? data_get($var, 'value') : $var->value; + $isMultiline = is_array($var) ? data_get($var, 'is_multiline', false) : ($var->is_multiline ?? false); + + if ($isMultiline) { + // For multiline variables, strip surrounding quotes and escape for bash + $raw_value = trim($value, "'"); + $escaped_value = str_replace(['\\', '"', '$', '`'], ['\\\\', '\\"', '\\$', '\\`'], $raw_value); + + return "-e {$key}=\"{$escaped_value}\""; + } + + $escaped_value = escapeshellarg($value); + + return "-e {$key}={$escaped_value}"; + }) + ->implode(' '); +} diff --git a/tests/Feature/MultilineEnvironmentVariableTest.php b/tests/Feature/MultilineEnvironmentVariableTest.php new file mode 100644 index 000000000..e32a2ce99 --- /dev/null +++ b/tests/Feature/MultilineEnvironmentVariableTest.php @@ -0,0 +1,208 @@ + 'SSH_PRIVATE_KEY', 'value' => "'{$sshKey}'", 'is_multiline' => true], + ['key' => 'REGULAR_VAR', 'value' => 'simple value', 'is_multiline' => false], + ]; + + $buildArgs = generateDockerBuildArgs($variables); + + // SSH key should use double quotes and have proper escaping + $sshArg = $buildArgs->first(); + expect($sshArg)->toStartWith('--build-arg SSH_PRIVATE_KEY="'); + expect($sshArg)->toEndWith('"'); + expect($sshArg)->toContain('BEGIN OPENSSH PRIVATE KEY'); + expect($sshArg)->not->toContain("'BEGIN"); // Should not have the wrapper single quotes + + // Regular var should use escapeshellarg (single quotes) + $regularArg = $buildArgs->last(); + expect($regularArg)->toBe("--build-arg REGULAR_VAR='simple value'"); +}); + +test('multiline variables with special bash characters are escaped correctly', function () { + $valueWithSpecialChars = "line1\nline2 with \"quotes\"\nline3 with \$variables\nline4 with `backticks`"; + + $variables = [ + ['key' => 'SPECIAL_VALUE', 'value' => "'{$valueWithSpecialChars}'", 'is_multiline' => true], + ]; + + $buildArgs = generateDockerBuildArgs($variables); + $arg = $buildArgs->first(); + + // Verify double quotes are escaped + expect($arg)->toContain('\\"quotes\\"'); + // Verify dollar signs are escaped + expect($arg)->toContain('\\$variables'); + // Verify backticks are escaped + expect($arg)->toContain('\\`backticks\\`'); +}); + +test('single-line environment variables use escapeshellarg', function () { + $variables = [ + ['key' => 'SIMPLE_VAR', 'value' => 'simple value with spaces', 'is_multiline' => false], + ]; + + $buildArgs = generateDockerBuildArgs($variables); + $arg = $buildArgs->first(); + + // Should use single quotes from escapeshellarg + expect($arg)->toBe("--build-arg SIMPLE_VAR='simple value with spaces'"); +}); + +test('multiline certificate with newlines is preserved', function () { + $certificate = '-----BEGIN CERTIFICATE----- +MIIDXTCCAkWgAwIBAgIJAKL0UG+mRkSvMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV +BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX +aWRnaXRzIFB0eSBMdGQwHhcNMTkwOTE3MDUzMzI5WhcNMjkwOTE0MDUzMzI5WjBF +-----END CERTIFICATE-----'; + + $variables = [ + ['key' => 'TLS_CERT', 'value' => "'{$certificate}'", 'is_multiline' => true], + ]; + + $buildArgs = generateDockerBuildArgs($variables); + $arg = $buildArgs->first(); + + // Newlines should be preserved in the output + expect($arg)->toContain("\n"); + expect($arg)->toContain('BEGIN CERTIFICATE'); + expect($arg)->toContain('END CERTIFICATE'); + expect(substr_count($arg, "\n"))->toBeGreaterThan(0); +}); + +test('multiline JSON configuration is properly escaped', function () { + $jsonConfig = '{ + "key": "value", + "nested": { + "array": [1, 2, 3] + } +}'; + + $variables = [ + ['key' => 'JSON_CONFIG', 'value' => "'{$jsonConfig}'", 'is_multiline' => true], + ]; + + $buildArgs = generateDockerBuildArgs($variables); + $arg = $buildArgs->first(); + + // All double quotes in JSON should be escaped + expect($arg)->toContain('\\"key\\"'); + expect($arg)->toContain('\\"value\\"'); + expect($arg)->toContain('\\"nested\\"'); +}); + +test('empty multiline variable is handled correctly', function () { + $variables = [ + ['key' => 'EMPTY_VAR', 'value' => "''", 'is_multiline' => true], + ]; + + $buildArgs = generateDockerBuildArgs($variables); + $arg = $buildArgs->first(); + + expect($arg)->toBe('--build-arg EMPTY_VAR=""'); +}); + +test('multiline variable with only newlines', function () { + $onlyNewlines = "\n\n\n"; + + $variables = [ + ['key' => 'NEWLINES_ONLY', 'value' => "'{$onlyNewlines}'", 'is_multiline' => true], + ]; + + $buildArgs = generateDockerBuildArgs($variables); + $arg = $buildArgs->first(); + + expect($arg)->toContain("\n"); + // Should have 3 newlines preserved + expect(substr_count($arg, "\n"))->toBe(3); +}); + +test('multiline variable with backslashes is escaped correctly', function () { + $valueWithBackslashes = "path\\to\\file\nC:\\Windows\\System32"; + + $variables = [ + ['key' => 'PATH_VAR', 'value' => "'{$valueWithBackslashes}'", 'is_multiline' => true], + ]; + + $buildArgs = generateDockerBuildArgs($variables); + $arg = $buildArgs->first(); + + // Backslashes should be doubled + expect($arg)->toContain('path\\\\to\\\\file'); + expect($arg)->toContain('C:\\\\Windows\\\\System32'); +}); + +test('generateDockerEnvFlags produces correct format', function () { + $variables = [ + ['key' => 'NORMAL_VAR', 'value' => 'value', 'is_multiline' => false], + ['key' => 'MULTILINE_VAR', 'value' => "'line1\nline2'", 'is_multiline' => true], + ]; + + $envFlags = generateDockerEnvFlags($variables); + + expect($envFlags)->toContain('-e NORMAL_VAR='); + expect($envFlags)->toContain('-e MULTILINE_VAR="'); + expect($envFlags)->toContain('line1'); + expect($envFlags)->toContain('line2'); +}); + +test('helper functions work with collection input', function () { + $variables = collect([ + (object) ['key' => 'VAR1', 'value' => 'value1', 'is_multiline' => false], + (object) ['key' => 'VAR2', 'value' => "'multiline\nvalue'", 'is_multiline' => true], + ]); + + $buildArgs = generateDockerBuildArgs($variables); + expect($buildArgs)->toHaveCount(2); + + $envFlags = generateDockerEnvFlags($variables); + expect($envFlags)->toBeString(); + expect($envFlags)->toContain('-e VAR1='); + expect($envFlags)->toContain('-e VAR2="'); +}); + +test('variables without is_multiline default to false', function () { + $variables = [ + ['key' => 'NO_FLAG_VAR', 'value' => 'some value'], + ]; + + $buildArgs = generateDockerBuildArgs($variables); + $arg = $buildArgs->first(); + + // Should use escapeshellarg (single quotes) since is_multiline defaults to false + expect($arg)->toBe("--build-arg NO_FLAG_VAR='some value'"); +}); + +test('real world SSH key example', function () { + // Simulate what real_value returns (wrapped in single quotes) + $sshKey = "'-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW +QyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevAAAAJi/QySHv0Mk +hwAAAAtzc2gtZWQyNTUxOQAAACBbhpqHhqv6aI67Mj9abM3DVbmcfYhZAhC7ca4d9UCevA +AAAECBQw4jg1WRT2IGHMncCiZhURCts2s24HoDS0thHnnRKVuGmoeGq/pojrsyP1pszcNV +uZx9iFkCELtxrh31QJ68AAAAEXNhaWxANzZmZjY2ZDJlMmRkAQIDBA== +-----END OPENSSH PRIVATE KEY-----'"; + + $variables = [ + ['key' => 'KEY', 'value' => $sshKey, 'is_multiline' => true], + ]; + + $buildArgs = generateDockerBuildArgs($variables); + $arg = $buildArgs->first(); + + // Should produce clean output without wrapper quotes + expect($arg)->toStartWith('--build-arg KEY="-----BEGIN OPENSSH PRIVATE KEY-----'); + expect($arg)->toEndWith('-----END OPENSSH PRIVATE KEY-----"'); + // Should NOT have the escaped quote sequence that was in the bug + expect($arg)->not->toContain("''"); + expect($arg)->not->toContain("'\\''"); +});