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:
Andras Bacsai
2026-10-05 12:03:37 +02:00
parent 2aead9c914
commit 4227ec6ebf
25 changed files with 863 additions and 128 deletions
@@ -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;
+9 -4
View File
@@ -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') {