mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 21:18:11 -04:00
fix(storage): require deploy permission for external host writes
Require deploy or root token access when creating managed mounts outside resource directories or changing their file contents through the API. Synchronize KeyDB and Dragonfly passwords with local and shared REDIS_PASSWORD values while preserving secret manager resolution.
This commit is contained in:
@@ -40,6 +40,7 @@ use Symfony\Component\Yaml\Yaml;
|
||||
class ApplicationsController extends Controller
|
||||
{
|
||||
use Concerns\HandlesTagsApi;
|
||||
use Concerns\RequiresDeployForOutsideHostPaths;
|
||||
|
||||
private const APPLICATION_SETTING_FIELDS = [
|
||||
'is_git_submodules_enabled',
|
||||
@@ -5042,7 +5043,7 @@ class ApplicationsController extends Controller
|
||||
'is_preview_suffix_enabled' => ['type' => 'boolean', 'description' => 'Whether to add -pr-N suffix for preview deployments.'],
|
||||
'name' => ['type' => 'string', 'description' => 'The volume name (persistent only, not allowed for read-only storages).'],
|
||||
'mount_path' => ['type' => 'string', 'description' => 'The container mount path (not allowed for read-only storages).'],
|
||||
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages).'],
|
||||
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages). Changing the content of a file outside the resource directory needs the deploy permission.'],
|
||||
],
|
||||
additionalProperties: false,
|
||||
),
|
||||
@@ -5067,6 +5068,10 @@ class ApplicationsController extends Controller
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 403,
|
||||
description: 'The token needs the deploy permission to change the content of a file outside the resource directory.',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
@@ -5173,6 +5178,10 @@ class ApplicationsController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
if (! $isReadOnly && ($forbidden = $this->outsideContentChangeForbiddenResponse($request, $storage))) {
|
||||
return $forbidden;
|
||||
}
|
||||
|
||||
// Always allowed
|
||||
if ($request->has('is_preview_suffix_enabled')) {
|
||||
$storage->is_preview_suffix_enabled = $request->is_preview_suffix_enabled;
|
||||
@@ -5243,7 +5252,7 @@ class ApplicationsController extends Controller
|
||||
'mount_path' => ['type' => 'string', 'description' => 'The container mount path.'],
|
||||
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional).'],
|
||||
'is_directory' => ['type' => 'boolean', 'description' => 'Whether this is a directory mount (file only, default false).'],
|
||||
'fs_path' => ['type' => 'string', 'description' => 'Host path. Required for directory mounts and host file mounts. Optional for file mounts with content (default: inside the resource directory). An absolute path can be anywhere on the host; a relative path is inside the resource directory. Coolify never deletes a path outside the resource directory.'],
|
||||
'fs_path' => ['type' => 'string', 'description' => 'Host path. Required for directory mounts and host file mounts. Optional for file mounts with content (default: inside the resource directory). An absolute path can be anywhere on the host; a relative path is inside the resource directory. A directory or file mount outside the resource directory needs a token with the deploy permission. Coolify never deletes a path outside the resource directory.'],
|
||||
],
|
||||
additionalProperties: false,
|
||||
),
|
||||
@@ -5259,6 +5268,7 @@ class ApplicationsController extends Controller
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 403, description: 'The token needs the deploy permission for a mount outside the resource directory.'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
)]
|
||||
@@ -5369,6 +5379,15 @@ class ApplicationsController extends Controller
|
||||
try {
|
||||
$fsPath = LocalFileVolume::resolveHostPath(application_configuration_dir().'/'.$application->uuid, $request->fs_path, 'storage source path');
|
||||
$mountPath = validateFileMountPath($request->mount_path, 'storage destination path');
|
||||
$forbidden = $this->outsideHostPathForbiddenResponse($request, new LocalFileVolume([
|
||||
'fs_path' => $fsPath,
|
||||
'is_directory' => true,
|
||||
'resource_id' => $application->id,
|
||||
'resource_type' => get_class($application),
|
||||
]));
|
||||
if ($forbidden) {
|
||||
return $forbidden;
|
||||
}
|
||||
LocalFileVolume::assertHostPathOnServer(application_configuration_dir().'/'.$application->uuid, $fsPath, $application->destination->server, isDirectory: true);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
@@ -5431,6 +5450,14 @@ class ApplicationsController extends Controller
|
||||
try {
|
||||
if ($request->filled('fs_path')) {
|
||||
$fsPath = LocalFileVolume::resolveHostPath(application_configuration_dir().'/'.$application->uuid, $request->fs_path, 'file storage source path');
|
||||
$forbidden = $this->outsideHostPathForbiddenResponse($request, new LocalFileVolume([
|
||||
'fs_path' => $fsPath,
|
||||
'resource_id' => $application->id,
|
||||
'resource_type' => get_class($application),
|
||||
]));
|
||||
if ($forbidden) {
|
||||
return $forbidden;
|
||||
}
|
||||
LocalFileVolume::assertHostPathOnServer(application_configuration_dir().'/'.$application->uuid, $fsPath, $application->destination->server, isDirectory: false);
|
||||
} else {
|
||||
$fsPath = confineFileMountPath(application_configuration_dir().'/'.$application->uuid, $mountPath, 'file storage path');
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Api\Concerns;
|
||||
|
||||
use App\Models\LocalFileVolume;
|
||||
use App\Models\LocalPersistentVolume;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
/**
|
||||
* Coolify writes mounts outside the resource directory on the host without a deployment, so a token
|
||||
* needs the deploy permission (or root) for them.
|
||||
*/
|
||||
trait RequiresDeployForOutsideHostPaths
|
||||
{
|
||||
private function outsideHostPathForbiddenResponse(Request $request, LocalFileVolume $storage): ?JsonResponse
|
||||
{
|
||||
if ($storage->is_host_file || ! $storage->isOutsideResourceDirectory()) {
|
||||
return null;
|
||||
}
|
||||
if ($request->user()->tokenCan('deploy') || $request->user()->tokenCan('root')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return response()->json([
|
||||
'message' => 'Missing required permissions: deploy. A mount outside the resource directory needs a token with the deploy permission.',
|
||||
], 403);
|
||||
}
|
||||
|
||||
private function outsideContentChangeForbiddenResponse(Request $request, LocalFileVolume|LocalPersistentVolume $storage): ?JsonResponse
|
||||
{
|
||||
if (! $storage instanceof LocalFileVolume || $storage->is_directory || ! $request->has('content')) {
|
||||
return null;
|
||||
}
|
||||
if ((string) $request->input('content') === (string) $storage->content) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $this->outsideHostPathForbiddenResponse($request, $storage);
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,7 @@ class DatabasesController extends Controller
|
||||
{
|
||||
use Concerns\HandlesDatabaseImportsApi;
|
||||
use Concerns\HandlesTagsApi;
|
||||
use Concerns\RequiresDeployForOutsideHostPaths;
|
||||
|
||||
#[OA\Post(
|
||||
path: '/databases/{uuid}/imports/uploads',
|
||||
@@ -4290,7 +4291,7 @@ class DatabasesController extends Controller
|
||||
'mount_path' => ['type' => 'string', 'description' => 'The container mount path.'],
|
||||
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional).'],
|
||||
'is_directory' => ['type' => 'boolean', 'description' => 'Whether this is a directory mount (file only, default false).'],
|
||||
'fs_path' => ['type' => 'string', 'description' => 'Host path. Required for directory mounts and host file mounts. Optional for file mounts with content (default: inside the resource directory). An absolute path can be anywhere on the host; a relative path is inside the resource directory. Coolify never deletes a path outside the resource directory.'],
|
||||
'fs_path' => ['type' => 'string', 'description' => 'Host path. Required for directory mounts and host file mounts. Optional for file mounts with content (default: inside the resource directory). An absolute path can be anywhere on the host; a relative path is inside the resource directory. A directory or file mount outside the resource directory needs a token with the deploy permission. Coolify never deletes a path outside the resource directory.'],
|
||||
],
|
||||
additionalProperties: false,
|
||||
),
|
||||
@@ -4306,6 +4307,7 @@ class DatabasesController extends Controller
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 403, description: 'The token needs the deploy permission for a mount outside the resource directory.'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
)]
|
||||
@@ -4412,6 +4414,15 @@ class DatabasesController extends Controller
|
||||
try {
|
||||
$fsPath = LocalFileVolume::resolveHostPath(database_configuration_dir().'/'.$database->uuid, $request->fs_path, 'storage source path');
|
||||
$mountPath = validateFileMountPath($request->mount_path, 'storage destination path');
|
||||
$forbidden = $this->outsideHostPathForbiddenResponse($request, new LocalFileVolume([
|
||||
'fs_path' => $fsPath,
|
||||
'is_directory' => true,
|
||||
'resource_id' => $database->id,
|
||||
'resource_type' => get_class($database),
|
||||
]));
|
||||
if ($forbidden) {
|
||||
return $forbidden;
|
||||
}
|
||||
LocalFileVolume::assertHostPathOnServer(database_configuration_dir().'/'.$database->uuid, $fsPath, $database->destination->server, isDirectory: true);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
@@ -4474,6 +4485,14 @@ class DatabasesController extends Controller
|
||||
try {
|
||||
if ($request->filled('fs_path')) {
|
||||
$fsPath = LocalFileVolume::resolveHostPath(database_configuration_dir().'/'.$database->uuid, $request->fs_path, 'file storage source path');
|
||||
$forbidden = $this->outsideHostPathForbiddenResponse($request, new LocalFileVolume([
|
||||
'fs_path' => $fsPath,
|
||||
'resource_id' => $database->id,
|
||||
'resource_type' => get_class($database),
|
||||
]));
|
||||
if ($forbidden) {
|
||||
return $forbidden;
|
||||
}
|
||||
LocalFileVolume::assertHostPathOnServer(database_configuration_dir().'/'.$database->uuid, $fsPath, $database->destination->server, isDirectory: false);
|
||||
} else {
|
||||
$fsPath = confineFileMountPath(database_configuration_dir().'/'.$database->uuid, $mountPath, 'file storage path');
|
||||
@@ -4543,7 +4562,7 @@ class DatabasesController extends Controller
|
||||
'is_preview_suffix_enabled' => ['type' => 'boolean', 'description' => 'Whether to add -pr-N suffix for preview deployments.'],
|
||||
'name' => ['type' => 'string', 'description' => 'The volume name (persistent only, not allowed for read-only storages).'],
|
||||
'mount_path' => ['type' => 'string', 'description' => 'The container mount path (not allowed for read-only storages).'],
|
||||
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages).'],
|
||||
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages). Changing the content of a file outside the resource directory needs the deploy permission.'],
|
||||
],
|
||||
additionalProperties: false,
|
||||
),
|
||||
@@ -4568,6 +4587,10 @@ class DatabasesController extends Controller
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 403,
|
||||
description: 'The token needs the deploy permission to change the content of a file outside the resource directory.',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
@@ -4671,6 +4694,10 @@ class DatabasesController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
if (! $isReadOnly && ($forbidden = $this->outsideContentChangeForbiddenResponse($request, $storage))) {
|
||||
return $forbidden;
|
||||
}
|
||||
|
||||
// Always allowed
|
||||
if ($request->has('is_preview_suffix_enabled')) {
|
||||
$storage->is_preview_suffix_enabled = $request->is_preview_suffix_enabled;
|
||||
|
||||
@@ -31,6 +31,7 @@ use Symfony\Component\Yaml\Yaml;
|
||||
class ServicesController extends Controller
|
||||
{
|
||||
use Concerns\HandlesTagsApi;
|
||||
use Concerns\RequiresDeployForOutsideHostPaths;
|
||||
|
||||
protected function findTaggableResource(string $uuid, int|string $teamId): mixed
|
||||
{
|
||||
@@ -2476,7 +2477,7 @@ class ServicesController extends Controller
|
||||
'mount_path' => ['type' => 'string', 'description' => 'The container mount path.'],
|
||||
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'File content (file only, optional).'],
|
||||
'is_directory' => ['type' => 'boolean', 'description' => 'Whether this is a directory mount (file only, default false).'],
|
||||
'fs_path' => ['type' => 'string', 'description' => 'Host path. Required for directory mounts and host file mounts. Optional for file mounts with content (default: inside the resource directory). An absolute path can be anywhere on the host; a relative path is inside the resource directory. Coolify never deletes a path outside the resource directory.'],
|
||||
'fs_path' => ['type' => 'string', 'description' => 'Host path. Required for directory mounts and host file mounts. Optional for file mounts with content (default: inside the resource directory). An absolute path can be anywhere on the host; a relative path is inside the resource directory. A directory or file mount outside the resource directory needs a token with the deploy permission. Coolify never deletes a path outside the resource directory.'],
|
||||
],
|
||||
additionalProperties: false,
|
||||
),
|
||||
@@ -2492,6 +2493,7 @@ class ServicesController extends Controller
|
||||
new OA\Response(response: 401, ref: '#/components/responses/401'),
|
||||
new OA\Response(response: 400, ref: '#/components/responses/400'),
|
||||
new OA\Response(response: 404, ref: '#/components/responses/404'),
|
||||
new OA\Response(response: 403, description: 'The token needs the deploy permission for a mount outside the resource directory.'),
|
||||
new OA\Response(response: 422, ref: '#/components/responses/422'),
|
||||
]
|
||||
)]
|
||||
@@ -2607,6 +2609,15 @@ class ServicesController extends Controller
|
||||
try {
|
||||
$fsPath = LocalFileVolume::resolveHostPath(service_configuration_dir().'/'.$service->uuid, $request->fs_path, 'storage source path');
|
||||
$mountPath = validateFileMountPath($request->mount_path, 'storage destination path');
|
||||
$forbidden = $this->outsideHostPathForbiddenResponse($request, new LocalFileVolume([
|
||||
'fs_path' => $fsPath,
|
||||
'is_directory' => true,
|
||||
'resource_id' => $subResource->id,
|
||||
'resource_type' => get_class($subResource),
|
||||
]));
|
||||
if ($forbidden) {
|
||||
return $forbidden;
|
||||
}
|
||||
LocalFileVolume::assertHostPathOnServer(service_configuration_dir().'/'.$service->uuid, $fsPath, $service->server, isDirectory: true);
|
||||
} catch (\Throwable $e) {
|
||||
return response()->json([
|
||||
@@ -2669,6 +2680,14 @@ class ServicesController extends Controller
|
||||
try {
|
||||
if ($request->filled('fs_path')) {
|
||||
$fsPath = LocalFileVolume::resolveHostPath(service_configuration_dir().'/'.$service->uuid, $request->fs_path, 'file storage source path');
|
||||
$forbidden = $this->outsideHostPathForbiddenResponse($request, new LocalFileVolume([
|
||||
'fs_path' => $fsPath,
|
||||
'resource_id' => $subResource->id,
|
||||
'resource_type' => get_class($subResource),
|
||||
]));
|
||||
if ($forbidden) {
|
||||
return $forbidden;
|
||||
}
|
||||
LocalFileVolume::assertHostPathOnServer(service_configuration_dir().'/'.$service->uuid, $fsPath, $service->server, isDirectory: false);
|
||||
} else {
|
||||
$fsPath = confineFileMountPath(service_configuration_dir().'/'.$service->uuid, $mountPath, 'file storage path');
|
||||
@@ -2738,7 +2757,7 @@ class ServicesController extends Controller
|
||||
'is_preview_suffix_enabled' => ['type' => 'boolean', 'description' => 'Whether to add -pr-N suffix for preview deployments.'],
|
||||
'name' => ['type' => 'string', 'description' => 'The volume name (persistent only, not allowed for read-only storages).'],
|
||||
'mount_path' => ['type' => 'string', 'description' => 'The container mount path (not allowed for read-only storages).'],
|
||||
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages).'],
|
||||
'content' => ['type' => 'string', 'nullable' => true, 'description' => 'The file content (file only, not allowed for read-only storages). Changing the content of a file outside the resource directory needs the deploy permission.'],
|
||||
],
|
||||
additionalProperties: false,
|
||||
),
|
||||
@@ -2763,6 +2782,10 @@ class ServicesController extends Controller
|
||||
response: 404,
|
||||
ref: '#/components/responses/404',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 403,
|
||||
description: 'The token needs the deploy permission to change the content of a file outside the resource directory.',
|
||||
),
|
||||
new OA\Response(
|
||||
response: 422,
|
||||
ref: '#/components/responses/422',
|
||||
@@ -2896,6 +2919,10 @@ class ServicesController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
if (! $isReadOnly && ($forbidden = $this->outsideContentChangeForbiddenResponse($request, $storage))) {
|
||||
return $forbidden;
|
||||
}
|
||||
|
||||
// Always allowed
|
||||
if ($request->has('is_preview_suffix_enabled')) {
|
||||
$storage->is_preview_suffix_enabled = $request->is_preview_suffix_enabled;
|
||||
|
||||
@@ -316,10 +316,15 @@ class Github extends Controller
|
||||
? GithubRunnerExecution::query()->where('github_app_id', $githubApp->id)->where('runner_name', $runnerName)->first()
|
||||
: null;
|
||||
|
||||
if ($action === 'in_progress' && $execution && in_array($execution->status, [GithubRunnerStatus::Provisioning, GithubRunnerStatus::Idle], true)) {
|
||||
$execution->update([...$jobDetails, 'status' => GithubRunnerStatus::Running, 'started_at' => now()]);
|
||||
|
||||
return response('Runner marked running.');
|
||||
// The completed hook can arrive before the in_progress hook.
|
||||
if (in_array($action, ['in_progress', 'completed'], true) && $execution && in_array($execution->status, [GithubRunnerStatus::Provisioning, GithubRunnerStatus::Idle], true)) {
|
||||
$replacement = $execution->assignJob($jobId, $jobDetails);
|
||||
if ($replacement) {
|
||||
ProvisionGithubRunnerJob::dispatch($replacement->id);
|
||||
}
|
||||
if ($action === 'in_progress') {
|
||||
return response('Runner marked running.');
|
||||
}
|
||||
}
|
||||
|
||||
if ($action === 'completed') {
|
||||
|
||||
Reference in New Issue
Block a user