Merge remote-tracking branch 'origin/next' into api-sensitive-data-scrubber

This commit is contained in:
Andras Bacsai committed 2026-07-02 15:57:43 +02:00
commit 13172849e1
117 files changed
+5114 -1799

No files matched your search

@@ -0,0 +1,16 @@
<?php
namespace App\Actions\Destination;
use App\Models\StandaloneDocker;
class RemoveStandaloneDockerNetwork
{
public function handle(StandaloneDocker $destination): void
{
$safeNetwork = escapeshellarg($destination->network);
instant_remote_process(["docker network disconnect {$safeNetwork} coolify-proxy"], $destination->server, throwError: false);
instant_remote_process(["docker network rm -f {$safeNetwork}"], $destination->server);
}
}
@@ -2943,8 +2943,12 @@ class ApplicationsController extends Controller
$this->authorize('manageEnvironment', $application);
if ($request->has('key')) {
$request->merge(['key' => ValidationPatterns::normalizeEnvironmentVariableKey((string) $request->key)]);
}
$validator = customApiValidator($request->all(), [
'key' => 'string|required',
'key' => ValidationPatterns::environmentVariableKeyRules(),
'value' => 'string|nullable',
'is_preview' => 'boolean',
'is_literal' => 'boolean',
@@ -3167,12 +3171,18 @@ class ApplicationsController extends Controller
], 400);
}
$bulk_data = collect($bulk_data)->map(function ($item) {
return collect($item)->only(['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime', 'comment']);
$item = collect($item)->only(['key', 'value', 'is_preview', 'is_literal', 'is_multiline', 'is_shown_once', 'is_runtime', 'is_buildtime', 'comment']);
if ($item->has('key')) {
$item->put('key', ValidationPatterns::normalizeEnvironmentVariableKey((string) $item->get('key')));
}
return $item;
});
$returnedEnvs = collect();
foreach ($bulk_data as $item) {
$validator = customApiValidator($item, [
'key' => 'string|required',
'key' => ValidationPatterns::environmentVariableKeyRules(),
'value' => 'string|nullable',
'is_preview' => 'boolean',
'is_literal' => 'boolean',
@@ -3369,8 +3379,12 @@ class ApplicationsController extends Controller
$this->authorize('manageEnvironment', $application);
if ($request->has('key')) {
$request->merge(['key' => ValidationPatterns::normalizeEnvironmentVariableKey((string) $request->key)]);
}
$validator = customApiValidator($request->all(), [
'key' => 'string|required',
'key' => ValidationPatterns::environmentVariableKeyRules(),
'value' => 'string|nullable',
'is_preview' => 'boolean',
'is_literal' => 'boolean',
@@ -4333,10 +4347,11 @@ class ApplicationsController extends Controller
'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
'content' => 'string|nullable',
'is_directory' => 'boolean',
'is_host_file' => 'boolean',
'fs_path' => 'string',
]);
$allAllowedFields = ['type', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'fs_path'];
$allAllowedFields = ['type', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'is_host_file', 'fs_path'];
$extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
@@ -4360,7 +4375,7 @@ class ApplicationsController extends Controller
], 422);
}
$typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'fs_path'], array_keys($request->all()));
$typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'is_host_file', 'fs_path'], array_keys($request->all()));
if (! empty($typeSpecificInvalidFields)) {
return response()->json([
'message' => 'Validation failed.',
@@ -4391,6 +4406,14 @@ class ApplicationsController extends Controller
}
$isDirectory = $request->boolean('is_directory', false);
$isHostFile = $request->boolean('is_host_file', false);
if ($isDirectory && $isHostFile) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['is_host_file' => 'Host file mounts cannot also be directory mounts.'],
], 422);
}
if ($isDirectory) {
if (! $request->fs_path) {
@@ -4413,12 +4436,50 @@ class ApplicationsController extends Controller
'resource_id' => $application->id,
'resource_type' => get_class($application),
]);
} elseif ($isHostFile) {
if (! $request->fs_path) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['fs_path' => 'The fs_path field is required for host file mounts.'],
], 422);
}
if ($request->filled('content')) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['content' => 'Content is not valid for host file mounts.'],
], 422);
}
try {
$fsPath = validateHostFileMountPath($request->fs_path, 'host file source path');
$mountPath = validateFileMountPath($request->mount_path, 'host file destination path');
} catch (\Throwable $e) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['mount_path' => $e->getMessage()],
], 422);
}
$storage = LocalFileVolume::create([
'fs_path' => $fsPath,
'mount_path' => $mountPath,
'content' => null,
'is_directory' => false,
'is_host_file' => true,
'resource_id' => $application->id,
'resource_type' => get_class($application),
]);
} else {
$mountPath = str($request->mount_path)->trim()->start('/')->value();
validateShellSafePath($mountPath, 'file storage path');
$fsPath = application_configuration_dir().'/'.$application->uuid.$mountPath;
try {
$mountPath = validateFileMountPath($request->mount_path, 'file storage path');
$fsPath = confineFileMountPath(application_configuration_dir().'/'.$application->uuid, $mountPath, 'file storage path');
} catch (\Throwable $e) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['mount_path' => $e->getMessage()],
], 422);
}
$storage = LocalFileVolume::create([
'fs_path' => $fsPath,
@@ -3207,8 +3207,12 @@ class DatabasesController extends Controller
$this->authorize('manageEnvironment', $database);
if ($request->has('key')) {
$request->merge(['key' => ValidationPatterns::normalizeEnvironmentVariableKey((string) $request->key)]);
}
$validator = customApiValidator($request->all(), [
'key' => 'string|required',
'key' => ValidationPatterns::environmentVariableKeyRules(),
'value' => 'string|nullable',
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
@@ -3355,8 +3359,12 @@ class DatabasesController extends Controller
$updatedEnvs = collect();
foreach ($bulk_data as $item) {
if (array_key_exists('key', $item)) {
$item['key'] = ValidationPatterns::normalizeEnvironmentVariableKey((string) $item['key']);
}
$validator = customApiValidator($item, [
'key' => 'string|required',
'key' => ValidationPatterns::environmentVariableKeyRules(),
'value' => 'string|nullable',
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
@@ -3473,8 +3481,12 @@ class DatabasesController extends Controller
$this->authorize('manageEnvironment', $database);
if ($request->has('key')) {
$request->merge(['key' => ValidationPatterns::normalizeEnvironmentVariableKey((string) $request->key)]);
}
$validator = customApiValidator($request->all(), [
'key' => 'string|required',
'key' => ValidationPatterns::environmentVariableKeyRules(),
'value' => 'string|nullable',
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
@@ -3759,10 +3771,11 @@ class DatabasesController extends Controller
'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
'content' => 'string|nullable',
'is_directory' => 'boolean',
'is_host_file' => 'boolean',
'fs_path' => 'string',
]);
$allAllowedFields = ['type', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'fs_path'];
$allAllowedFields = ['type', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'is_host_file', 'fs_path'];
$extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
@@ -3786,7 +3799,7 @@ class DatabasesController extends Controller
], 422);
}
$typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'fs_path'], array_keys($request->all()));
$typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'is_host_file', 'fs_path'], array_keys($request->all()));
if (! empty($typeSpecificInvalidFields)) {
return response()->json([
'message' => 'Validation failed.',
@@ -3817,6 +3830,14 @@ class DatabasesController extends Controller
}
$isDirectory = $request->boolean('is_directory', false);
$isHostFile = $request->boolean('is_host_file', false);
if ($isDirectory && $isHostFile) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['is_host_file' => 'Host file mounts cannot also be directory mounts.'],
], 422);
}
if ($isDirectory) {
if (! $request->fs_path) {
@@ -3839,12 +3860,50 @@ class DatabasesController extends Controller
'resource_id' => $database->id,
'resource_type' => get_class($database),
]);
} elseif ($isHostFile) {
if (! $request->fs_path) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['fs_path' => 'The fs_path field is required for host file mounts.'],
], 422);
}
if ($request->filled('content')) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['content' => 'Content is not valid for host file mounts.'],
], 422);
}
try {
$fsPath = validateHostFileMountPath($request->fs_path, 'host file source path');
$mountPath = validateFileMountPath($request->mount_path, 'host file destination path');
} catch (\Throwable $e) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['mount_path' => $e->getMessage()],
], 422);
}
$storage = LocalFileVolume::create([
'fs_path' => $fsPath,
'mount_path' => $mountPath,
'content' => null,
'is_directory' => false,
'is_host_file' => true,
'resource_id' => $database->id,
'resource_type' => get_class($database),
]);
} else {
$mountPath = str($request->mount_path)->trim()->start('/')->value();
validateShellSafePath($mountPath, 'file storage path');
$fsPath = database_configuration_dir().'/'.$database->uuid.$mountPath;
try {
$mountPath = validateFileMountPath($request->mount_path, 'file storage path');
$fsPath = confineFileMountPath(database_configuration_dir().'/'.$database->uuid, $mountPath, 'file storage path');
} catch (\Throwable $e) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['mount_path' => $e->getMessage()],
], 422);
}
$storage = LocalFileVolume::create([
'fs_path' => $fsPath,
@@ -0,0 +1,239 @@
<?php
namespace App\Http\Controllers\Api;
use App\Actions\Destination\RemoveStandaloneDockerNetwork;
use App\Http\Controllers\Controller;
use App\Models\Server;
use App\Models\StandaloneDocker;
use App\Models\SwarmDocker;
use Illuminate\Database\QueryException;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class DestinationsController extends Controller
{
private function transform(StandaloneDocker|SwarmDocker $destination): array
{
return [
'uuid' => $destination->uuid,
'name' => $destination->name,
'network' => $destination->network,
'type' => $destination instanceof SwarmDocker ? 'swarm' : 'standalone',
'server_uuid' => $destination->server?->uuid,
'created_at' => $destination->created_at,
'updated_at' => $destination->updated_at,
];
}
/**
* Resolve the calling token's team id, or return an invalid-token response.
*/
private function teamIdOrAbort(): int|JsonResponse
{
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
return invalidTokenResponse();
}
return $teamId;
}
/**
* StandaloneDocker / SwarmDocker scoped to a team via their parent server.
* Uses whereHas instead of the model's ownedByCurrentTeamAPI() scope so the
* controller works on Coolify versions that pre-date that scope being added
* to the destination models (e.g. 4.0.0-beta.470).
*/
private function teamScopedDockers(int $teamId): array
{
return [
'standalone' => StandaloneDocker::with('server:id,uuid')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->get(),
'swarm' => SwarmDocker::with('server:id,uuid')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->get(),
];
}
private function findDestinationForTeam(int $teamId, string $uuid): StandaloneDocker|SwarmDocker
{
return StandaloneDocker::with('server:id,uuid,team_id,ip,user,port,private_key_id')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->whereUuid($uuid)->first()
?? SwarmDocker::with('server:id,uuid,team_id')->whereHas('server', fn ($query) => $query->whereTeamId($teamId))->whereUuid($uuid)->firstOrFail();
}
public function index(Request $request): JsonResponse
{
$teamId = $this->teamIdOrAbort();
if (! is_int($teamId)) {
return $teamId;
}
$sets = $this->teamScopedDockers($teamId);
return response()->json(
$sets['standalone']->concat($sets['swarm'])
->map(fn ($destination) => $this->transform($destination))
->values()
);
}
public function index_by_server(Request $request, string $server_uuid): JsonResponse
{
$teamId = $this->teamIdOrAbort();
if (! is_int($teamId)) {
return $teamId;
}
$server = Server::with(['standaloneDockers.server:id,uuid', 'swarmDockers.server:id,uuid'])
->whereTeamId($teamId)
->whereUuid($server_uuid)
->firstOrFail();
$list = $server->standaloneDockers->concat($server->swarmDockers);
return response()->json($list->map(fn ($destination) => $this->transform($destination))->values());
}
public function show(Request $request, string $uuid): JsonResponse
{
$teamId = $this->teamIdOrAbort();
if (! is_int($teamId)) {
return $teamId;
}
$destination = $this->findDestinationForTeam($teamId, $uuid);
return response()->json($this->transform($destination));
}
public function create(Request $request, string $server_uuid): JsonResponse
{
$teamId = $this->teamIdOrAbort();
if (! is_int($teamId)) {
return $teamId;
}
$return = validateIncomingRequest($request);
if ($return instanceof JsonResponse) {
return $return;
}
$server = Server::whereTeamId($teamId)->whereUuid($server_uuid)->firstOrFail();
$allowed = ['name', 'network', 'type'];
$validator = customApiValidator($request->all(), [
'name' => 'nullable|string|max:255',
'network' => ['required', 'string', 'max:255', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/'],
'type' => 'nullable|in:standalone,swarm',
]);
$extra = array_diff(array_keys($request->all()), $allowed);
if ($validator->fails() || ! empty($extra)) {
$errors = $validator->errors();
if (! empty($extra)) {
foreach ($extra as $field) {
$errors->add($field, 'This field is not allowed.');
}
}
return response()->json(['message' => 'Validation failed.', 'errors' => $errors], 422);
}
$expectedType = $server->isSwarm() ? 'swarm' : 'standalone';
$type = $request->input('type', $expectedType);
if ($type !== $expectedType) {
return response()->json(['message' => "Destination type must be {$expectedType} for this server."], 422);
}
$name = $request->input('name') ?: ($server->name.'-'.$request->input('network'));
$class = $type === 'swarm' ? SwarmDocker::class : StandaloneDocker::class;
$this->authorize('create', $class);
$exists = $class::where('server_id', $server->id)->where('network', $request->input('network'))->exists();
if ($exists) {
return response()->json(['message' => 'A destination with this network already exists on the server.'], 409);
}
try {
$destination = $class::create([
'name' => $name,
'network' => $request->input('network'),
'server_id' => $server->id,
]);
} catch (QueryException $exception) {
if ($this->isUniqueConstraintViolation($exception)) {
return response()->json(['message' => 'A destination with this network already exists on the server.'], 409);
}
throw $exception;
}
auditLog('api.destination.created', [
'team_id' => $teamId,
'destination_uuid' => $destination->uuid,
'destination_name' => $destination->name,
'destination_type' => $type,
'server_uuid' => $server->uuid,
]);
return response()->json($this->transform($destination->load('server:id,uuid')), 201);
}
private function isUniqueConstraintViolation(QueryException $exception): bool
{
$sqlState = $exception->errorInfo[0] ?? null;
$driverCode = (string) ($exception->errorInfo[1] ?? $exception->getCode());
return in_array($sqlState, ['23000', '23505'], true)
|| in_array($driverCode, ['19', '1062', '2067'], true);
}
public function delete(Request $request, string $uuid): JsonResponse
{
$teamId = $this->teamIdOrAbort();
if (! is_int($teamId)) {
return $teamId;
}
$destination = $this->findDestinationForTeam($teamId, $uuid);
$this->authorize('delete', $destination);
// Guard against deleting destinations with attached resources. attachedTo()
// is recent on the destination models; fall back to a manual check for
// older Coolify versions (e.g. 4.0.0-beta.470).
if (method_exists($destination, 'attachedTo')) {
if ($destination->attachedTo()) {
return response()->json(['message' => 'Destination has attached resources, detach first.'], 409);
}
} else {
$hasAttached = $destination->applications()->exists()
|| $destination->postgresqls()->exists()
|| (method_exists($destination, 'mysqls') && $destination->mysqls()->exists())
|| (method_exists($destination, 'mariadbs') && $destination->mariadbs()->exists())
|| (method_exists($destination, 'mongodbs') && $destination->mongodbs()->exists())
|| (method_exists($destination, 'redis') && $destination->redis()->exists())
|| (method_exists($destination, 'keydbs') && $destination->keydbs()->exists())
|| (method_exists($destination, 'dragonflies') && $destination->dragonflies()->exists())
|| (method_exists($destination, 'clickhouses') && $destination->clickhouses()->exists())
|| (method_exists($destination, 'services') && $destination->services()->exists());
if ($hasAttached) {
return response()->json(['message' => 'Destination has attached resources, detach first.'], 409);
}
}
if ($destination instanceof StandaloneDocker) {
app(RemoveStandaloneDockerNetwork::class)->handle($destination);
}
$destinationUuid = $destination->uuid;
$destinationName = $destination->name;
$destinationType = $destination instanceof SwarmDocker ? 'swarm' : 'standalone';
$serverUuid = $destination->server?->uuid;
$destination->delete();
auditLog('api.destination.deleted', [
'team_id' => $teamId,
'destination_uuid' => $destinationUuid,
'destination_name' => $destinationName,
'destination_type' => $destinationType,
'server_uuid' => $serverUuid,
]);
return response()->json(['message' => 'Deleted.']);
}
}
+69 -10
View File
@@ -1312,8 +1312,12 @@ class ServicesController extends Controller
$this->authorize('manageEnvironment', $service);
if ($request->has('key')) {
$request->merge(['key' => ValidationPatterns::normalizeEnvironmentVariableKey((string) $request->key)]);
}
$validator = customApiValidator($request->all(), [
'key' => 'string|required',
'key' => ValidationPatterns::environmentVariableKeyRules(),
'value' => 'string|nullable',
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
@@ -1461,8 +1465,12 @@ class ServicesController extends Controller
$updatedEnvs = collect();
foreach ($bulk_data as $item) {
if (array_key_exists('key', $item)) {
$item['key'] = ValidationPatterns::normalizeEnvironmentVariableKey((string) $item['key']);
}
$validator = customApiValidator($item, [
'key' => 'string|required',
'key' => ValidationPatterns::environmentVariableKeyRules(),
'value' => 'string|nullable',
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
@@ -1580,8 +1588,12 @@ class ServicesController extends Controller
$this->authorize('manageEnvironment', $service);
if ($request->has('key')) {
$request->merge(['key' => ValidationPatterns::normalizeEnvironmentVariableKey((string) $request->key)]);
}
$validator = customApiValidator($request->all(), [
'key' => 'string|required',
'key' => ValidationPatterns::environmentVariableKeyRules(),
'value' => 'string|nullable',
'is_literal' => 'boolean',
'is_multiline' => 'boolean',
@@ -2166,10 +2178,11 @@ class ServicesController extends Controller
'host_path' => ['string', 'nullable', 'regex:'.ValidationPatterns::DIRECTORY_PATH_PATTERN],
'content' => 'string|nullable',
'is_directory' => 'boolean',
'is_host_file' => 'boolean',
'fs_path' => 'string',
]);
$allAllowedFields = ['type', 'resource_uuid', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'fs_path'];
$allAllowedFields = ['type', 'resource_uuid', 'name', 'mount_path', 'host_path', 'content', 'is_directory', 'is_host_file', 'fs_path'];
$extraFields = array_diff(array_keys($request->all()), $allAllowedFields);
if ($validator->fails() || ! empty($extraFields)) {
$errors = $validator->errors();
@@ -2201,7 +2214,7 @@ class ServicesController extends Controller
], 422);
}
$typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'fs_path'], array_keys($request->all()));
$typeSpecificInvalidFields = array_intersect(['content', 'is_directory', 'is_host_file', 'fs_path'], array_keys($request->all()));
if (! empty($typeSpecificInvalidFields)) {
return response()->json([
'message' => 'Validation failed.',
@@ -2232,6 +2245,14 @@ class ServicesController extends Controller
}
$isDirectory = $request->boolean('is_directory', false);
$isHostFile = $request->boolean('is_host_file', false);
if ($isDirectory && $isHostFile) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['is_host_file' => 'Host file mounts cannot also be directory mounts.'],
], 422);
}
if ($isDirectory) {
if (! $request->fs_path) {
@@ -2254,12 +2275,50 @@ class ServicesController extends Controller
'resource_id' => $subResource->id,
'resource_type' => get_class($subResource),
]);
} elseif ($isHostFile) {
if (! $request->fs_path) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['fs_path' => 'The fs_path field is required for host file mounts.'],
], 422);
}
if ($request->filled('content')) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['content' => 'Content is not valid for host file mounts.'],
], 422);
}
try {
$fsPath = validateHostFileMountPath($request->fs_path, 'host file source path');
$mountPath = validateFileMountPath($request->mount_path, 'host file destination path');
} catch (\Throwable $e) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['mount_path' => $e->getMessage()],
], 422);
}
$storage = LocalFileVolume::create([
'fs_path' => $fsPath,
'mount_path' => $mountPath,
'content' => null,
'is_directory' => false,
'is_host_file' => true,
'resource_id' => $subResource->id,
'resource_type' => get_class($subResource),
]);
} else {
$mountPath = str($request->mount_path)->trim()->start('/')->value();
validateShellSafePath($mountPath, 'file storage path');
$fsPath = service_configuration_dir().'/'.$service->uuid.$mountPath;
try {
$mountPath = validateFileMountPath($request->mount_path, 'file storage path');
$fsPath = confineFileMountPath(service_configuration_dir().'/'.$service->uuid, $mountPath, 'file storage path');
} catch (\Throwable $e) {
return response()->json([
'message' => 'Validation failed.',
'errors' => ['mount_path' => $e->getMessage()],
], 422);
}
$storage = LocalFileVolume::create([
'fs_path' => $fsPath,
+4 -40
View File
@@ -2,6 +2,7 @@
namespace App\Http\Controllers;
use App\Support\DatabaseBackupFileValidator;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Request;
use Illuminate\Http\UploadedFile;
@@ -16,24 +17,7 @@ class UploadController extends BaseController
private const MAX_BYTES = 10 * 1024 * 1024 * 1024; // 10 GiB
private const ALLOWED_EXTENSIONS = [
'sql',
'sql.gz',
'gz',
'zip',
'tar',
'tar.gz',
'tgz',
'dump',
'bak',
'bson',
'bson.gz',
'archive',
'archive.gz',
'bz2',
'xz',
'dmp',
];
private const ALLOWED_EXTENSIONS = DatabaseBackupFileValidator::ALLOWED_EXTENSIONS;
public function upload(Request $request)
{
@@ -85,10 +69,7 @@ class UploadController extends BaseController
protected function saveFile(UploadedFile $file, string $resourceIdentifier)
{
$originalName = $file->getClientOriginalName();
$size = $file->getSize();
if (! self::hasAllowedExtension($originalName) || $size === false || $size > self::MAX_BYTES) {
if (! DatabaseBackupFileValidator::isUploadAllowed($file, self::MAX_BYTES)) {
@unlink($file->getPathname());
return response()->json([
@@ -108,24 +89,7 @@ class UploadController extends BaseController
private static function hasAllowedExtension(string $name): bool
{
$lower = strtolower($name);
$suffixes = array_map(fn ($ext) => '.'.$ext, self::ALLOWED_EXTENSIONS);
usort($suffixes, fn ($a, $b) => strlen($b) <=> strlen($a));
foreach ($suffixes as $suffix) {
if (! str_ends_with($lower, $suffix)) {
continue;
}
$stem = substr($lower, 0, -strlen($suffix));
if ($stem !== '' && ! str_ends_with($stem, '.')) {
return true;
}
return false;
}
return false;
return DatabaseBackupFileValidator::hasAllowedExtension($name);
}
private static function formatMaxSize(): string
+10
View File
@@ -261,6 +261,16 @@ class Github extends Controller
return response('Nothing to do. No GitHub App found.');
}
$webhook_secret = data_get($github_app, 'webhook_secret');
if (empty($webhook_secret)) {
auditLogWebhookFailure('github', 'webhook_secret_missing', [
'mode' => 'app',
'github_app_id' => $github_app->id,
'github_app_name' => $github_app->name,
'installation_target_id' => $x_github_hook_installation_target_id,
]);
return response('Invalid signature.');
}
$hmac = hash_hmac('sha256', $request->getContent(), $webhook_secret);
if (config('app.env') !== 'local') {
if (! hash_equals($x_hub_signature_256, $hmac)) {
+2
View File
@@ -12,6 +12,7 @@ use App\Http\Middleware\CheckForcePasswordReset;
use App\Http\Middleware\DecideWhatToDoWithUser;
use App\Http\Middleware\EncryptCookies;
use App\Http\Middleware\EnsureMcpEnabled;
use App\Http\Middleware\EnsureTeamMcpEnabled;
use App\Http\Middleware\EnsureTokenBelongsToCurrentTeamMember;
use App\Http\Middleware\PreventRequestsDuringMaintenance;
use App\Http\Middleware\RedirectIfAuthenticated;
@@ -110,5 +111,6 @@ class Kernel extends HttpKernel
'can.update.resource' => CanUpdateResource::class,
'can.access.terminal' => CanAccessTerminal::class,
'mcp.enabled' => EnsureMcpEnabled::class,
'mcp.team.enabled' => EnsureTeamMcpEnabled::class,
];
}
+5 -6
View File
@@ -12,15 +12,14 @@ class CanCreateResources
/**
* Handle an incoming request.
*
* @param \Closure(\Illuminate\Http\Request): (\Symfony\Component\HttpFoundation\Response) $next
* @param Closure(Request): (Response) $next
*/
public function handle(Request $request, Closure $next): Response
{
return $next($request);
// if (! Gate::allows('createAnyResource')) {
// abort(403, 'You do not have permission to create resources.');
// }
if (! Gate::allows('createAnyResource')) {
abort(403, 'You do not have permission to create resources.');
}
// return $next($request);
return $next($request);
}
}
+49 -40
View File
@@ -5,6 +5,7 @@ namespace App\Http\Middleware;
use App\Models\Application;
use App\Models\Environment;
use App\Models\Project;
use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
@@ -23,53 +24,61 @@ use Symfony\Component\HttpFoundation\Response;
class CanUpdateResource
{
/**
* @var array<string, list<class-string>>
*/
private const ROUTE_RESOURCE_MODELS = [
'application_uuid' => [Application::class],
'database_uuid' => [
StandalonePostgresql::class,
StandaloneMysql::class,
StandaloneMariadb::class,
StandaloneRedis::class,
StandaloneKeydb::class,
StandaloneDragonfly::class,
StandaloneClickhouse::class,
StandaloneMongodb::class,
],
'stack_service_uuid' => [ServiceApplication::class, ServiceDatabase::class],
'service_uuid' => [Service::class],
'server_uuid' => [Server::class],
'environment_uuid' => [Environment::class],
'project_uuid' => [Project::class],
];
public function handle(Request $request, Closure $next): Response
{
$resource = $this->resourceFromRoute($request);
if (! $resource) {
abort(404, 'Resource not found.');
}
if (! Gate::allows('update', $resource)) {
abort(403, 'You do not have permission to update this resource.');
}
return $next($request);
}
// Get resource from route parameters
// $resource = null;
// if ($request->route('application_uuid')) {
// $resource = Application::where('uuid', $request->route('application_uuid'))->first();
// } elseif ($request->route('service_uuid')) {
// $resource = Service::where('uuid', $request->route('service_uuid'))->first();
// } elseif ($request->route('stack_service_uuid')) {
// // Handle ServiceApplication or ServiceDatabase
// $stack_service_uuid = $request->route('stack_service_uuid');
// $resource = ServiceApplication::where('uuid', $stack_service_uuid)->first() ??
// ServiceDatabase::where('uuid', $stack_service_uuid)->first();
// } elseif ($request->route('database_uuid')) {
// // Try different database types
// $database_uuid = $request->route('database_uuid');
// $resource = StandalonePostgresql::where('uuid', $database_uuid)->first() ??
// StandaloneMysql::where('uuid', $database_uuid)->first() ??
// StandaloneMariadb::where('uuid', $database_uuid)->first() ??
// StandaloneRedis::where('uuid', $database_uuid)->first() ??
// StandaloneKeydb::where('uuid', $database_uuid)->first() ??
// StandaloneDragonfly::where('uuid', $database_uuid)->first() ??
// StandaloneClickhouse::where('uuid', $database_uuid)->first() ??
// StandaloneMongodb::where('uuid', $database_uuid)->first();
// } elseif ($request->route('server_uuid')) {
// // For server routes, check if user can manage servers
// if (! auth()->user()->isAdmin()) {
// abort(403, 'You do not have permission to access this resource.');
// }
private function resourceFromRoute(Request $request): ?object
{
foreach (self::ROUTE_RESOURCE_MODELS as $routeParameter => $models) {
$uuid = $request->route($routeParameter);
// return $next($request);
// } elseif ($request->route('environment_uuid')) {
// $resource = Environment::where('uuid', $request->route('environment_uuid'))->first();
// } elseif ($request->route('project_uuid')) {
// $resource = Project::ownedByCurrentTeam()->where('uuid', $request->route('project_uuid'))->first();
// }
if (! $uuid) {
continue;
}
// if (! $resource) {
// abort(404, 'Resource not found.');
// }
foreach ($models as $model) {
$resource = $model::where('uuid', $uuid)->first();
// if (! Gate::allows('update', $resource)) {
// abort(403, 'You do not have permission to update this resource.');
// }
if ($resource) {
return $resource;
}
}
}
// return $next($request);
return null;
}
}
@@ -0,0 +1,26 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureTeamMcpEnabled
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
$teamId = $user?->currentAccessToken()?->team_id;
$team = $user?->teams()
->where('teams.id', $teamId)
->first();
if (! $team?->is_mcp_server_enabled) {
return response()->json(['message' => 'MCP server is disabled for this team.'], 403);
}
return $next($request);
}
}
+55 -12
View File
@@ -1031,7 +1031,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
);
}
foreach ($this->application->fileStorages as $fileStorage) {
if (! $fileStorage->is_based_on_git && ! $fileStorage->is_directory) {
if (! $fileStorage->is_host_file && ! $fileStorage->is_based_on_git && ! $fileStorage->is_directory) {
$fileStorage->saveStorageOnServer();
}
}
@@ -1833,8 +1833,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
]
);
}
} elseif ($this->build_pack === 'dockercompose' || $this->build_pack === 'dockerfile') {
// For Docker Compose and Dockerfile, create an empty .env file even if there are no build-time variables
} elseif (in_array($this->build_pack, ['dockercompose', 'dockerfile', 'railpack'], true)) {
// For build packs that source the build-time .env file, create an empty file even if there are no build-time variables
// This ensures the file exists when referenced in build commands
$this->application_deployment_queue->addLogEntry('Creating empty build-time .env file in /artifacts (no build-time variables defined).', hidden: true);
@@ -2306,6 +2306,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
],
[
executeInDocker($this->deployment_uuid, "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null"),
'hidden' => true,
'skip_command_log' => true,
],
[
executeInDocker($this->deployment_uuid, "chmod 600 {$customSshKeyLocation}"),
@@ -2365,12 +2367,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
if ($this->pull_request_id !== 0) {
$this->application_deployment_queue->addLogEntry("Checking out tag pull/{$this->pull_request_id}/head.");
}
$this->execute_remote_command(
[
$importCommands,
'hidden' => true,
]
);
$this->execute_remote_command(...$this->gitCommandDefinitions($importCommands));
$this->create_workdir();
$this->execute_remote_command(
[
@@ -2400,6 +2397,39 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
return $commands;
}
private function gitCommandDefinitions(Collection|array|string $commands): array
{
if (is_string($commands)) {
return [
[
$commands,
'hidden' => true,
],
];
}
return collect($commands)
->map(function ($command): array {
if (is_string($command)) {
return [
$command,
'hidden' => true,
];
}
if (is_array($command)) {
return $command + ['hidden' => true];
}
return [
'command' => $command,
'hidden' => true,
];
})
->values()
->all();
}
private function cleanup_git()
{
$this->execute_remote_command(
@@ -2667,12 +2697,22 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$cacheArgs .= ' --build-arg secrets-hash='.$this->generate_secrets_hash($variables);
}
$environmentPrefix = $this->railpack_build_environment_prefix($variables);
// Build-time variables reach the build through the sourced build-time .env file
// (written by save_buildtime_environment_variables), which interpolates shell-style
// references such as BETTER_AUTH_URL=$COOLIFY_URL. Passing them inline via `env`
// would forward the literal `$COOLIFY_URL` because each value is single-quoted and
// `env` does not interpolate its own assignments. Only buildpack control variables
// (NIXPACKS_/RAILPACK_) — which are excluded from the build-time .env file and never
// need interpolation — are still passed inline.
$controlVariables = $variables->filter(
fn ($value, $key) => str($key)->startsWith(EnvironmentVariable::BUILDPACK_CONTROL_VARIABLE_PREFIXES)
);
$environmentPrefix = $this->railpack_build_environment_prefix($controlVariables);
$secretFlags = $this->railpack_build_secret_flags($variables);
$frontendImage = 'ghcr.io/railwayapp/railpack-frontend:v'.config('constants.coolify.railpack_version');
return 'docker buildx create --name coolify-railpack --driver docker-container 2>/dev/null || true'
." && {$environmentPrefix}docker buildx build --builder coolify-railpack"
$buildxBuildCommand = "{$environmentPrefix}docker buildx build --builder coolify-railpack"
." {$this->addHosts} --network host"
." --build-arg BUILDKIT_SYNTAX=\"{$frontendImage}\""
." {$cacheArgs}"
@@ -2682,6 +2722,9 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
.' --load'
." -t {$imageName}"
." {$this->workdir}";
return 'docker buildx create --name coolify-railpack --driver docker-container 2>/dev/null || true'
.' && '.$this->wrap_build_command_with_env_export($buildxBuildCommand);
}
private function decode_railpack_config(string $config, string $source): array
+3 -1
View File
@@ -714,9 +714,11 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
$escapedEndpoint = escapeshellarg($endpoint);
$escapedKey = escapeshellarg($key);
$escapedSecret = escapeshellarg($secret);
$escapedBackupLocation = escapeshellarg($this->backup_location);
$escapedS3Destination = escapeshellarg("temporary/{$bucket}{$this->backup_dir}/");
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc alias set temporary {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp $this->backup_location temporary/$bucket{$this->backup_dir}/";
$commands[] = "docker exec backup-of-{$this->backup_log_uuid} mc cp {$escapedBackupLocation} {$escapedS3Destination}";
instant_remote_process($commands, $this->server, true, false, null, disableMultiplexing: true);
$this->s3_uploaded = true;
+18 -1
View File
@@ -3,6 +3,7 @@
namespace App\Jobs;
use App\Notifications\Dto\DiscordMessage;
use App\Rules\SafeWebhookUrl;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -10,6 +11,8 @@ use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
class SendMessageToDiscordJob implements ShouldBeEncrypted, ShouldQueue
{
@@ -41,6 +44,20 @@ class SendMessageToDiscordJob implements ShouldBeEncrypted, ShouldQueue
*/
public function handle(): void
{
Http::post($this->webhookUrl, $this->message->toPayload());
$validator = Validator::make(
['webhook_url' => $this->webhookUrl],
['webhook_url' => ['required', 'url', new SafeWebhookUrl]]
);
if ($validator->fails()) {
Log::warning('SendMessageToDiscordJob: blocked unsafe webhook URL', [
'url' => $this->webhookUrl,
'errors' => $validator->errors()->all(),
]);
return;
}
Http::withOptions(['allow_redirects' => false])->post($this->webhookUrl, $this->message->toPayload());
}
}
+19 -2
View File
@@ -3,6 +3,7 @@
namespace App\Jobs;
use App\Notifications\Dto\SlackMessage;
use App\Rules\SafeWebhookUrl;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
use Illuminate\Contracts\Queue\ShouldQueue;
@@ -10,6 +11,8 @@ use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Validator;
class SendMessageToSlackJob implements ShouldBeEncrypted, ShouldQueue
{
@@ -34,6 +37,20 @@ class SendMessageToSlackJob implements ShouldBeEncrypted, ShouldQueue
public function handle(): void
{
$validator = Validator::make(
['webhook_url' => $this->webhookUrl],
['webhook_url' => ['required', 'url', new SafeWebhookUrl]]
);
if ($validator->fails()) {
Log::warning('SendMessageToSlackJob: blocked unsafe webhook URL', [
'url' => $this->webhookUrl,
'errors' => $validator->errors()->all(),
]);
return;
}
if ($this->isSlackWebhook()) {
$this->sendToSlack();
@@ -64,7 +81,7 @@ class SendMessageToSlackJob implements ShouldBeEncrypted, ShouldQueue
private function sendToSlack(): void
{
Http::post($this->webhookUrl, [
Http::withOptions(['allow_redirects' => false])->post($this->webhookUrl, [
'text' => $this->message->title,
'blocks' => [
[
@@ -106,7 +123,7 @@ class SendMessageToSlackJob implements ShouldBeEncrypted, ShouldQueue
{
$username = config('app.name');
Http::post($this->webhookUrl, [
Http::withOptions(['allow_redirects' => false])->post($this->webhookUrl, [
'username' => $username,
'attachments' => [
[
+1 -1
View File
@@ -64,7 +64,7 @@ class SendWebhookJob implements ShouldBeEncrypted, ShouldQueue
]);
}
$response = Http::post($this->webhookUrl, $this->payload);
$response = Http::withOptions(['allow_redirects' => false])->post($this->webhookUrl, $this->payload);
if (isDev()) {
ray('Webhook response', [
+4 -1
View File
@@ -260,7 +260,10 @@ class StripeProcessJob implements ShouldBeEncrypted, ShouldQueue
$comment = data_get($data, 'cancellation_details.comment');
$lookup_key = data_get($data, 'items.data.0.price.lookup_key');
if (str($lookup_key)->contains('dynamic')) {
$quantity = min((int) data_get($data, 'items.data.0.quantity', 2), UpdateSubscriptionQuantity::MAX_SERVER_LIMIT);
$quantity = max(
UpdateSubscriptionQuantity::MIN_SERVER_LIMIT,
min((int) data_get($data, 'items.data.0.quantity', 2), UpdateSubscriptionQuantity::MAX_SERVER_LIMIT)
);
$team = data_get($subscription, 'team');
if ($team) {
$team->update([
+10 -2
View File
@@ -170,11 +170,15 @@ class Email extends Component
$this->smtpPort = $this->settings->smtp_port;
$this->smtpEncryption = $this->settings->smtp_encryption;
$this->smtpUsername = $this->settings->smtp_username;
$this->smtpPassword = $this->settings->smtp_password;
$this->smtpPassword = auth()->user()->can('update', $this->settings)
? $this->settings->smtp_password
: null;
$this->smtpTimeout = $this->settings->smtp_timeout;
$this->resendEnabled = $this->settings->resend_enabled;
$this->resendApiKey = $this->settings->resend_api_key;
$this->resendApiKey = auth()->user()->can('update', $this->settings)
? $this->settings->resend_api_key
: null;
$this->useInstanceEmailSettings = $this->settings->use_instance_email_settings;
@@ -242,6 +246,8 @@ class Email extends Component
public function submitSmtp()
{
$this->authorize('update', $this->settings);
try {
$this->resetErrorBag();
$this->validate([
@@ -289,6 +295,8 @@ class Email extends Component
public function submitResend()
{
$this->authorize('update', $this->settings);
try {
$this->resetErrorBag();
$this->validate([
+39 -6
View File
@@ -3,10 +3,12 @@
namespace App\Livewire\Project\Database;
use App\Jobs\DatabaseBackupJob;
use App\Models\S3Storage;
use App\Models\ScheduledDatabaseBackup;
use App\Models\ServiceDatabase;
use Exception;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Attributes\Locked;
use Livewire\Attributes\Validate;
use Livewire\Component;
@@ -18,7 +20,7 @@ class BackupEdit extends Component
public ScheduledDatabaseBackup $backup;
#[Locked]
public $s3s;
public $availableS3Storages;
#[Locked]
public $parameters;
@@ -69,7 +71,7 @@ class BackupEdit extends Component
public bool $disableLocalBackup = false;
#[Validate(['nullable', 'integer'])]
public ?int $s3StorageId = 1;
public ?int $s3StorageId = null;
#[Validate(['nullable', 'string'])]
public ?string $databasesToBackup = null;
@@ -129,7 +131,7 @@ class BackupEdit extends Component
$this->databaseBackupRetentionMaxStorageS3 = $this->backup->database_backup_retention_max_storage_s3;
$this->saveS3 = $this->backup->save_s3;
$this->disableLocalBackup = $this->backup->disable_local_backup ?? false;
$this->s3StorageId = $this->backup->s3_storage_id;
$this->s3StorageId = $this->backup->s3_storage_id ?? $this->availableS3StorageIds()->first();
$this->databasesToBackup = $this->backup->databases_to_backup;
$this->dumpAll = $this->backup->dump_all;
$this->timeout = $this->backup->timeout;
@@ -215,6 +217,11 @@ class BackupEdit extends Component
}
}
public function updatedS3StorageId(): void
{
$this->instantSave();
}
private function customValidate()
{
if (! is_numeric($this->backup->s3_storage_id)) {
@@ -222,10 +229,14 @@ class BackupEdit extends Component
}
// S3 backup cannot be enabled without a valid S3 storage owned by the team
$availableS3Ids = collect($this->s3s)->pluck('id');
if ($this->backup->save_s3 && ! $availableS3Ids->contains($this->backup->s3_storage_id)) {
$this->backup->save_s3 = $this->saveS3 = false;
$availableS3Ids = $this->availableS3StorageIds();
if ($availableS3Ids->isEmpty()) {
$this->backup->s3_storage_id = $this->s3StorageId = null;
if ($this->backup->save_s3) {
$this->backup->save_s3 = $this->saveS3 = false;
}
} elseif (! $availableS3Ids->contains($this->backup->s3_storage_id)) {
$this->backup->s3_storage_id = $this->s3StorageId = $availableS3Ids->first();
}
// Validate that disable_local_backup can only be true when S3 backup is enabled
@@ -240,6 +251,28 @@ class BackupEdit extends Component
$this->validate();
}
private function availableS3StorageIds(): Collection
{
$storages = collect($this->availableS3Storages);
$storageIds = $storages->pluck('id')->filter()->all();
if (empty($storageIds)) {
return collect();
}
$teamIds = $storages->pluck('team_id')->reject(fn ($teamId) => $teamId === null)->unique()->values()->all();
if (empty($teamIds)) {
return collect();
}
return S3Storage::query()
->whereKey($storageIds)
->whereIn('team_id', $teamIds)
->where('is_usable', true)
->pluck('id');
}
public function submit()
{
try {
+79 -4
View File
@@ -14,6 +14,7 @@ use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Support\DatabaseBackupFileValidator;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Storage;
@@ -27,11 +28,10 @@ class ImportForm extends Component
/**
* Validate that a string is safe for use as an S3 bucket name.
* Allows alphanumerics, dots, dashes, and underscores.
*/
private function validateBucketName(string $bucket): bool
{
return preg_match('/^[a-zA-Z0-9.\-_]+$/', $bucket) === 1;
return ValidationPatterns::isValidS3BucketName($bucket);
}
/**
@@ -451,10 +451,20 @@ EOD;
// Check if an uploaded file exists first (takes priority over custom location)
if (Storage::exists($backupFileName)) {
$path = Storage::path($backupFileName);
// Reject malicious PostgreSQL payloads before transferring the file anywhere.
if ($this->isPostgresqlRestore() && DatabaseBackupFileValidator::fileContainsPostgresqlProgramExecution($path)) {
Storage::delete($backupFileName);
$this->dispatch('error', 'The uploaded backup contains disallowed PostgreSQL restore directives (COPY ... PROGRAM or psql shell commands) and was rejected.');
return true;
}
$tmpPath = '/tmp/'.basename($backupFileName).'_'.$this->resourceUuid;
instant_scp($path, $tmpPath, $this->server);
Storage::delete($backupFileName);
$this->importCommands[] = "docker cp {$tmpPath} {$this->container}:{$tmpPath}";
$this->addRestoreSafetyCheckCommand($this->importCommands, $tmpPath);
} elseif (filled($this->customLocation)) {
// Validate the custom location to prevent command injection
if (! $this->validateServerPath($this->customLocation)) {
@@ -465,6 +475,7 @@ EOD;
$tmpPath = '/tmp/restore_'.$this->resourceUuid;
$escapedCustomLocation = escapeshellarg($this->customLocation);
$this->importCommands[] = "docker cp {$escapedCustomLocation} {$this->container}:{$tmpPath}";
$this->addRestoreSafetyCheckCommand($this->importCommands, $tmpPath);
} else {
$this->dispatch('error', 'The file does not exist or has been deleted.');
@@ -570,7 +581,7 @@ EOD;
// Validate bucket name early
if (! $this->validateBucketName($s3Storage->bucket)) {
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only lowercase letters, numbers, dots, and dashes, and must follow S3 bucket naming rules.');
return;
}
@@ -651,7 +662,7 @@ EOD;
// Validate bucket name to prevent command injection
if (! $this->validateBucketName($bucket)) {
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only alphanumerics, dots, dashes, and underscores.');
$this->dispatch('error', 'Invalid S3 bucket name. Bucket name must contain only lowercase letters, numbers, dots, and dashes, and must follow S3 bucket naming rules.');
return true;
}
@@ -721,6 +732,7 @@ EOD;
// 6. Copy from helper to server, then immediately to database container
$commands[] = "docker cp {$escapedHelperContainerPath} {$escapedServerTmpPath}";
$commands[] = "docker cp {$escapedServerTmpPath} {$escapedDatabaseContainerTmpPath}";
$this->addRestoreSafetyCheckCommand($commands, $containerTmpPath);
// 7. Cleanup helper container and server temp file immediately (no longer needed)
$commands[] = "docker rm -f {$containerName} 2>/dev/null || true";
@@ -765,6 +777,69 @@ EOD;
return true;
}
public function buildRestoreSafetyCheckCommand(string $tmpPath): ?string
{
$script = $this->buildPostgresRestoreScanScript($tmpPath);
if ($script === null) {
return null;
}
return "docker exec {$this->container} sh -c ".escapeshellarg($script);
}
/**
* Build the POSIX shell snippet that aborts (exit 1) when a PostgreSQL
* backup contains directives leading to OS command execution.
*
* Hardened against bypasses:
* - decompresses gzip backups before scanning,
* - strips `--` line comments and flattens newlines so multi-line and
* comment-separated payloads (e.g. `FROM/**​/PROGRAM`) are caught,
* - matches a literal `\!` shell escape and `\o|`/`\g|` pipe redirects.
*/
public function buildPostgresRestoreScanScript(string $tmpPath): ?string
{
if (! $this->isPostgresqlRestore()) {
return null;
}
$escapedTmpPath = escapeshellarg($tmpPath);
// Token separator PostgreSQL treats as whitespace: real whitespace or a
// /* ... */ block comment (used to split keywords like FROM/**/PROGRAM).
$sep = '([[:space:]]|/\\*[^*]*\\*/)';
$pattern = implode('|', [
"copy{$sep}+[^;]*(from|to){$sep}+program",
'(^|[[:space:]])\\\\!',
"(^|[[:space:]])\\\\(o|g){$sep}*\\|",
]);
$escapedPattern = escapeshellarg($pattern);
return "if (gunzip -cf {$escapedTmpPath} 2>/dev/null || cat {$escapedTmpPath}) | sed 's/--.*//' | tr '\n\r\t' ' ' | grep -Eiq {$escapedPattern}; then echo 'Blocked PostgreSQL restore: COPY ... PROGRAM and psql shell commands are not allowed.'; exit 1; fi";
}
private function addRestoreSafetyCheckCommand(array &$commands, string $tmpPath): void
{
$command = $this->buildRestoreSafetyCheckCommand($tmpPath);
if ($command !== null) {
$commands[] = $command;
}
}
private function isPostgresqlRestore(): bool
{
$morphClass = $this->resource->getMorphClass();
if ($morphClass === ServiceDatabase::class) {
return str_contains($this->resource->databaseType(), 'postgres');
}
return $morphClass === StandalonePostgresql::class || $morphClass === 'postgresql';
}
public function buildRestoreCommand(string $tmpPath): string
{
$escapedTmpPath = escapeshellarg($tmpPath);
@@ -5,11 +5,14 @@ namespace App\Livewire\Project\New;
use App\Models\EnvironmentVariable;
use App\Models\Project;
use App\Models\Service;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
use Symfony\Component\Yaml\Yaml;
class DockerCompose extends Component
{
use AuthorizesRequests;
public string $dockerComposeRaw = '';
public string $envFile = '';
@@ -30,6 +33,8 @@ class DockerCompose extends Component
public function submit()
{
try {
$this->authorize('create', Service::class);
$this->validate([
'dockerComposeRaw' => 'required',
]);
+5
View File
@@ -6,10 +6,13 @@ use App\Models\Application;
use App\Models\Project;
use App\Services\DockerImageParser;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class DockerImage extends Component
{
use AuthorizesRequests;
public string $imageName = '';
public string $imageTag = '';
@@ -80,6 +83,8 @@ class DockerImage extends Component
public function submit()
{
$this->authorize('create', Application::class);
$this->validate([
'imageName' => ValidationPatterns::dockerImageNameRules(required: true),
'imageTag' => ValidationPatterns::dockerImageTagRules(),
@@ -3,12 +3,17 @@
namespace App\Livewire\Project\New;
use App\Models\Project;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class EmptyProject extends Component
{
use AuthorizesRequests;
public function createEmptyProject()
{
$this->authorize('create', Project::class);
$project = Project::create([
'name' => generate_random_name(),
'team_id' => currentTeam()->id,
@@ -7,6 +7,7 @@ use App\Models\GithubApp;
use App\Models\Project;
use App\Rules\ValidGitBranch;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Route;
use Livewire\Attributes\Locked;
@@ -14,6 +15,8 @@ use Livewire\Component;
class GithubPrivateRepository extends Component
{
use AuthorizesRequests;
public $current_step = 'github_apps';
public $github_apps;
@@ -169,6 +172,8 @@ class GithubPrivateRepository extends Component
public function submit()
{
try {
$this->authorize('create', Application::class);
// Validate git repository parts and branch
$validator = validator([
'selected_repository_owner' => $this->selected_repository_owner,
@@ -10,12 +10,15 @@ use App\Models\Project;
use App\Rules\ValidGitBranch;
use App\Rules\ValidGitRepositoryUrl;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Str;
use Livewire\Component;
use Spatie\Url\Url;
class GithubPrivateRepositoryDeployKey extends Component
{
use AuthorizesRequests;
public $current_step = 'private_keys';
public $parameters;
@@ -128,6 +131,8 @@ class GithubPrivateRepositoryDeployKey extends Component
public function submit()
{
$this->authorize('create', Application::class);
$this->validate();
try {
$destination_uuid = $this->query['destination'] ?? null;
@@ -6,16 +6,18 @@ use App\Models\Application;
use App\Models\GithubApp;
use App\Models\GitlabApp;
use App\Models\Project;
use App\Models\Service;
use App\Rules\ValidGitBranch;
use App\Rules\ValidGitRepositoryUrl;
use App\Support\ValidationPatterns;
use Carbon\Carbon;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
use Spatie\Url\Url;
class PublicGitRepository extends Component
{
use AuthorizesRequests;
public string $repository_url;
public int $port = 3000;
@@ -260,6 +262,8 @@ class PublicGitRepository extends Component
public function submit()
{
try {
$this->authorize('create', Application::class);
$this->validate();
// Additional validation for git repository and branch
@@ -295,33 +299,6 @@ class PublicGitRepository extends Component
$project = Project::ownedByCurrentTeam()->where('uuid', $project_uuid)->firstOrFail();
$environment = $project->environments()->where('uuid', $environment_uuid)->firstOrFail();
if ($this->build_pack === 'dockercompose' && isDev() && $this->new_compose_services) {
$server = $destination->server;
$new_service = [
'name' => 'service'.str()->random(10),
'docker_compose_raw' => 'coolify',
'environment_id' => $environment->id,
'server_id' => $server->id,
];
if ($this->git_source === 'other') {
$new_service['git_repository'] = $this->git_repository;
$new_service['git_branch'] = $this->git_branch;
} else {
$new_service['git_repository'] = $this->git_repository;
$new_service['git_branch'] = $this->git_branch;
$new_service['source_id'] = $this->git_source->id;
$new_service['source_type'] = $this->git_source->getMorphClass();
}
$service = Service::create($new_service);
return redirect()->route('project.service.configuration', [
'service_uuid' => $service->uuid,
'environment_uuid' => $environment->uuid,
'project_uuid' => $project->uuid,
]);
return;
}
if ($this->git_source === 'other') {
$application_init = [
'name' => generate_random_name(),
@@ -5,10 +5,13 @@ namespace App\Livewire\Project\New;
use App\Models\Application;
use App\Models\GithubApp;
use App\Models\Project;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class SimpleDockerfile extends Component
{
use AuthorizesRequests;
public string $dockerfile = '';
public array $parameters;
@@ -29,6 +32,8 @@ CMD ["nginx", "-g", "daemon off;"]
public function submit()
{
$this->authorize('create', Application::class);
$this->validate([
'dockerfile' => 'required',
]);
+4
View File
@@ -4,16 +4,20 @@ namespace App\Livewire\Project\Resource;
use App\Models\EnvironmentVariable;
use App\Models\Service;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
class Create extends Component
{
use AuthorizesRequests;
public $type;
public $project;
public function mount()
{
$this->authorize('createAnyResource');
$type = str(request()->query('type'));
$destination_uuid = request()->query('destination');
+30 -1
View File
@@ -94,6 +94,10 @@ class FileStorage extends Component
try {
$this->authorize('update', $this->resource);
if ($this->fileStorage->is_host_file) {
throw new \Exception('Host file mounts are bind-only and cannot be converted.');
}
$this->fileStorage->deleteStorageOnServer();
$this->fileStorage->is_directory = true;
$this->fileStorage->content = null;
@@ -112,6 +116,10 @@ class FileStorage extends Component
try {
$this->authorize('update', $this->resource);
if ($this->fileStorage->is_host_file) {
throw new \Exception('Host file mounts are bind-only and cannot be loaded from the server.');
}
$this->fileStorage->loadStorageOnServer();
$this->syncData();
$this->dispatch('success', 'File storage loaded from server.');
@@ -127,6 +135,10 @@ class FileStorage extends Component
try {
$this->authorize('update', $this->resource);
if ($this->fileStorage->is_host_file) {
throw new \Exception('Host file mounts are bind-only and cannot be converted.');
}
$this->fileStorage->deleteStorageOnServer();
$this->fileStorage->is_directory = false;
$this->fileStorage->content = null;
@@ -154,8 +166,10 @@ class FileStorage extends Component
$message = 'File deleted.';
if ($this->fileStorage->is_directory) {
$message = 'Directory deleted.';
} elseif ($this->fileStorage->is_host_file) {
$message = 'Host file mount removed.';
}
if ($this->permanently_delete) {
if ($this->permanently_delete && ! $this->fileStorage->is_host_file) {
$message = 'Directory deleted from the server.';
$this->fileStorage->deleteStorageOnServer();
}
@@ -174,6 +188,12 @@ class FileStorage extends Component
{
$this->authorize('update', $this->resource);
if ($this->fileStorage->is_host_file) {
$this->dispatch('error', 'Host file mounts are bind-only and cannot be edited from the UI.');
return;
}
if ($this->fileStorage->is_too_large) {
$this->dispatch('error', 'File on server is too large to edit from the UI.');
@@ -205,6 +225,12 @@ class FileStorage extends Component
public function instantSave(): void
{
$this->authorize('update', $this->resource);
if ($this->fileStorage->is_host_file) {
$this->dispatch('error', 'Host file mounts are bind-only and cannot be edited from the UI.');
return;
}
if ($this->fileStorage->is_too_large) {
$this->dispatch('error', 'File on server is too large to edit from the UI.');
@@ -223,6 +249,9 @@ class FileStorage extends Component
'fileDeletionCheckboxes' => [
['id' => 'permanently_delete', 'label' => 'The selected file will be permanently deleted form the server.'],
],
'hostFileDeletionCheckboxes' => [
['id' => 'permanently_delete', 'label' => 'Only the mount configuration will be removed. The host file will not be deleted.'],
],
]);
}
}
+68 -12
View File
@@ -29,6 +29,10 @@ class Storage extends Component
public ?string $file_storage_content = null;
public string $host_file_storage_source = '';
public string $host_file_storage_destination = '';
public string $file_storage_directory_source = '';
public string $file_storage_directory_destination = '';
@@ -146,19 +150,9 @@ class Storage extends Component
'file_storage_content' => 'nullable|string',
]);
$this->file_storage_path = trim($this->file_storage_path);
$this->file_storage_path = str($this->file_storage_path)->start('/')->value();
$this->file_storage_path = validateFileMountPath($this->file_storage_path, 'file storage path');
// Validate path to prevent command injection
validateShellSafePath($this->file_storage_path, 'file storage path');
if ($this->resource->getMorphClass() === Application::class) {
$fs_path = application_configuration_dir().'/'.$this->resource->uuid.$this->file_storage_path;
} elseif (str($this->resource->getMorphClass())->contains('Standalone')) {
$fs_path = database_configuration_dir().'/'.$this->resource->uuid.$this->file_storage_path;
} else {
throw new \Exception('No valid resource type for file mount storage type!');
}
$fs_path = confineFileMountPath($this->fileStorageHostPath(), $this->file_storage_path, 'file storage path');
LocalFileVolume::create([
'fs_path' => $fs_path,
@@ -178,6 +172,38 @@ class Storage extends Component
}
}
public function submitHostFileStorage()
{
try {
$this->authorize('update', $this->resource);
$this->validate([
'host_file_storage_source' => 'required|string',
'host_file_storage_destination' => 'required|string',
]);
$this->host_file_storage_source = validateHostFileMountPath($this->host_file_storage_source, 'host file source path');
$this->host_file_storage_destination = validateFileMountPath($this->host_file_storage_destination, 'host file destination path');
LocalFileVolume::create([
'fs_path' => $this->host_file_storage_source,
'mount_path' => $this->host_file_storage_destination,
'content' => null,
'is_directory' => false,
'is_host_file' => true,
'resource_id' => $this->resource->id,
'resource_type' => get_class($this->resource),
]);
$this->dispatch('success', 'Host file mount added successfully');
$this->dispatch('closeStorageModal', 'host-file');
$this->clearForm();
$this->refreshStorages();
} catch (\Throwable $e) {
return handleError($e, $this);
}
}
public function submitFileStorageDirectory()
{
try {
@@ -222,6 +248,8 @@ class Storage extends Component
$this->file_storage_path = '';
$this->file_storage_content = null;
$this->file_storage_directory_destination = '';
$this->host_file_storage_source = '';
$this->host_file_storage_destination = '';
if (str($this->resource->getMorphClass())->contains('Standalone')) {
$this->file_storage_directory_source = database_configuration_dir()."/{$this->resource->uuid}";
@@ -230,6 +258,34 @@ class Storage extends Component
}
}
public function fileStorageHostPath(): string
{
if (method_exists($this->resource, 'workdir')) {
return $this->resource->workdir();
}
if ($this->resource->getMorphClass() === Application::class) {
return application_configuration_dir().'/'.$this->resource->uuid;
}
if (str($this->resource->getMorphClass())->contains('Standalone')) {
return database_configuration_dir().'/'.$this->resource->uuid;
}
throw new \Exception('No valid resource type for file mount storage type!');
}
public function fileStoragePreviewPath(): string
{
$path = str($this->file_storage_path)->trim();
if ($path->isEmpty()) {
return $this->fileStorageHostPath().'/';
}
return $this->fileStorageHostPath().$path->start('/')->value();
}
public function render()
{
return view('livewire.project.service.storage');
+2 -2
View File
@@ -4,6 +4,7 @@ namespace App\Livewire\Storage;
use App\Models\S3Storage;
use App\Rules\SafeWebhookUrl;
use App\Rules\ValidS3BucketName;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Uri;
@@ -37,7 +38,7 @@ class Create extends Component
'region' => 'required|max:255',
'key' => 'required|max:255',
'secret' => 'required|max:255',
'bucket' => 'required|max:255',
'bucket' => ['required', new ValidS3BucketName],
'endpoint' => ['required', 'max:255', new SafeWebhookUrl],
];
}
@@ -54,7 +55,6 @@ class Create extends Component
'secret.required' => 'The Secret Key field is required.',
'secret.max' => 'The Secret Key may not be greater than 255 characters.',
'bucket.required' => 'The Bucket field is required.',
'bucket.max' => 'The Bucket may not be greater than 255 characters.',
'endpoint.required' => 'The Endpoint field is required.',
'endpoint.max' => 'The Endpoint may not be greater than 255 characters.',
]
+2 -2
View File
@@ -4,6 +4,7 @@ namespace App\Livewire\Storage;
use App\Models\S3Storage;
use App\Rules\SafeWebhookUrl;
use App\Rules\ValidS3BucketName;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
@@ -44,7 +45,7 @@ class Form extends Component
'region' => 'required|max:255',
'key' => 'required|max:255',
'secret' => 'required|max:255',
'bucket' => 'required|max:255',
'bucket' => ['required', new ValidS3BucketName],
'endpoint' => ['required', 'max:255', new SafeWebhookUrl],
];
}
@@ -61,7 +62,6 @@ class Form extends Component
'secret.required' => 'The Secret Key field is required.',
'secret.max' => 'The Secret Key may not be greater than 255 characters.',
'bucket.required' => 'The Bucket field is required.',
'bucket.max' => 'The Bucket may not be greater than 255 characters.',
'endpoint.required' => 'The Endpoint field is required.',
'endpoint.max' => 'The Endpoint may not be greater than 255 characters.',
]
+5
View File
@@ -24,11 +24,14 @@ class Index extends Component
public ?string $description = null;
public bool $is_mcp_server_enabled = true;
protected function rules(): array
{
return [
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'is_mcp_server_enabled' => 'boolean',
];
}
@@ -58,10 +61,12 @@ class Index extends Component
// Sync TO model (before save)
$this->team->name = $this->name;
$this->team->description = $this->description;
$this->team->is_mcp_server_enabled = $this->is_mcp_server_enabled;
} else {
// Sync FROM model (on load/refresh)
$this->name = $this->team->name;
$this->description = $this->team->description;
$this->is_mcp_server_enabled = $this->team->is_mcp_server_enabled;
}
}
+95 -105
View File
@@ -1353,7 +1353,7 @@ class Application extends BaseModel
{
try {
['commands' => $lsRemoteCommand] = $this->generateGitLsRemoteCommands(deployment_uuid: $deployment_uuid, exec_in_docker: false);
instant_remote_process([$lsRemoteCommand], $this->destination->server, true);
instant_remote_process([$this->gitCommandsAsShellCommand($lsRemoteCommand)], $this->destination->server, true);
return [
'is_accessible' => true,
@@ -1405,13 +1405,13 @@ class Application extends BaseModel
}
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, $base_command));
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, $base_command)));
} else {
$commands->push($base_command);
$commands->push($this->gitCommand($base_command));
}
return [
'commands' => $commands->implode(' && '),
'commands' => $this->gitCommandsAsShellCommand($commands),
'branch' => $branch,
'fullRepoUrl' => $fullRepoUrl,
];
@@ -1428,29 +1428,16 @@ class Application extends BaseModel
$escapedCustomRepository = str_replace("'", "'\\''", $customRepository);
$base_command = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$gitlabPort} -o Port={$gitlabPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$customSshKeyLocation} -o IdentitiesOnly=yes\" {$base_command} '{$escapedCustomRepository}'";
if ($exec_in_docker) {
$commands = collect([
executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'),
executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null"),
executeInDocker($deployment_uuid, "chmod 600 {$customSshKeyLocation}"),
]);
} else {
$commands = collect([
"trap 'rm -f {$customSshKeyLocation}' EXIT",
'mkdir -p /root/.ssh',
"echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null",
"chmod 600 {$customSshKeyLocation}",
]);
}
$commands = $this->gitSshKeySetupCommands($deployment_uuid, $private_key, $exec_in_docker);
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, $base_command));
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, $base_command)));
} else {
$commands->push($base_command);
$commands->push($this->gitCommand($base_command));
}
return [
'commands' => $commands->implode(' && '),
'commands' => $commands,
'branch' => $branch,
'fullRepoUrl' => $fullRepoUrl,
];
@@ -1462,13 +1449,13 @@ class Application extends BaseModel
$base_command = "{$base_command} {$escapedCustomRepository}";
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, $base_command));
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, $base_command)));
} else {
$commands->push($base_command);
$commands->push($this->gitCommand($base_command));
}
return [
'commands' => $commands->implode(' && '),
'commands' => $this->gitCommandsAsShellCommand($commands),
'branch' => $branch,
'fullRepoUrl' => $fullRepoUrl,
];
@@ -1487,29 +1474,16 @@ class Application extends BaseModel
$escapedCustomRepository = str_replace("'", "'\\''", $customRepository);
$base_command = "GIT_SSH_COMMAND=\"ssh -o ConnectTimeout=30 -p {$customPort} -o Port={$customPort} -o LogLevel=ERROR -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -i {$customSshKeyLocation} -o IdentitiesOnly=yes\" {$base_command} '{$escapedCustomRepository}'";
if ($exec_in_docker) {
$commands = collect([
executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'),
executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null"),
executeInDocker($deployment_uuid, "chmod 600 {$customSshKeyLocation}"),
]);
} else {
$commands = collect([
"trap 'rm -f {$customSshKeyLocation}' EXIT",
'mkdir -p /root/.ssh',
"echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null",
"chmod 600 {$customSshKeyLocation}",
]);
}
$commands = $this->gitSshKeySetupCommands($deployment_uuid, $private_key, $exec_in_docker);
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, $base_command));
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, $base_command)));
} else {
$commands->push($base_command);
$commands->push($this->gitCommand($base_command));
}
return [
'commands' => $commands->implode(' && '),
'commands' => $commands,
'branch' => $branch,
'fullRepoUrl' => $fullRepoUrl,
];
@@ -1521,19 +1495,60 @@ class Application extends BaseModel
$base_command = "{$base_command} {$escapedCustomRepository}";
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, $base_command));
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, $base_command)));
} else {
$commands->push($base_command);
$commands->push($this->gitCommand($base_command));
}
return [
'commands' => $commands->implode(' && '),
'commands' => $this->gitCommandsAsShellCommand($commands),
'branch' => $branch,
'fullRepoUrl' => $fullRepoUrl,
];
}
}
private function gitCommand(string $command): array
{
return [
'command' => $command,
'hidden' => true,
];
}
private function gitCommandsAsShellCommand(Collection|array|string $commands): string
{
if (is_string($commands)) {
return $commands;
}
return collect($commands)
->map(fn ($command) => data_get($command, 'command') ?? $command[0] ?? $command)
->implode(' && ');
}
private function gitSshKeySetupCommands(string $deploymentUuid, string $privateKey, bool $execInDocker): Collection
{
$customSshKeyLocation = "/root/.ssh/id_rsa_coolify_{$deploymentUuid}";
$commands = collect([]);
if (! $execInDocker) {
$commands->push($this->gitCommand("trap 'rm -f {$customSshKeyLocation}' EXIT"));
}
$commands->push($this->gitCommand($execInDocker ? executeInDocker($deploymentUuid, 'mkdir -p /root/.ssh') : 'mkdir -p /root/.ssh'));
$commands->push([
'command' => $execInDocker
? executeInDocker($deploymentUuid, "echo '{$privateKey}' | base64 -d | tee {$customSshKeyLocation} > /dev/null")
: "echo '{$privateKey}' | base64 -d | tee {$customSshKeyLocation} > /dev/null",
'hidden' => true,
'skip_command_log' => true,
]);
$commands->push($this->gitCommand($execInDocker ? executeInDocker($deploymentUuid, "chmod 600 {$customSshKeyLocation}") : "chmod 600 {$customSshKeyLocation}"));
return $commands;
}
private function withGitHttpTransportConfig(?string $gitConfigOptions = null): string
{
return trim(($gitConfigOptions ? "{$gitConfigOptions} " : '').'-c http.version=HTTP/1.1');
@@ -1605,9 +1620,9 @@ class Application extends BaseModel
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, gitConfigOptions: $gitConfigOptions);
}
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, $git_clone_command));
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, $git_clone_command)));
} else {
$commands->push($git_clone_command);
$commands->push($this->gitCommand($git_clone_command));
}
} else {
$github_access_token = generateGithubInstallationToken($this->source);
@@ -1634,9 +1649,9 @@ class Application extends BaseModel
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: false, commit: $commit, gitConfigOptions: $gitConfigOptions);
}
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, $git_clone_command));
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, $git_clone_command)));
} else {
$commands->push($git_clone_command);
$commands->push($this->gitCommand($git_clone_command));
}
}
if ($pull_request_id !== 0) {
@@ -1646,14 +1661,14 @@ class Application extends BaseModel
$gitCommand = isset($gitConfigOptions) ? "git {$gitConfigOptions}" : 'git';
$escapedPrBranch = escapeshellarg($branch);
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, "cd {$escapedBaseDir} && {$gitCommand} fetch origin {$escapedPrBranch} && $git_checkout_command"));
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, "cd {$escapedBaseDir} && {$gitCommand} fetch origin {$escapedPrBranch} && $git_checkout_command")));
} else {
$commands->push("cd {$escapedBaseDir} && {$gitCommand} fetch origin {$escapedPrBranch} && $git_checkout_command");
$commands->push($this->gitCommand("cd {$escapedBaseDir} && {$gitCommand} fetch origin {$escapedPrBranch} && $git_checkout_command"));
}
}
return [
'commands' => $commands->implode(' && '),
'commands' => $this->gitCommandsAsShellCommand($commands),
'branch' => $branch,
'fullRepoUrl' => $fullRepoUrl,
];
@@ -1676,39 +1691,26 @@ class Application extends BaseModel
} else {
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitSshCommand: $gitlabSshCommand);
}
if ($exec_in_docker) {
$commands = collect([
executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'),
executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null"),
executeInDocker($deployment_uuid, "chmod 600 {$customSshKeyLocation}"),
]);
} else {
$commands = collect([
"trap 'rm -f {$customSshKeyLocation}' EXIT",
'mkdir -p /root/.ssh',
"echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null",
"chmod 600 {$customSshKeyLocation}",
]);
}
$commands = $this->gitSshKeySetupCommands($deployment_uuid, $private_key, $exec_in_docker);
if ($pull_request_id !== 0) {
$branch = "merge-requests/{$pull_request_id}/head:$pr_branch_name";
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"));
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")));
} else {
$commands->push("echo 'Checking out $branch'");
$commands->push($this->gitCommand("echo 'Checking out $branch'"));
}
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$gitlabGitSshCommand} git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $gitlabSshCommand);
}
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, $git_clone_command));
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, $git_clone_command)));
} else {
$commands->push($git_clone_command);
$commands->push($this->gitCommand($git_clone_command));
}
return [
'commands' => $commands->implode(' && '),
'commands' => $commands,
'branch' => $branch,
'fullRepoUrl' => $fullRepoUrl,
];
@@ -1725,13 +1727,13 @@ class Application extends BaseModel
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command, public: true, commit: $commit, gitConfigOptions: $gitConfigOptions);
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, $git_clone_command));
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, $git_clone_command)));
} else {
$commands->push($git_clone_command);
$commands->push($this->gitCommand($git_clone_command));
}
return [
'commands' => $commands->implode(' && '),
'commands' => $this->gitCommandsAsShellCommand($commands),
'branch' => $branch,
'fullRepoUrl' => $fullRepoUrl,
];
@@ -1753,55 +1755,42 @@ class Application extends BaseModel
} else {
$git_clone_command = $this->setGitImportSettings($deployment_uuid, $git_clone_command_base, commit: $commit, gitSshCommand: $deployKeySshCommand);
}
if ($exec_in_docker) {
$commands = collect([
executeInDocker($deployment_uuid, 'mkdir -p /root/.ssh'),
executeInDocker($deployment_uuid, "echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null"),
executeInDocker($deployment_uuid, "chmod 600 {$customSshKeyLocation}"),
]);
} else {
$commands = collect([
"trap 'rm -f {$customSshKeyLocation}' EXIT",
'mkdir -p /root/.ssh',
"echo '{$private_key}' | base64 -d | tee {$customSshKeyLocation} > /dev/null",
"chmod 600 {$customSshKeyLocation}",
]);
}
$commands = $this->gitSshKeySetupCommands($deployment_uuid, $private_key, $exec_in_docker);
if ($pull_request_id !== 0) {
if ($git_type === 'gitlab') {
$branch = "merge-requests/{$pull_request_id}/head:$pr_branch_name";
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"));
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")));
} else {
$commands->push("echo 'Checking out $branch'");
$commands->push($this->gitCommand("echo 'Checking out $branch'"));
}
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$deployKeyGitSshCommand} git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $deployKeySshCommand);
} elseif ($git_type === 'github' || $git_type === 'gitea') {
$branch = "pull/{$pull_request_id}/head:$pr_branch_name";
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"));
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")));
} else {
$commands->push("echo 'Checking out $branch'");
$commands->push($this->gitCommand("echo 'Checking out $branch'"));
}
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$deployKeyGitSshCommand} git fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $deployKeySshCommand);
} elseif ($git_type === 'bitbucket') {
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"));
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")));
} else {
$commands->push("echo 'Checking out $branch'");
$commands->push($this->gitCommand("echo 'Checking out $branch'"));
}
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$deployKeyGitSshCommand} ".$this->buildGitCheckoutCommand($commit, $deployKeySshCommand);
}
}
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, $git_clone_command));
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, $git_clone_command)));
} else {
$commands->push($git_clone_command);
$commands->push($this->gitCommand($git_clone_command));
}
return [
'commands' => $commands->implode(' && '),
'commands' => $commands,
'branch' => $branch,
'fullRepoUrl' => $fullRepoUrl,
];
@@ -1822,37 +1811,37 @@ class Application extends BaseModel
if ($git_type === 'gitlab') {
$branch = "merge-requests/{$pull_request_id}/head:$pr_branch_name";
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"));
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")));
} else {
$commands->push("echo 'Checking out $branch'");
$commands->push($this->gitCommand("echo 'Checking out $branch'"));
}
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" {$gitCommand} fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand, $gitConfigOptions);
} elseif ($git_type === 'github' || $git_type === 'gitea') {
$branch = "pull/{$pull_request_id}/head:$pr_branch_name";
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"));
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")));
} else {
$commands->push("echo 'Checking out $branch'");
$commands->push($this->gitCommand("echo 'Checking out $branch'"));
}
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" {$gitCommand} fetch origin $branch && ".$this->buildGitCheckoutCommand($pr_branch_name, $otherSshCommand, $gitConfigOptions);
} elseif ($git_type === 'bitbucket') {
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, "echo 'Checking out $branch'"));
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, "echo 'Checking out $branch'")));
} else {
$commands->push("echo 'Checking out $branch'");
$commands->push($this->gitCommand("echo 'Checking out $branch'"));
}
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && GIT_SSH_COMMAND=\"{$otherSshCommand}\" ".$this->buildGitCheckoutCommand($commit, $otherSshCommand, $gitConfigOptions);
}
}
if ($exec_in_docker) {
$commands->push(executeInDocker($deployment_uuid, $git_clone_command));
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, $git_clone_command)));
} else {
$commands->push($git_clone_command);
$commands->push($this->gitCommand($git_clone_command));
}
return [
'commands' => $commands->implode(' && '),
'commands' => $this->gitCommandsAsShellCommand($commands),
'branch' => $branch,
'fullRepoUrl' => $fullRepoUrl,
];
@@ -1941,6 +1930,7 @@ class Application extends BaseModel
}
$uuid = new_public_id();
['commands' => $cloneCommand] = $this->generateGitImportCommands(deployment_uuid: $uuid, only_checkout: true, exec_in_docker: false, custom_base_dir: 'checkout');
$cloneCommand = $this->gitCommandsAsShellCommand($cloneCommand);
$cloneCommand = str_replace(' clone ', ' clone --quiet ', $cloneCommand);
$workdir = rtrim($this->base_directory, '/');
$composeFile = $this->docker_compose_location;
+26 -8
View File
@@ -21,6 +21,7 @@ class LocalFileVolume extends BaseModel
// 'mount_path' => 'encrypted',
'content' => 'encrypted',
'is_directory' => 'boolean',
'is_host_file' => 'boolean',
'is_preview_suffix_enabled' => 'boolean',
];
@@ -37,6 +38,7 @@ class LocalFileVolume extends BaseModel
'resource_type',
'resource_id',
'is_directory',
'is_host_file',
'chown',
'chmod',
'is_based_on_git',
@@ -48,6 +50,10 @@ class LocalFileVolume extends BaseModel
protected static function booted()
{
static::created(function (LocalFileVolume $fileVolume) {
if ($fileVolume->is_host_file) {
return;
}
$fileVolume->load(['service']);
dispatch(new ServerStorageSaveJob($fileVolume));
});
@@ -74,6 +80,10 @@ class LocalFileVolume extends BaseModel
public function loadStorageOnServer()
{
if ($this->is_host_file) {
return;
}
$this->load(['service']);
$isService = data_get($this->resource, 'service');
if ($isService) {
@@ -128,6 +138,10 @@ class LocalFileVolume extends BaseModel
public function deleteStorageOnServer()
{
if ($this->is_host_file) {
return;
}
$this->load(['service']);
$isService = data_get($this->resource, 'service');
if ($isService) {
@@ -165,6 +179,10 @@ class LocalFileVolume extends BaseModel
public function saveStorageOnServer()
{
if ($this->is_host_file) {
return;
}
$this->load(['service']);
$isService = data_get($this->resource, 'service');
if ($isService) {
@@ -175,26 +193,26 @@ class LocalFileVolume extends BaseModel
$server = $this->resource->destination->server;
}
$commands = collect([]);
// Validate fs_path early before any shell interpolation
validateShellSafePath($this->fs_path, 'storage path');
$escapedFsPath = escapeshellarg($this->fs_path);
$escapedWorkdir = escapeshellarg($workdir);
if ($this->is_directory) {
// Validate fs_path early before any shell interpolation
validateShellSafePath($this->fs_path, 'storage path');
$escapedFsPath = escapeshellarg($this->fs_path);
$commands->push("mkdir -p {$escapedFsPath} > /dev/null 2>&1 || true");
$commands->push("mkdir -p {$escapedWorkdir} > /dev/null 2>&1 || true");
$commands->push("cd {$escapedWorkdir}");
}
if (str($this->fs_path)->startsWith('.') || str($this->fs_path)->startsWith('/') || str($this->fs_path)->startsWith('~')) {
$parent_dir = str($this->fs_path)->beforeLast('/');
$path = data_get_str($this, 'fs_path');
$content = data_get($this, 'content');
$pathForParentDirectory = str($this->fs_path);
if ($pathForParentDirectory->startsWith('.') || $pathForParentDirectory->startsWith('/') || $pathForParentDirectory->startsWith('~')) {
$parent_dir = $pathForParentDirectory->beforeLast('/');
if ($parent_dir != '') {
$escapedParentDir = escapeshellarg($parent_dir);
$commands->push("mkdir -p {$escapedParentDir} > /dev/null 2>&1 || true");
}
}
$path = data_get_str($this, 'fs_path');
$content = data_get($this, 'content');
if ($path->startsWith('.')) {
$path = $path->after('.');
$path = $workdir.$path;
+15 -3
View File
@@ -3,6 +3,7 @@
namespace App\Models;
use App\Rules\SafeWebhookUrl;
use App\Rules\ValidS3BucketName;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
@@ -152,12 +153,22 @@ class S3Storage extends BaseModel
{
try {
$validator = Validator::make(
['endpoint' => $this['endpoint']],
['endpoint' => ['required', new SafeWebhookUrl]],
[
'endpoint' => $this['endpoint'],
'bucket' => $this['bucket'],
],
[
'endpoint' => ['required', new SafeWebhookUrl],
'bucket' => ['required', new ValidS3BucketName],
],
);
if ($validator->fails()) {
$validator->fails();
if ($validator->errors()->has('endpoint')) {
throw new \RuntimeException('S3 endpoint is not allowed: '.$validator->errors()->first('endpoint'));
}
if ($validator->errors()->has('bucket')) {
throw new \RuntimeException('S3 bucket name is not allowed: '.$validator->errors()->first('bucket'));
}
$disk = Storage::build([
'driver' => 's3',
@@ -170,6 +181,7 @@ class S3Storage extends BaseModel
'http' => [
'connect_timeout' => self::CONNECTION_TIMEOUT_SECONDS,
'timeout' => self::REQUEST_TIMEOUT_SECONDS,
'allow_redirects' => false,
],
]);
// Test the connection by listing files with ListObjectsV2 (S3)
+1 -1
View File
@@ -144,6 +144,6 @@ class StandaloneDocker extends BaseModel
public function attachedTo()
{
return $this->applications?->count() > 0 || $this->databases()->count() > 0;
return $this->applications()->exists() || $this->databases()->count() > 0 || $this->services()->exists();
}
}
+1 -1
View File
@@ -124,6 +124,6 @@ class SwarmDocker extends BaseModel
public function attachedTo()
{
return $this->applications?->count() > 0 || $this->databases()->count() > 0;
return $this->applications()->exists() || $this->databases()->count() > 0 || $this->services()->exists();
}
}
+2
View File
@@ -47,10 +47,12 @@ class Team extends Model implements SendsDiscord, SendsEmail, SendsPushover, Sen
'personal_team',
'show_boarding',
'custom_server_limit',
'is_mcp_server_enabled',
];
protected $casts = [
'personal_team' => 'boolean',
'is_mcp_server_enabled' => 'boolean',
];
protected static function booted()
+1 -1
View File
@@ -69,6 +69,6 @@ class ServiceDatabasePolicy
*/
public function uploadBackup(User $user, ServiceDatabase $serviceDatabase): bool
{
return Gate::allows('uploadBackup', $serviceDatabase->service);
return $user->can('uploadBackup', $serviceDatabase->service);
}
}
+5 -10
View File
@@ -37,12 +37,11 @@ class TeamPolicy
*/
public function update(User $user, Team $team): bool
{
// Only admins and owners can update team settings
if (! $user->teams->contains('id', $team->id)) {
return false;
}
return $user->isAdmin() || $user->isOwner();
return $user->isAdminOfTeam($team->id);
}
/**
@@ -50,12 +49,11 @@ class TeamPolicy
*/
public function delete(User $user, Team $team): bool
{
// Only admins and owners can delete teams
if (! $user->teams->contains('id', $team->id)) {
return false;
}
return $user->isAdmin() || $user->isOwner();
return $user->isAdminOfTeam($team->id);
}
/**
@@ -63,12 +61,11 @@ class TeamPolicy
*/
public function manageMembers(User $user, Team $team): bool
{
// Only admins and owners can manage team members
if (! $user->teams->contains('id', $team->id)) {
return false;
}
return $user->isAdmin() || $user->isOwner();
return $user->isAdminOfTeam($team->id);
}
/**
@@ -76,12 +73,11 @@ class TeamPolicy
*/
public function viewAdmin(User $user, Team $team): bool
{
// Only admins and owners can view admin panel
if (! $user->teams->contains('id', $team->id)) {
return false;
}
return $user->isAdmin() || $user->isOwner();
return $user->isAdminOfTeam($team->id);
}
/**
@@ -89,11 +85,10 @@ class TeamPolicy
*/
public function manageInvitations(User $user, Team $team): bool
{
// Only admins and owners can manage invitations
if (! $user->teams->contains('id', $team->id)) {
return false;
}
return $user->isAdmin() || $user->isOwner();
return $user->isAdminOfTeam($team->id);
}
}
+122 -24
View File
@@ -8,6 +8,11 @@ use Illuminate\Support\Facades\Log;
class SafeExternalUrl implements ValidationRule
{
/**
* @param (Closure(string): array<int, string>)|null $resolver
*/
public function __construct(private ?Closure $resolver = null) {}
/**
* Run the validation rule.
*
@@ -38,44 +43,137 @@ class SafeExternalUrl implements ValidationRule
}
$host = strtolower($host);
$hostForIpCheck = $this->normalizeHostForIpCheck($host);
$hostForDns = rtrim($hostForIpCheck, '.');
// Block well-known internal hostnames
$internalHosts = ['localhost', '0.0.0.0', '::1'];
if (in_array($host, $internalHosts) || str_ends_with($host, '.local') || str_ends_with($host, '.internal')) {
Log::warning('External URL points to internal host', [
'attribute' => $attribute,
'url' => $value,
'host' => $host,
'ip' => request()->ip(),
'user_id' => auth()->id(),
]);
if (in_array($hostForDns, $internalHosts, true) || str_ends_with($hostForDns, '.local') || str_ends_with($hostForDns, '.internal')) {
$this->logBlockedHost($attribute, $value, $host);
$fail('The :attribute must not point to internal hosts.');
return;
}
// Resolve hostname to IP and block private/reserved ranges
$ip = gethostbyname($host);
if (filter_var($hostForIpCheck, FILTER_VALIDATE_IP)) {
if (! $this->isPublicIp($hostForIpCheck)) {
$this->logBlockedIp($attribute, $value, $host, $hostForIpCheck);
$fail('The :attribute must not point to a private or reserved IP address.');
// gethostbyname returns the original hostname on failure (e.g. unresolvable)
if ($ip === $host && ! filter_var($host, FILTER_VALIDATE_IP)) {
return;
}
return;
}
$resolvedIps = $this->resolveHost($hostForDns);
if ($resolvedIps === []) {
$fail('The :attribute host could not be resolved.');
return;
}
if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
Log::warning('External URL resolves to private or reserved IP', [
'attribute' => $attribute,
'url' => $value,
'host' => $host,
'resolved_ip' => $ip,
'ip' => request()->ip(),
'user_id' => auth()->id(),
]);
$fail('The :attribute must not point to a private or reserved IP address.');
foreach ($resolvedIps as $resolvedIp) {
if (! $this->isPublicIp($resolvedIp)) {
$this->logBlockedIp($attribute, $value, $host, $resolvedIp);
$fail('The :attribute must not point to a private or reserved IP address.');
return;
return;
}
}
}
private function normalizeHostForIpCheck(string $host): string
{
return (str_starts_with($host, '[') && str_ends_with($host, ']'))
? substr($host, 1, -1)
: $host;
}
/**
* @return array<int, string>
*/
private function resolveHost(string $host): array
{
if ($this->resolver instanceof Closure) {
return array_values(array_filter(($this->resolver)($host), fn (string $ip): bool => filter_var($ip, FILTER_VALIDATE_IP) !== false));
}
$records = @dns_get_record($host, DNS_A | DNS_AAAA);
if ($records === false) {
$records = [];
}
$ips = [];
foreach ($records as $record) {
foreach (['ip', 'ipv6'] as $key) {
if (isset($record[$key]) && filter_var($record[$key], FILTER_VALIDATE_IP)) {
$ips[] = $record[$key];
}
}
}
$ipv4Addresses = @gethostbynamel($host);
if (is_array($ipv4Addresses)) {
foreach ($ipv4Addresses as $ip) {
if (filter_var($ip, FILTER_VALIDATE_IP)) {
$ips[] = $ip;
}
}
}
return array_values(array_unique($ips));
}
private function isPublicIp(string $ip): bool
{
$embeddedIpv4 = $this->extractIpv4FromMappedIpv6($ip);
if ($embeddedIpv4 !== null) {
return filter_var($embeddedIpv4, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false;
}
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false;
}
private function extractIpv4FromMappedIpv6(string $ip): ?string
{
$packed = @inet_pton($ip);
if ($packed === false || strlen($packed) !== 16) {
return null;
}
$prefix = substr($packed, 0, 12);
if ($prefix !== str_repeat("\0", 10)."\xff\xff") {
return null;
}
$parts = unpack('C4', substr($packed, 12, 4));
if ($parts === false) {
return null;
}
return implode('.', $parts);
}
private function logBlockedHost(string $attribute, string $url, string $host): void
{
Log::warning('External URL points to internal host', [
'attribute' => $attribute,
'url' => $url,
'host' => $host,
'ip' => request()->ip(),
'user_id' => auth()->id(),
]);
}
private function logBlockedIp(string $attribute, string $url, string $host, string $resolvedIp): void
{
Log::warning('External URL resolves to private or reserved IP', [
'attribute' => $attribute,
'url' => $url,
'host' => $host,
'resolved_ip' => $resolvedIp,
'ip' => request()->ip(),
'user_id' => auth()->id(),
]);
}
}
+154 -37
View File
@@ -8,6 +8,11 @@ use Illuminate\Support\Facades\Log;
class SafeWebhookUrl implements ValidationRule
{
/**
* @param (Closure(string): array<int, string>)|null $resolver
*/
public function __construct(private ?Closure $resolver = null) {}
/**
* Run the validation rule.
*
@@ -39,63 +44,175 @@ class SafeWebhookUrl implements ValidationRule
}
$host = strtolower($host);
$hostForIpCheck = $this->normalizeHostForIpCheck($host);
$hostForDns = rtrim($hostForIpCheck, '.');
// Strip IPv6 brackets (e.g. "[::1]" -> "::1") before IP checks so bracketed
// literals can't sneak past filter_var FILTER_VALIDATE_IP.
$hostForIpCheck = (str_starts_with($host, '[') && str_ends_with($host, ']'))
? substr($host, 1, -1)
: $host;
// Block well-known dangerous hostnames
$blockedHosts = ['localhost', '0.0.0.0', '::1'];
if (in_array($hostForIpCheck, $blockedHosts) || str_ends_with($host, '.internal')) {
Log::warning('Webhook URL points to blocked host', [
'attribute' => $attribute,
'host' => $host,
'ip' => request()->ip(),
'user_id' => auth()->id(),
]);
if (in_array($hostForDns, $blockedHosts, true) || str_ends_with($hostForDns, '.internal')) {
$this->logBlockedHost($attribute, $host);
$fail('The :attribute must not point to localhost or internal hosts.');
return;
}
// Block loopback (127.0.0.0/8) and link-local/metadata (169.254.0.0/16) when IP is provided directly
if (filter_var($hostForIpCheck, FILTER_VALIDATE_IP) && ($this->isLoopback($hostForIpCheck) || $this->isLinkLocal($hostForIpCheck))) {
Log::warning('Webhook URL points to blocked IP range', [
'attribute' => $attribute,
'host' => $host,
'ip' => request()->ip(),
'user_id' => auth()->id(),
]);
$fail('The :attribute must not point to loopback or link-local addresses.');
if (filter_var($hostForIpCheck, FILTER_VALIDATE_IP)) {
if ($this->isBlockedIp($hostForIpCheck)) {
$this->logBlockedIp($attribute, $host, $hostForIpCheck);
$fail('The :attribute must not point to loopback or link-local addresses.');
return;
}
return;
}
$resolvedIps = $this->resolveHost($hostForDns);
foreach ($resolvedIps as $resolvedIp) {
if ($this->isBlockedIp($resolvedIp)) {
$this->logBlockedIp($attribute, $host, $resolvedIp);
$fail('The :attribute must not point to loopback or link-local addresses.');
return;
}
}
}
private function isLoopback(string $ip): bool
private function normalizeHostForIpCheck(string $host): string
{
return (str_starts_with($host, '[') && str_ends_with($host, ']'))
? substr($host, 1, -1)
: $host;
}
/**
* @return array<int, string>
*/
private function resolveHost(string $host): array
{
if ($this->resolver instanceof Closure) {
return array_values(array_filter(($this->resolver)($host), fn (string $ip): bool => filter_var($ip, FILTER_VALIDATE_IP) !== false));
}
$records = @dns_get_record($host, DNS_A | DNS_AAAA);
if ($records === false) {
$records = [];
}
$ips = [];
foreach ($records as $record) {
foreach (['ip', 'ipv6'] as $key) {
if (isset($record[$key]) && filter_var($record[$key], FILTER_VALIDATE_IP)) {
$ips[] = $record[$key];
}
}
}
$ipv4Addresses = @gethostbynamel($host);
if (is_array($ipv4Addresses)) {
foreach ($ipv4Addresses as $ip) {
if (filter_var($ip, FILTER_VALIDATE_IP)) {
$ips[] = $ip;
}
}
}
return array_values(array_unique($ips));
}
private function isBlockedIp(string $ip): bool
{
$embeddedIpv4 = $this->extractIpv4FromMappedIpv6($ip);
if ($embeddedIpv4 !== null) {
return $this->isBlockedIpv4($embeddedIpv4);
}
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return $this->isBlockedIpv4($ip);
}
return $this->isBlockedIpv6($ip);
}
private function isBlockedIpv4(string $ip): bool
{
// 127.0.0.0/8, 0.0.0.0
if ($ip === '0.0.0.0' || str_starts_with($ip, '127.')) {
return true;
}
// IPv6 loopback
$normalized = @inet_pton($ip);
return $normalized !== false && $normalized === inet_pton('::1');
}
private function isLinkLocal(string $ip): bool
{
// 169.254.0.0/16 — covers cloud metadata at 169.254.169.254
if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
$long = ip2long($ip);
if ($long === false) {
return false;
}
$long = ip2long($ip);
$unsigned = sprintf('%u', $long);
$linkLocalStart = sprintf('%u', ip2long('169.254.0.0'));
$linkLocalEnd = sprintf('%u', ip2long('169.254.255.255'));
return $long !== false && ($long >> 16) === (ip2long('169.254.0.0') >> 16);
return $unsigned >= $linkLocalStart && $unsigned <= $linkLocalEnd;
}
private function isBlockedIpv6(string $ip): bool
{
$packed = @inet_pton($ip);
if ($packed === false) {
return false;
}
if ($packed === inet_pton('::1') || $packed === inet_pton('::')) {
return true;
}
$bytes = unpack('C16', $packed);
if ($bytes === false) {
return false;
}
$firstByte = $bytes[1];
$secondByte = $bytes[2];
// fe80::/10 link-local and fc00::/7 unique local addresses.
return ($firstByte === 0xFE && ($secondByte & 0xC0) === 0x80)
|| (($firstByte & 0xFE) === 0xFC);
}
private function extractIpv4FromMappedIpv6(string $ip): ?string
{
$packed = @inet_pton($ip);
if ($packed === false || strlen($packed) !== 16) {
return null;
}
$prefix = substr($packed, 0, 12);
if ($prefix !== str_repeat("\0", 10)."\xff\xff") {
return null;
}
$parts = unpack('C4', substr($packed, 12, 4));
if ($parts === false) {
return null;
}
return implode('.', $parts);
}
private function logBlockedHost(string $attribute, string $host): void
{
Log::warning('Webhook URL points to blocked host', [
'attribute' => $attribute,
'host' => $host,
'ip' => request()->ip(),
'user_id' => auth()->id(),
]);
}
private function logBlockedIp(string $attribute, string $host, string $blockedIp): void
{
Log::warning('Webhook URL points to blocked IP range', [
'attribute' => $attribute,
'host' => $host,
'resolved_ip' => $blockedIp,
'ip' => request()->ip(),
'user_id' => auth()->id(),
]);
}
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace App\Rules;
use App\Support\ValidationPatterns;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Translation\PotentiallyTranslatedString;
class ValidS3BucketName implements ValidationRule
{
/**
* Run the validation rule.
*
* @param Closure(string, ?string=): PotentiallyTranslatedString $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
if (! is_string($value) || ! ValidationPatterns::isValidS3BucketName($value)) {
$fail('The :attribute must be a valid S3 bucket name: 3-63 lowercase letters, numbers, dots, or hyphens; start and end with a letter or number; no consecutive dots, dot-hyphen pairs, or IP address format.');
}
}
}
+209
View File
@@ -0,0 +1,209 @@
<?php
namespace App\Support;
use Illuminate\Http\UploadedFile;
class DatabaseBackupFileValidator
{
public const ALLOWED_EXTENSIONS = [
'sql',
'sql.gz',
'gz',
'zip',
'tar',
'tar.gz',
'tgz',
'dump',
'bak',
'bson',
'bson.gz',
'archive',
'archive.gz',
'bz2',
'xz',
'dmp',
];
private const DANGEROUS_EXTENSIONS = [
'asp',
'aspx',
'bat',
'bash',
'cgi',
'cmd',
'com',
'exe',
'htm',
'html',
'jar',
'js',
'jsp',
'php',
'php3',
'php4',
'php5',
'phtml',
'pl',
'ps1',
'py',
'rb',
'sh',
];
public static function hasAllowedExtension(string $name): bool
{
return self::extensionFor($name) !== null;
}
public static function isUploadAllowed(UploadedFile $file, int $maxBytes): bool
{
$originalName = $file->getClientOriginalName();
$size = $file->getSize();
if ($size === false || $size > $maxBytes) {
return false;
}
$extension = self::extensionFor($originalName);
if ($extension === null) {
return false;
}
return self::contentMatchesExtension($file->getPathname(), $extension);
}
/**
* Scan a stored backup file (decompressing gzip on the fly) for PostgreSQL
* restore directives that lead to OS command execution.
*/
public static function fileContainsPostgresqlProgramExecution(string $path): bool
{
$contents = self::readPossiblyGzippedText($path);
if ($contents === null) {
return false;
}
return self::containsPostgresqlProgramExecution($contents);
}
public static function containsPostgresqlProgramExecution(string $sql): bool
{
$withoutComments = self::stripSqlComments($sql);
if (preg_match('/^\s*\\\\(?:!|copy\b.*\bprogram\b)/mi', $withoutComments) === 1) {
return true;
}
return preg_match('/\bcopy\b[\s\S]{0,2000}\b(?:from|to)\s+program\b/i', $withoutComments) === 1;
}
private static function extensionFor(string $name): ?string
{
$lower = strtolower($name);
$suffixes = array_map(fn (string $ext) => '.'.$ext, self::ALLOWED_EXTENSIONS);
usort($suffixes, fn (string $a, string $b) => strlen($b) <=> strlen($a));
foreach ($suffixes as $suffix) {
if (! str_ends_with($lower, $suffix)) {
continue;
}
$stem = substr($lower, 0, -strlen($suffix));
if ($stem === '' || str_ends_with($stem, '.')) {
return null;
}
$parts = array_filter(explode('.', $stem));
if (array_intersect($parts, self::DANGEROUS_EXTENSIONS) !== []) {
return null;
}
return ltrim($suffix, '.');
}
return null;
}
private static function contentMatchesExtension(string $path, string $extension): bool
{
$sample = (string) file_get_contents($path, false, null, 0, 4096);
return match ($extension) {
'sql' => self::looksLikeText($sample) && ! self::containsPostgresqlProgramExecution($sample),
'sql.gz', 'gz', 'tar.gz', 'tgz', 'bson.gz', 'archive.gz' => str_starts_with($sample, "\x1f\x8b"),
'zip' => str_starts_with($sample, "PK\x03\x04") || str_starts_with($sample, "PK\x05\x06") || str_starts_with($sample, "PK\x07\x08"),
'tar' => substr($sample, 257, 5) === 'ustar',
'bz2' => str_starts_with($sample, 'BZh'),
'xz' => str_starts_with($sample, "\xfd7zXZ\x00"),
'dump', 'bak', 'archive', 'dmp' => str_starts_with($sample, 'PGDMP')
|| (self::looksLikeText($sample) && ! self::containsPostgresqlProgramExecution($sample)),
'bson' => self::looksLikeBson($path, $sample),
default => false,
};
}
private static function readPossiblyGzippedText(string $path): ?string
{
// Cap the scan so a huge legitimate dump cannot exhaust memory; the
// remote pre-restore scanner inspects the full file as a second layer.
$maxBytes = 50 * 1024 * 1024;
$handle = @fopen($path, 'rb');
if ($handle === false) {
return null;
}
$magic = (string) fread($handle, 2);
fclose($handle);
if ($magic === "\x1f\x8b") {
$gz = @gzopen($path, 'rb');
if ($gz === false) {
return null;
}
$data = '';
while (! gzeof($gz) && strlen($data) < $maxBytes) {
$chunk = gzread($gz, 1024 * 1024);
if ($chunk === false || $chunk === '') {
break;
}
$data .= $chunk;
}
gzclose($gz);
return $data;
}
return (string) file_get_contents($path, false, null, 0, $maxBytes);
}
private static function looksLikeText(string $sample): bool
{
if ($sample === '' || str_contains($sample, "\0")) {
return false;
}
return mb_check_encoding($sample, 'UTF-8') || mb_check_encoding($sample, 'ASCII');
}
private static function looksLikeBson(string $path, string $sample): bool
{
if (strlen($sample) < 5) {
return false;
}
$documentLength = unpack('V', substr($sample, 0, 4))[1] ?? 0;
$fileSize = filesize($path) ?: 0;
return $documentLength >= 5 && $documentLength <= $fileSize;
}
private static function stripSqlComments(string $sql): string
{
$sql = preg_replace('/\/\*[\s\S]*?\*\//', ' ', $sql) ?? $sql;
return preg_replace('/--[^\r\n]*/', ' ', $sql) ?? $sql;
}
}
+29 -4
View File
@@ -94,10 +94,19 @@ class ValidationPatterns
public const DOCKER_NETWORK_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/';
/**
* Pattern for Docker-compatible environment variable keys.
* Docker environment entries are KEY=value strings, so keys must be non-empty and cannot contain '=' or NUL.
* Pattern for S3 bucket names.
*
* Bucket names must be 3-63 lowercase characters, start and end with a
* letter or digit, and contain only lowercase letters, digits, dots, and
* hyphens. Additional semantic checks live in isValidS3BucketName().
*/
public const ENVIRONMENT_VARIABLE_KEY_PATTERN = '/\A[^=\x00]+\z/u';
public const S3_BUCKET_NAME_PATTERN = '/\A(?=.{3,63}\z)[a-z0-9][a-z0-9.-]*[a-z0-9]\z/';
/**
* Pattern for Docker-compatible environment variable keys.
* Environment variable keys are later interpolated into shell commands as Docker build args, so only shell-safe identifier characters are allowed.
*/
public const ENVIRONMENT_VARIABLE_KEY_PATTERN = '/\A[A-Za-z_][A-Za-z0-9_.]*\z/u';
/**
* Pattern for SQL-safe unquoted database identifiers (usernames, database names).
@@ -164,7 +173,7 @@ class ValidationPatterns
public static function environmentVariableKeyMessages(string $field = 'key', string $label = 'key'): array
{
return [
"{$field}.regex" => "The {$label} must be a non-empty Docker-compatible environment variable key and cannot contain '=' or NUL characters.",
"{$field}.regex" => "The {$label} must start with a letter or underscore and may only contain letters, numbers, underscores, and dots.",
"{$field}.max" => "The {$label} may not be greater than :max characters.",
];
}
@@ -177,6 +186,22 @@ class ValidationPatterns
return preg_match(self::ENVIRONMENT_VARIABLE_KEY_PATTERN, $value) === 1;
}
/**
* Check if a string is a valid S3 bucket name.
*/
public static function isValidS3BucketName(string $value): bool
{
if (preg_match(self::S3_BUCKET_NAME_PATTERN, $value) !== 1) {
return false;
}
if (str_contains($value, '..') || str_contains($value, '.-') || str_contains($value, '-.')) {
return false;
}
return filter_var($value, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) === false;
}
/**
* Normalize and validate an environment variable key.
*/
+9 -8
View File
@@ -79,6 +79,7 @@ trait ExecuteRemoteCommand
$ignore_errors = data_get($single_command, 'ignore_errors', false);
$append = data_get($single_command, 'append', true);
$command_hidden = data_get($single_command, 'command_hidden', false);
$skip_command_log = data_get($single_command, 'skip_command_log', false);
$this->save = data_get($single_command, 'save');
if ($this->server->isNonRoot()) {
if (str($command)->startsWith('docker exec')) {
@@ -91,7 +92,7 @@ trait ExecuteRemoteCommand
// Check for cancellation before executing commands
if (isset($this->application_deployment_queue)) {
$this->application_deployment_queue->refresh();
if ($this->application_deployment_queue->status === \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
throw new \RuntimeException('Deployment cancelled by user', 69420);
}
}
@@ -103,7 +104,7 @@ trait ExecuteRemoteCommand
while ($attempt < $maxRetries && ! $commandExecuted) {
try {
$this->executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors, $command_hidden);
$this->executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors, $command_hidden, $skip_command_log);
$commandExecuted = true;
} catch (\RuntimeException|DeploymentException $e) {
$lastError = $e;
@@ -119,7 +120,7 @@ trait ExecuteRemoteCommand
// Check for cancellation during retry wait
$this->application_deployment_queue->refresh();
if ($this->application_deployment_queue->status === \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
throw new \RuntimeException('Deployment cancelled by user during retry', 69420);
}
}
@@ -153,14 +154,14 @@ trait ExecuteRemoteCommand
/**
* Execute the actual command with process handling
*/
private function executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors, $command_hidden = false)
private function executeCommandWithProcess($command, $hidden, $customType, $append, $ignore_errors, $command_hidden = false, $skip_command_log = false)
{
if ($command_hidden && isset($this->application_deployment_queue)) {
if ($command_hidden && ! $skip_command_log && isset($this->application_deployment_queue)) {
$this->application_deployment_queue->addLogEntry('[CMD]: '.$this->redact_sensitive_info($command), hidden: true);
}
$remote_command = SshMultiplexingHelper::generateSshCommand($this->server, $command);
$process = Process::timeout(config('constants.ssh.command_timeout'))->idleTimeout(3600)->start($remote_command, function (string $type, string $output) use ($command, $hidden, $customType, $append, $command_hidden) {
$process = Process::timeout(config('constants.ssh.command_timeout'))->idleTimeout(3600)->start($remote_command, function (string $type, string $output) use ($command, $hidden, $customType, $append, $command_hidden, $skip_command_log) {
$output = str($output)->trim();
if ($output->startsWith('╔')) {
$output = "\n".$output;
@@ -170,7 +171,7 @@ trait ExecuteRemoteCommand
$sanitized_output = sanitize_utf8_text($output);
$new_log_entry = [
'command' => $command_hidden ? null : $this->redact_sensitive_info($command),
'command' => $skip_command_log || $command_hidden ? null : $this->redact_sensitive_info($command),
'output' => $this->redact_sensitive_info($sanitized_output),
'type' => $customType ?? ($type === 'err' ? 'stderr' : 'stdout'),
'timestamp' => Carbon::now('UTC'),
@@ -227,7 +228,7 @@ trait ExecuteRemoteCommand
// Check if deployment was cancelled while command was running
if (isset($this->application_deployment_queue)) {
$this->application_deployment_queue->refresh();
if ($this->application_deployment_queue->status === \App\Enums\ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
if ($this->application_deployment_queue->status === ApplicationDeploymentStatus::CANCELLED_BY_USER->value) {
throw new \RuntimeException('Deployment cancelled by user', 69420);
}
}