Root cause
Validation & fail-open logic
feat: add Hetzner server provisioning API endpoints
How AI contributed: New attack surfacePublished Jul 6, 2026 in
coollabsio/coolifyAll 4 findings in this repository
A Claude-assisted commit added cloud provider validation surfaces to the Coolify API and registered POST /api/v1/cloud-tokens/{uuid}/validate under the api.ability:read ability, while every other token lifecycle route required write access. The validate handler makes outbound Hetzner and DigitalOcean API calls with the stored provider token, so a read-only API token could trigger state-changing provider work that it should never reach. The fix moves both that route and GET /api/v1/servers/{uuid}/validate to api.ability:write and adds tests asserting that read-only tokens receive 403.
The POST /api/v1/cloud-tokens/{uuid}/validate route was declared with the api.ability:read ability even though the operation changes provider state, so any token that only proved read access could invoke it. Requiring api.ability:write on the route makes the requested ability match the action performed.
Root cause
feat: add Hetzner server provisioning API endpoints
Fix
The fix changes both cloud-token and server validation routes from the read ability to the write ability, so read-only API tokens can no longer invoke these state-changing validation operations.
Case facts
@@ -1,9 +1,11 @@ <?php use App\Http\Controllers\Api\ApplicationsController;+use App\Http\Controllers\Api\CloudProviderTokensController; use App\Http\Controllers\Api\DatabasesController; use App\Http\Controllers\Api\DeployController; use App\Http\Controllers\Api\GithubController;+use App\Http\Controllers\Api\HetznerController; use App\Http\Controllers\Api\OtherController; use App\Http\Controllers\Api\ProjectController; use App\Http\Controllers\Api\ResourcesController;@@ -63,6 +65,13 @@ Route::patch('/security/keys/{uuid}', [SecurityController::class, 'update_key'])->middleware(['api.ability:write']); Route::delete('/security/keys/{uuid}', [SecurityController::class, 'delete_key'])->middleware(['api.ability:write']); + Route::get('/cloud-tokens', [CloudProviderTokensController::class, 'index'])->middleware(['api.ability:read']);+ Route::post('/cloud-tokens', [CloudProviderTokensController::class, 'store'])->middleware(['api.ability:write']);+ Route::get('/cloud-tokens/{uuid}', [CloudProviderTokensController::class, 'show'])->middleware(['api.ability:read']);+ Route::patch('/cloud-tokens/{uuid}', [CloudProviderTokensController::class, 'update'])->middleware(['api.ability:write']);+ Route::delete('/cloud-tokens/{uuid}', [CloudProviderTokensController::class, 'destroy'])->middleware(['api.ability:write']);+ Route::post('/cloud-tokens/{uuid}/validate', [CloudProviderTokensController::class, 'validate'])->middleware(['api.ability:read']);+ Route::match(['get', 'post'], '/deploy', [DeployController::class, 'deploy'])->middleware(['api.ability:deploy']); Route::get('/deployments', [DeployController::class, 'deployments'])->middleware(['api.ability:read']); Route::get('/deployments/{uuid}', [DeployController::class, 'deployment_by_uuid'])->middleware(['api.ability:read']);@@ -80,6 +89,12 @@ Route::patch('/servers/{uuid}', [ServersController::class, 'update_server'])->middleware(['api.ability:write']); Route::delete('/servers/{uuid}', [ServersController::class, 'delete_server'])->middleware(['api.ability:write']); + Route::get('/hetzner/locations', [HetznerController::class, 'locations'])->middleware(['api.ability:read']);+ Route::get('/hetzner/server-types', [HetznerController::class, 'serverTypes'])->middleware(['api.ability:read']);+ Route::get('/hetzner/images', [HetznerController::class, 'images'])->middleware(['api.ability:read']);+ Route::get('/hetzner/ssh-keys', [HetznerController::class, 'sshKeys'])->middleware(['api.ability:read']);+ Route::post('/servers/hetzner', [HetznerController::class, 'createServer'])->middleware(['api.ability:write']);+ Route::get('/resources', [ResourcesController::class, 'resources'])->middleware(['api.ability:read']); Route::get('/applications', [ApplicationsController::class, 'applications'])->middleware(['api.ability:read']);@@ -71,7 +71,7 @@ Route::get('/cloud-tokens/{uuid}', [CloudProviderTokensController::class, 'show'])->middleware(['api.ability:read']); Route::patch('/cloud-tokens/{uuid}', [CloudProviderTokensController::class, 'update'])->middleware(['api.ability:write']); Route::delete('/cloud-tokens/{uuid}', [CloudProviderTokensController::class, 'destroy'])->middleware(['api.ability:write']);- Route::post('/cloud-tokens/{uuid}/validate', [CloudProviderTokensController::class, 'validateToken'])->middleware(['api.ability:read']);+ Route::post('/cloud-tokens/{uuid}/validate', [CloudProviderTokensController::class, 'validateToken'])->middleware(['api.ability:write']); Route::match(['get', 'post'], '/deploy', [DeployController::class, 'deploy'])->middleware(['api.ability:deploy']); Route::get('/deployments', [DeployController::class, 'deployments'])->middleware(['api.ability:read']);@@ -84,7 +84,7 @@ Route::get('/servers/{uuid}/domains', [ServersController::class, 'domains_by_server'])->middleware(['api.ability:read']); Route::get('/servers/{uuid}/resources', [ServersController::class, 'resources_by_server'])->middleware(['api.ability:read']); - Route::get('/servers/{uuid}/validate', [ServersController::class, 'validate_server'])->middleware(['api.ability:read']);+ Route::get('/servers/{uuid}/validate', [ServersController::class, 'validate_server'])->middleware(['api.ability:write']); Route::post('/servers', [ServersController::class, 'create_server'])->middleware(['api.ability:write']); Route::patch('/servers/{uuid}', [ServersController::class, 'update_server'])->middleware(['api.ability:write']);AI-assisted change 04e06b091913920966f3ff837d276b5ace0b9481bdfd5310fb5c6b7a53e14cb9 · Fix bf1f64012207e7a2fa4b455db3612c7c3badbf3359ca1c4b3435c0cec5c3b4b0