Merge remote-tracking branch 'origin/next' into v5-parallel-inertia-react

This commit is contained in:
Andras Bacsai committed 2026-07-02 21:25:02 +02:00
commit c9416abec6
117 files changed
+5121 -795

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);
}
}
+1 -1
View File
@@ -20,7 +20,7 @@ class CleanupDocker
$realtimeImageWithoutPrefixVersion = "coollabsio/coolify-realtime:$realtimeImageVersion";
$helperImageVersion = getHelperVersion();
$helperImage = config('constants.coolify.helper_image');
$helperImage = coolifyHelperImage();
$helperImageWithVersion = "$helperImage:$helperImageVersion";
$helperImageWithoutPrefix = 'coollabsio/coolify-helper';
$helperImageWithoutPrefixVersion = "coollabsio/coolify-helper:$helperImageVersion";
+1 -1
View File
@@ -26,7 +26,7 @@ class StartSentinel
$endpoint = data_get($server, 'settings.sentinel_custom_url');
$debug = data_get($server, 'settings.is_sentinel_debug_enabled');
$mountDir = '/data/coolify/sentinel';
$image = config('constants.coolify.registry_url').'/coollabsio/sentinel:'.$version;
$image = coolifyRegistryUrl().'/coollabsio/sentinel:'.$version;
if (! $endpoint) {
throw new \RuntimeException('You should set FQDN in Instance Settings.');
}
+5 -1
View File
@@ -118,10 +118,14 @@ class UpdateCoolify
{
$latestHelperImageVersion = getHelperVersion();
$upgradeScriptUrl = config('constants.coolify.upgrade_script_url');
$registryUrl = coolifyRegistryUrl();
remote_process([
"curl -fsSL {$upgradeScriptUrl} -o /data/coolify/source/upgrade.sh",
"bash /data/coolify/source/upgrade.sh $this->latestVersion $latestHelperImageVersion",
'bash /data/coolify/source/upgrade.sh '.
escapeshellarg($this->latestVersion).' '.
escapeshellarg($latestHelperImageVersion).' '.
escapeshellarg($registryUrl),
], $this->server);
}
}
@@ -952,6 +952,10 @@ class ApplicationsController extends Controller
}
$serverUuid = $request->server_uuid;
$fqdn = $request->domains;
if ($request->has('domains') && is_string($request->domains)) {
$fqdn = ValidationPatterns::normalizeApplicationDomains($request->domains);
$request->offsetSet('domains', $fqdn);
}
$autogenerateDomain = $request->boolean('autogenerate_domain', true);
$instantDeploy = $request->instant_deploy;
$githubAppUuid = $request->github_app_uuid;
@@ -1031,7 +1035,7 @@ class ApplicationsController extends Controller
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required',
'docker_compose_domains.*.domain' => 'string|nullable',
'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
];
// ports_exposes is not required for dockercompose
if ($request->build_pack === 'dockercompose') {
@@ -1239,7 +1243,7 @@ class ApplicationsController extends Controller
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required',
'docker_compose_domains.*.domain' => 'string|nullable',
'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
];
$validationRules = array_merge(sharedDataApplications(), $validationRules);
$validationMessages = [
@@ -1479,7 +1483,7 @@ class ApplicationsController extends Controller
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required',
'docker_compose_domains.*.domain' => 'string|nullable',
'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
];
$validationRules = array_merge(sharedDataApplications(), $validationRules);
@@ -2378,7 +2382,7 @@ class ApplicationsController extends Controller
'docker_compose_domains' => 'array|nullable',
'docker_compose_domains.*' => 'array:name,domain',
'docker_compose_domains.*.name' => 'string|required',
'docker_compose_domains.*.domain' => 'string|nullable',
'docker_compose_domains.*.domain' => ValidationPatterns::applicationDomainRules(),
'custom_nginx_configuration' => 'string|nullable',
'is_http_basic_auth_enabled' => 'boolean|nullable',
'http_basic_auth_username' => 'string',
@@ -2482,29 +2486,7 @@ class ApplicationsController extends Controller
$requestHasDomains = $request->has('domains');
if ($requestHasDomains && $server->isProxyShouldRun()) {
$uuid = $request->uuid;
$urls = $request->domains;
$urls = str($urls)->replaceStart(',', '')->replaceEnd(',', '')->trim();
$errors = [];
$urls = str($urls)->trim()->explode(',')->map(function ($url) use (&$errors) {
$url = trim($url);
// If "domains" is empty clear all URLs from the fqdn column
if (blank($url)) {
return null;
}
if (! filter_var($url, FILTER_VALIDATE_URL)) {
$errors[] = 'Invalid URL: '.$url;
return $url;
}
$scheme = parse_url($url, PHP_URL_SCHEME) ?? '';
if (! in_array(strtolower($scheme), ['http', 'https'])) {
$errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported.";
}
return str($url)->lower();
});
$errors = ValidationPatterns::validateApplicationDomains($request->domains);
if (count($errors) > 0) {
return response()->json([
@@ -2512,6 +2494,9 @@ class ApplicationsController extends Controller
'errors' => $errors,
], 422);
}
$domains = ValidationPatterns::normalizeApplicationDomains($request->domains);
$request->offsetSet('domains', $domains);
$urls = collect(ValidationPatterns::applicationDomainList($domains));
// Check for domain conflicts
$result = checkIfDomainIsAlreadyUsedViaAPI($urls, $teamId, $uuid);
if (isset($result['error'])) {
@@ -3871,36 +3856,16 @@ class ApplicationsController extends Controller
}
if ($request->has('domains') && $server->isProxyShouldRun()) {
$uuid = $request->uuid;
$urls = $request->domains;
$urls = str($urls)->replaceEnd(',', '')->trim();
$urls = str($urls)->replaceStart(',', '')->trim();
$errors = [];
$urls = str($urls)->trim()->explode(',')->map(function ($url) use (&$errors) {
$url = trim($url);
// If "domains" is empty clear all URLs from the fqdn column
if (blank($url)) {
return null;
}
if (! filter_var($url, FILTER_VALIDATE_URL)) {
$errors[] = 'Invalid URL: '.$url;
return str($url)->lower();
}
$scheme = parse_url($url, PHP_URL_SCHEME) ?? '';
if (! in_array(strtolower($scheme), ['http', 'https'])) {
$errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported.";
}
return str($url)->lower();
});
$errors = ValidationPatterns::validateApplicationDomains($request->domains);
if (count($errors) > 0) {
return response()->json([
'message' => 'Validation failed.',
'errors' => $errors,
], 422);
}
$normalizedDomains = ValidationPatterns::normalizeApplicationDomains($request->domains);
$request->offsetSet('domains', $normalizedDomains);
$urls = collect(ValidationPatterns::applicationDomainList($normalizedDomains));
// Check for domain conflicts
$result = checkIfDomainIsAlreadyUsedViaAPI($urls, $teamId, $uuid);
if (isset($result['error'])) {
@@ -4279,10 +4244,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();
@@ -4306,7 +4272,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.',
@@ -4337,6 +4303,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) {
@@ -4359,12 +4333,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,
@@ -3696,10 +3696,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();
@@ -3723,7 +3724,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.',
@@ -3754,6 +3755,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) {
@@ -3776,12 +3785,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.']);
}
}
+61 -25
View File
@@ -60,19 +60,10 @@ class ServicesController extends Controller
return str($urlValue)->replaceStart(',', '')->replaceEnd(',', '')->trim()->explode(',')->map(fn ($url) => trim($url))->filter();
});
$urls = $urls->map(function ($url) use (&$errors) {
if (! filter_var($url, FILTER_VALIDATE_URL)) {
$errors[] = "Invalid URL: {$url}";
return $url;
}
$scheme = parse_url($url, PHP_URL_SCHEME) ?? '';
if (! in_array(strtolower($scheme), ['http', 'https'])) {
$errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported.";
}
return $url;
});
$errors = ValidationPatterns::validateApplicationDomains($urls->implode(','));
$urls = collect(ValidationPatterns::applicationDomainList(
ValidationPatterns::normalizeApplicationDomains($urls->implode(','))
));
$duplicates = $urls->duplicates()->unique()->values();
if ($duplicates->isNotEmpty() && ! $forceDomainOverride) {
@@ -101,10 +92,10 @@ class ServicesController extends Controller
}
if (filled($containerUrls)) {
$containerUrls = str($containerUrls)->replaceStart(',', '')->replaceEnd(',', '')->trim();
$containerUrls = str($containerUrls)->explode(',')->map(fn ($url) => str(trim($url))->lower());
$containerUrls = ValidationPatterns::normalizeApplicationDomains($containerUrls);
$containerUrlCollection = collect(ValidationPatterns::applicationDomainList($containerUrls));
$result = checkIfDomainIsAlreadyUsedViaAPI($containerUrls, $teamId, $application->uuid);
$result = checkIfDomainIsAlreadyUsedViaAPI($containerUrlCollection, $teamId, $application->uuid);
if (isset($result['error'])) {
$errors[] = $result['error'];
@@ -116,8 +107,6 @@ class ServicesController extends Controller
return;
}
$containerUrls = $containerUrls->filter(fn ($u) => filled($u))->unique()->implode(',');
} else {
$containerUrls = null;
}
@@ -2115,10 +2104,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();
@@ -2150,7 +2140,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.',
@@ -2181,6 +2171,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) {
@@ -2203,12 +2201,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,
+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;
@@ -128,5 +129,6 @@ class Kernel extends HttpKernel
'can.update.resource' => CanUpdateResource::class,
'can.access.terminal' => CanAccessTerminal::class,
'mcp.enabled' => EnsureMcpEnabled::class,
'mcp.team.enabled' => EnsureTeamMcpEnabled::class,
];
}
+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);
}
}
+95 -13
View File
@@ -52,6 +52,21 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private const RAILPACK_GENERATED_CONFIG_PATH = '.coolify/railpack.generated.json';
private const DOCKER_CLIENT_ENV_KEYS = [
'BUILDKIT_HOST',
'BUILDX_BUILDER',
'BUILDX_CONFIG',
'DOCKER_API_VERSION',
'DOCKER_BUILDKIT',
'DOCKER_CERT_PATH',
'DOCKER_CLI_EXPERIMENTAL',
'DOCKER_CONFIG',
'DOCKER_CONTEXT',
'DOCKER_HOST',
'DOCKER_TLS',
'DOCKER_TLS_VERIFY',
];
public $tries = 1;
public $timeout = 3600;
@@ -1031,7 +1046,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();
}
}
@@ -1701,6 +1716,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
foreach ($sorted_environment_variables as $env) {
if ($this->build_pack === 'railpack' && $this->is_reserved_docker_client_env_key($env->key)) {
continue;
}
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
// For literal/multiline vars, real_value includes quotes that we need to remove
if ($env->is_literal || $env->is_multiline) {
@@ -1752,6 +1771,10 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
foreach ($sorted_environment_variables as $env) {
if ($this->build_pack === 'railpack' && $this->is_reserved_docker_client_env_key($env->key)) {
continue;
}
$resolvedValue = $env->getResolvedValueWithServer($this->mainServer);
// For literal/multiline vars, real_value includes quotes that we need to remove
if ($env->is_literal || $env->is_multiline) {
@@ -2124,7 +2147,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private function prepare_builder_image(bool $firstTry = true)
{
$this->checkForCancellation();
$helperImage = config('constants.coolify.helper_image');
$helperImage = coolifyHelperImage();
$helperImage = "{$helperImage}:".getHelperVersion();
// Get user home directory
$this->serverUserHomeDir = instant_remote_process(['echo $HOME'], $this->server);
@@ -2229,7 +2252,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
// Only include SOURCE_COMMIT in build context if enabled in settings
if ($this->application->settings->include_source_commit_in_build) {
$this->coolify_variables .= "SOURCE_COMMIT={$this->commit} ";
$this->coolify_variables .= 'SOURCE_COMMIT='.escapeShellValue($this->commit).' ';
}
if ($this->pull_request_id === 0) {
$fqdn = $this->application->fqdn;
@@ -2241,17 +2264,33 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$fqdn = $url->getHost();
$url = $url->withHost($fqdn)->withPort(null)->__toString();
if ((int) $this->application->compose_parsing_version >= 3) {
$this->coolify_variables .= "COOLIFY_URL={$url} ";
$this->coolify_variables .= "COOLIFY_FQDN={$fqdn} ";
$this->coolify_variables .= 'COOLIFY_URL='.escapeShellValue($url).' ';
$this->coolify_variables .= 'COOLIFY_FQDN='.escapeShellValue($fqdn).' ';
} else {
$this->coolify_variables .= "COOLIFY_URL={$fqdn} ";
$this->coolify_variables .= "COOLIFY_FQDN={$url} ";
$this->coolify_variables .= 'COOLIFY_URL='.escapeShellValue($fqdn).' ';
$this->coolify_variables .= 'COOLIFY_FQDN='.escapeShellValue($url).' ';
}
}
if (isset($this->application->git_branch)) {
$this->coolify_variables .= 'COOLIFY_BRANCH='.escapeShellValue($this->application->git_branch).' ';
}
$this->coolify_variables .= "COOLIFY_RESOURCE_UUID={$this->application->uuid} ";
$this->coolify_variables .= 'COOLIFY_RESOURCE_UUID='.escapeShellValue($this->application->uuid).' ';
}
private function shellAssignmentForDockerfileArg(string $assignment): string
{
[$key, $value] = array_pad(explode('=', $assignment, 2), 2, null);
if ($value === null) {
return $assignment;
}
if (str_starts_with($value, "'") && str_ends_with($value, "'")) {
$value = substr($value, 1, -1);
$value = str_replace("'\\''", "'", $value);
}
return "{$key}={$value}";
}
private function gitLsRemoteCommand(string $lsRemoteRef, ?string $identityFile = null): string
@@ -2327,7 +2366,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
],
);
}
if ($this->saved_outputs->get('git_commit_sha') && ! $this->rollback) {
if ($this->saved_outputs->get('git_commit_sha') && ! $this->rollback && $this->shouldResolveBranchHeadCommit()) {
// Extract commit SHA from git ls-remote output, handling multi-line output (e.g., redirect warnings)
// Expected format: "commit_sha\trefs/heads/branch" possibly preceded by warning lines
// Note: Git warnings can be on the same line as the result (no newline)
@@ -2359,6 +2398,13 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
}
private function shouldResolveBranchHeadCommit(): bool
{
$commit = trim($this->commit);
return $commit === '' || $commit === 'HEAD';
}
private function clone_repository()
{
$importCommands = $this->generate_git_import_commands();
@@ -2566,6 +2612,20 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$this->env_nixpacks_args = $this->env_nixpacks_args->implode(' ');
}
private function is_reserved_docker_client_env_key(?string $key): bool
{
if (blank($key)) {
return false;
}
return in_array(strtoupper($key), self::DOCKER_CLIENT_ENV_KEYS, true);
}
private function without_reserved_docker_client_variables(Collection $variables): Collection
{
return $variables->reject(fn ($value, $key) => $this->is_reserved_docker_client_env_key((string) $key));
}
private function generate_railpack_env_variables(): Collection
{
$variables = $this->railpack_build_variables();
@@ -2686,6 +2746,8 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private function railpack_build_command(string $imageName, Collection $variables): string
{
$variables = $this->without_reserved_docker_client_variables($variables);
$cacheArgs = '';
if ($this->force_rebuild) {
$cacheArgs = '--no-cache';
@@ -2712,7 +2774,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$secretFlags = $this->railpack_build_secret_flags($variables);
$frontendImage = 'ghcr.io/railwayapp/railpack-frontend:v'.config('constants.coolify.railpack_version');
$buildxBuildCommand = "{$environmentPrefix}docker buildx build --builder coolify-railpack"
$buildxBuildCommand = "{$environmentPrefix}DOCKER_CONFIG=/root/.docker docker buildx build --builder coolify-railpack"
." {$this->addHosts} --network host"
." --build-arg BUILDKIT_SYNTAX=\"{$frontendImage}\""
." {$cacheArgs}"
@@ -2723,7 +2785,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
." -t {$imageName}"
." {$this->workdir}";
return 'docker buildx create --name coolify-railpack --driver docker-container 2>/dev/null || true'
return 'DOCKER_CONFIG=/root/.docker docker buildx create --name coolify-railpack --driver docker-container 2>/dev/null || true'
.' && '.$this->wrap_build_command_with_env_export($buildxBuildCommand);
}
@@ -2878,9 +2940,25 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
throw new DeploymentException('Railpack deployments require the Docker buildx CLI plugin on the build server. Install or enable docker buildx and retry the deployment.');
}
private function ensure_helper_docker_buildx_available_for_railpack(): void
{
$this->execute_remote_command([
executeInDocker($this->deployment_uuid, 'DOCKER_CONFIG=/root/.docker docker buildx version >/dev/null 2>&1 && echo available || echo not-available'),
'hidden' => true,
'save' => 'railpack_helper_buildx_available',
]);
if (trim((string) $this->saved_outputs->get('railpack_helper_buildx_available')) === 'available') {
return;
}
throw new DeploymentException('Railpack deployments require the Docker buildx CLI plugin inside the Coolify helper container. The helper could not find buildx at /root/.docker/cli-plugins/docker-buildx. Pull the latest helper image and retry the deployment.');
}
private function build_railpack_image(): void
{
$this->ensure_docker_buildx_available_for_railpack();
$this->ensure_helper_docker_buildx_available_for_railpack();
$railpackVariables = $this->generate_railpack_env_variables();
$railpackConfigPath = $this->generate_railpack_config_file();
@@ -4080,6 +4158,10 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
$variables = $this->env_args;
if ($this->build_pack === 'railpack') {
$variables = $this->without_reserved_docker_client_variables($variables);
}
if ($variables->isEmpty()) {
return '';
}
@@ -4220,7 +4302,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
$coolify_vars = collect(explode(' ', trim($this->coolify_variables)))
->filter()
->map(function ($var) {
return "ARG {$var}";
return 'ARG '.$this->shellAssignmentForDockerfileArg($var);
});
$argsToInsert = $argsToInsert->merge($coolify_vars);
}
@@ -4242,7 +4324,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf");
$coolify_vars = collect(explode(' ', trim($this->coolify_variables)))
->filter()
->map(function ($var) {
return "ARG {$var}";
return 'ARG '.$this->shellAssignmentForDockerfileArg($var);
});
$argsToInsert = $argsToInsert->merge($coolify_vars);
}
+1 -1
View File
@@ -36,7 +36,7 @@ class CleanupHelperContainersJob implements ShouldBeEncrypted, ShouldBeUnique, S
'active_deployment_uuids' => $activeDeployments,
]);
$containers = instant_remote_process_with_timeout(['docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image | contains("'.config('constants.coolify.registry_url').'/coollabsio/coolify-helper")))\''], $this->server, false);
$containers = instant_remote_process_with_timeout(['docker container ps --format \'{{json .}}\' | jq -s \'map(select(.Image | contains("'.coolifyRegistryUrl().'/coollabsio/coolify-helper")))\''], $this->server, false);
$helperContainers = collect(json_decode($containers));
if ($helperContainers->count() > 0) {
+10 -3
View File
@@ -16,6 +16,7 @@ use App\Models\Team;
use App\Notifications\Database\BackupFailed;
use App\Notifications\Database\BackupSuccess;
use App\Notifications\Database\BackupSuccessWithS3Warning;
use App\Rules\SafeWebhookUrl;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeEncrypted;
@@ -714,9 +715,15 @@ 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}/");
$resolveOptions = collect(SafeWebhookUrl::minioClientResolveOptions($endpoint))
->map(fn (string $resolveOption): string => '--resolve '.escapeshellarg($resolveOption))
->implode(' ');
$resolveOptions = $resolveOptions === '' ? '' : ' '.$resolveOptions;
$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 alias set{$resolveOptions} temporary {$escapedEndpoint} {$escapedKey} {$escapedSecret}";
$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;
@@ -732,7 +739,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
private function getFullImageName(): string
{
$helperImage = config('constants.coolify.helper_image');
$helperImage = coolifyHelperImage();
$latestVersion = getHelperVersion();
return "{$helperImage}:{$latestVersion}";
+29 -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,31 @@ 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' => SafeWebhookUrl::redactedUrlForLog($this->webhookUrl),
'errors' => $validator->errors()->all(),
]);
return;
}
try {
$httpOptions = SafeWebhookUrl::httpClientOptions($this->webhookUrl);
} catch (\RuntimeException $e) {
Log::warning('SendMessageToDiscordJob: blocked unsafe webhook URL at send time', [
'url' => SafeWebhookUrl::redactedUrlForLog($this->webhookUrl),
'error' => $e->getMessage(),
]);
return;
}
Http::withOptions($httpOptions)->post($this->webhookUrl, $this->message->toPayload());
}
}
+40 -6
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,8 +37,33 @@ 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' => SafeWebhookUrl::redactedUrlForLog($this->webhookUrl),
'errors' => $validator->errors()->all(),
]);
return;
}
try {
$httpOptions = SafeWebhookUrl::httpClientOptions($this->webhookUrl);
} catch (\RuntimeException $e) {
Log::warning('SendMessageToSlackJob: blocked unsafe webhook URL at send time', [
'url' => SafeWebhookUrl::redactedUrlForLog($this->webhookUrl),
'error' => $e->getMessage(),
]);
return;
}
if ($this->isSlackWebhook()) {
$this->sendToSlack();
$this->sendToSlack($httpOptions);
return;
}
@@ -45,7 +73,7 @@ class SendMessageToSlackJob implements ShouldBeEncrypted, ShouldQueue
*
* @see https://github.com/coollabsio/coolify/pull/6139#issuecomment-3756777708
*/
$this->sendToMattermost();
$this->sendToMattermost($httpOptions);
}
private function isSlackWebhook(): bool
@@ -62,9 +90,12 @@ class SendMessageToSlackJob implements ShouldBeEncrypted, ShouldQueue
return $scheme === 'https' && $host === 'hooks.slack.com';
}
private function sendToSlack(): void
/**
* @param array<string, mixed> $httpOptions
*/
private function sendToSlack(array $httpOptions): void
{
Http::post($this->webhookUrl, [
Http::withOptions($httpOptions)->post($this->webhookUrl, [
'text' => $this->message->title,
'blocks' => [
[
@@ -102,11 +133,14 @@ class SendMessageToSlackJob implements ShouldBeEncrypted, ShouldQueue
/**
* @todo v5 refactor: Extract this into a separate SendMessageToMattermostJob.php triggered via the "mattermost" notification channel type.
*/
private function sendToMattermost(): void
/**
* @param array<string, mixed> $httpOptions
*/
private function sendToMattermost(array $httpOptions): void
{
$username = config('app.name');
Http::post($this->webhookUrl, [
Http::withOptions($httpOptions)->post($this->webhookUrl, [
'username' => $username,
'attachments' => [
[
+13 -2
View File
@@ -50,7 +50,7 @@ class SendWebhookJob implements ShouldBeEncrypted, ShouldQueue
if ($validator->fails()) {
Log::warning('SendWebhookJob: blocked unsafe webhook URL', [
'url' => $this->webhookUrl,
'url' => SafeWebhookUrl::redactedUrlForLog($this->webhookUrl),
'errors' => $validator->errors()->all(),
]);
@@ -64,7 +64,18 @@ class SendWebhookJob implements ShouldBeEncrypted, ShouldQueue
]);
}
$response = Http::post($this->webhookUrl, $this->payload);
try {
$httpOptions = SafeWebhookUrl::httpClientOptions($this->webhookUrl);
} catch (\RuntimeException $e) {
Log::warning('SendWebhookJob: blocked unsafe webhook URL at send time', [
'url' => SafeWebhookUrl::redactedUrlForLog($this->webhookUrl),
'error' => $e->getMessage(),
]);
return;
}
$response = Http::withOptions($httpOptions)->post($this->webhookUrl, $this->payload);
if (isDev()) {
ray('Webhook response', [
+6 -12
View File
@@ -12,7 +12,6 @@ use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Component;
use Livewire\Features\SupportEvents\Event;
use Spatie\Url\Url;
class General extends Component
{
@@ -142,7 +141,8 @@ class General extends Component
return [
'name' => ValidationPatterns::nameRules(),
'description' => ValidationPatterns::descriptionRules(),
'fqdn' => 'nullable',
'fqdn' => ValidationPatterns::applicationDomainRules(),
'parsedServiceDomains.*.domain' => ValidationPatterns::applicationDomainRules(),
'gitRepository' => 'required',
'gitBranch' => ['required', 'string', new ValidGitBranch],
'gitCommitSha' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'],
@@ -771,16 +771,7 @@ class General extends Component
$oldBaseDirectory = $this->application->base_directory;
// Process FQDN with intermediate variable to avoid Collection/string confusion
$this->fqdn = str($this->fqdn)->replaceEnd(',', '')->trim()->toString();
$this->fqdn = str($this->fqdn)->replaceStart(',', '')->trim()->toString();
$domains = str($this->fqdn)->trim()->explode(',')->map(function ($domain) {
$domain = trim($domain);
Url::fromString($domain, ['http', 'https']);
return str($domain)->lower();
});
$this->fqdn = $domains->unique()->implode(',');
$this->fqdn = ValidationPatterns::normalizeApplicationDomains($this->fqdn);
$warning = sslipDomainWarning($this->fqdn);
if ($warning) {
$this->dispatch('warning', __('warning.sslipdomain'));
@@ -863,6 +854,9 @@ class General extends Component
}
}
if ($this->buildPack === 'dockercompose') {
foreach ($this->parsedServiceDomains as $serviceName => $service) {
$this->parsedServiceDomains[$serviceName]['domain'] = ValidationPatterns::normalizeApplicationDomains(data_get($service, 'domain'));
}
$this->application->docker_compose_domains = json_encode($this->parsedServiceDomains);
if ($this->application->isDirty('docker_compose_domains')) {
foreach ($this->parsedServiceDomains as $service) {
@@ -6,6 +6,7 @@ use App\Actions\Docker\GetContainersStatus;
use App\Jobs\DeleteResourceJob;
use App\Models\Application;
use App\Models\ApplicationPreview;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Livewire\Component;
@@ -117,12 +118,14 @@ class Previews extends Component
});
if ($previewKey !== false && isset($this->previewFqdns[$previewKey])) {
$this->validate([
"previewFqdns.{$previewKey}" => ValidationPatterns::applicationDomainRules(),
]);
$fqdn = $this->previewFqdns[$previewKey];
if (! empty($fqdn)) {
$fqdn = str($fqdn)->replaceEnd(',', '')->trim();
$fqdn = str($fqdn)->replaceStart(',', '')->trim();
$fqdn = str($fqdn)->trim()->lower();
$fqdn = ValidationPatterns::normalizeApplicationDomains($fqdn);
$this->previewFqdns[$previewKey] = $fqdn;
if (! validateDNSEntry($fqdn, $this->application->destination->server)) {
@@ -3,6 +3,7 @@
namespace App\Livewire\Project\Application;
use App\Models\ApplicationPreview;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Component;
use Spatie\Url\Url;
@@ -33,6 +34,11 @@ class PreviewsCompose extends Component
{
try {
$this->authorize('update', $this->preview->application);
$this->validate([
'domain' => ValidationPatterns::applicationDomainRules(),
]);
$this->domain = ValidationPatterns::normalizeApplicationDomains($this->domain);
$docker_compose_domains = data_get($this->preview, 'docker_compose_domains');
$docker_compose_domains = json_decode($docker_compose_domains, true) ?: [];
@@ -73,9 +79,13 @@ class PreviewsCompose extends Component
$preview_fqdn = str_replace('{{pr_id}}', $this->preview->pull_request_id, $preview_fqdn);
$preview_fqdn = str($generated_fqdn)->before('://').'://'.$preview_fqdn;
} else {
foreach (ValidationPatterns::validateApplicationDomains($domain_string) as $error) {
throw new \InvalidArgumentException($error);
}
// Use the existing domain from the main application
// Handle multiple domains separated by commas
$domain_list = explode(',', $domain_string);
$domain_list = ValidationPatterns::applicationDomainList($domain_string);
$preview_fqdns = [];
$template = $this->preview->application->preview_url_template;
$random = new_public_id();
+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 {
+6 -5
View File
@@ -14,6 +14,7 @@ use App\Models\StandaloneMongodb;
use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Rules\SafeWebhookUrl;
use App\Support\DatabaseBackupFileValidator;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
@@ -28,11 +29,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);
}
/**
@@ -582,7 +582,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;
}
@@ -599,6 +599,7 @@ EOD;
'bucket' => $s3Storage->bucket,
'endpoint' => $s3Storage->endpoint,
'use_path_style_endpoint' => true,
'http' => SafeWebhookUrl::httpClientOptions($s3Storage->endpoint),
]);
// Check if file exists
@@ -663,7 +664,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;
}
@@ -679,7 +680,7 @@ EOD;
}
// Get helper image
$helperImage = config('constants.coolify.helper_image');
$helperImage = coolifyHelperImage();
$latestVersion = getHelperVersion();
$fullImageName = "{$helperImage}:{$latestVersion}";
+9 -3
View File
@@ -112,14 +112,17 @@ class Select extends Component
$default_logo = 'images/default.webp';
$logo = data_get($service, 'logo', $default_logo);
$local_logo_path = public_path($logo);
$serviceKey = (string) $key;
return [
'name' => str($key)->headline(),
'id' => $serviceKey,
'name' => str($serviceKey)->headline(),
'docsSlug' => str($serviceKey)->lower()->value(),
'logo' => asset($logo),
'logo_github_url' => file_exists($local_logo_path)
? 'https://raw.githubusercontent.com/coollabsio/coolify/refs/heads/main/public/'.$logo
: asset($default_logo),
'templateLastUpdated' => $templateLastUpdatedMap[(string) $key] ?? null,
'templateLastUpdated' => $templateLastUpdatedMap[$serviceKey] ?? null,
] + (array) $service;
})->all();
@@ -336,7 +339,10 @@ class Select extends Component
public function setType(string $type)
{
$type = str($type)->lower()->slug()->value();
if (! str($type)->startsWith('one-click-service-')) {
$type = str($type)->lower()->slug()->value();
}
if ($this->loading) {
return;
}
+10 -13
View File
@@ -3,10 +3,10 @@
namespace App\Livewire\Project\Service;
use App\Models\ServiceApplication;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Livewire\Attributes\Validate;
use Livewire\Component;
use Spatie\Url\Url;
class EditDomain extends Component
{
@@ -28,12 +28,15 @@ class EditDomain extends Component
public $requiredPort = null;
#[Validate(['nullable'])]
#[Validate]
public ?string $fqdn = null;
protected $rules = [
'fqdn' => 'nullable',
];
protected function rules(): array
{
return [
'fqdn' => ValidationPatterns::applicationDomainRules(),
];
}
public function mount()
{
@@ -82,15 +85,9 @@ class EditDomain extends Component
{
try {
$this->authorize('update', $this->application);
$this->fqdn = str($this->fqdn)->replaceEnd(',', '')->trim()->toString();
$this->fqdn = str($this->fqdn)->replaceStart(',', '')->trim()->toString();
$domains = str($this->fqdn)->trim()->explode(',')->map(function ($domain) {
$domain = trim($domain);
Url::fromString($domain, ['http', 'https']);
$this->validate();
return str($domain)->lower();
});
$this->fqdn = $domains->unique()->implode(',');
$this->fqdn = ValidationPatterns::normalizeApplicationDomains($this->fqdn);
$warning = sslipDomainWarning($this->fqdn);
if ($warning) {
$this->dispatch('warning', __('warning.sslipdomain'));
+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.'],
],
]);
}
}
+5 -9
View File
@@ -8,11 +8,11 @@ use App\Models\Server;
use App\Models\Service;
use App\Models\ServiceApplication;
use App\Models\ServiceDatabase;
use App\Support\ValidationPatterns;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
use Spatie\Url\Url;
class Index extends Component
{
@@ -480,15 +480,11 @@ class Index extends Component
{
try {
$this->authorize('update', $this->serviceApplication);
$this->fqdn = str($this->fqdn)->replaceEnd(',', '')->trim()->toString();
$this->fqdn = str($this->fqdn)->replaceStart(',', '')->trim()->toString();
$domains = str($this->fqdn)->trim()->explode(',')->map(function ($domain) {
$domain = trim($domain);
Url::fromString($domain, ['http', 'https']);
$this->validate([
'fqdn' => ValidationPatterns::applicationDomainRules(),
]);
return str($domain)->lower();
});
$this->fqdn = $domains->unique()->implode(',');
$this->fqdn = ValidationPatterns::normalizeApplicationDomains($this->fqdn);
$warning = sslipDomainWarning($this->fqdn);
if ($warning) {
$this->dispatch('warning', __('warning.sslipdomain'));
+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');
@@ -93,6 +93,10 @@ class All extends Component
private function getEnvironmentVariables(bool $isPreview, bool $withSearch = true): Collection
{
if ($isPreview && ! $this->supportsPreviewEnvironmentVariables()) {
return collect();
}
$query = $isPreview
? $this->resource->environment_variables_preview()
: $this->resource->environment_variables();
@@ -119,12 +123,21 @@ class All extends Component
return trim($this->search);
}
private function supportsPreviewEnvironmentVariables(): bool
{
return $this->showPreview && $this->resource instanceof Application;
}
public function getHasEnvironmentVariablesProperty(): bool
{
return $this->environmentVariables->isNotEmpty() ||
$hasPreviewEnvironmentVariables = $this->supportsPreviewEnvironmentVariables() && (
$this->environmentVariablesPreview->isNotEmpty() ||
$this->hardcodedEnvironmentVariablesPreview->isNotEmpty()
);
return $this->environmentVariables->isNotEmpty() ||
$this->hardcodedEnvironmentVariables->isNotEmpty() ||
$this->hardcodedEnvironmentVariablesPreview->isNotEmpty();
$hasPreviewEnvironmentVariables;
}
private function nullLockedValues($envs)
@@ -158,6 +171,10 @@ class All extends Component
protected function getHardcodedVariables(bool $isPreview)
{
if ($isPreview && ! $this->supportsPreviewEnvironmentVariables()) {
return collect([]);
}
// Only for services and docker-compose applications
if ($this->resource->type() !== 'service' &&
($this->resourceClass !== 'App\Models\Application' ||
+64 -2
View File
@@ -43,6 +43,11 @@ class Advanced extends Component
#[Validate('boolean')]
public bool $is_mcp_server_enabled;
public ?string $webhook_allowed_internal_hosts = null;
#[Validate('boolean')]
public bool $webhook_allow_localhost;
public function rules()
{
return [
@@ -56,6 +61,8 @@ class Advanced extends Component
'disable_two_step_confirmation' => 'boolean',
'is_wire_navigate_enabled' => 'boolean',
'is_mcp_server_enabled' => 'boolean',
'webhook_allowed_internal_hosts' => 'nullable|string',
'webhook_allow_localhost' => 'boolean',
];
}
@@ -75,6 +82,8 @@ class Advanced extends Component
$this->is_sponsorship_popup_enabled = $this->settings->is_sponsorship_popup_enabled;
$this->is_wire_navigate_enabled = $this->settings->is_wire_navigate_enabled ?? true;
$this->is_mcp_server_enabled = $this->settings->is_mcp_server_enabled ?? false;
$this->webhook_allowed_internal_hosts = collect($this->settings->webhook_allowed_internal_hosts ?? [])->implode(',');
$this->webhook_allow_localhost = $this->settings->webhook_allow_localhost ?? false;
}
public function submit()
@@ -141,13 +150,21 @@ class Advanced extends Component
$this->allowed_ips = implode(',', $validEntries);
}
$this->instantSave();
$webhookAllowedInternalHosts = $this->normalizeWebhookAllowedInternalHosts();
if ($webhookAllowedInternalHosts === false) {
return;
}
$this->instantSave($webhookAllowedInternalHosts);
} catch (\Exception $e) {
return handleError($e, $this);
}
}
public function instantSave()
/**
* @param array<int, string>|null $webhookAllowedInternalHosts
*/
public function instantSave(?array $webhookAllowedInternalHosts = null)
{
try {
$this->authorize('update', $this->settings);
@@ -161,6 +178,8 @@ class Advanced extends Component
$this->settings->disable_two_step_confirmation = $this->disable_two_step_confirmation;
$this->settings->is_wire_navigate_enabled = $this->is_wire_navigate_enabled;
$this->settings->is_mcp_server_enabled = $this->is_mcp_server_enabled;
$this->settings->webhook_allowed_internal_hosts = $webhookAllowedInternalHosts ?? $this->settings->webhook_allowed_internal_hosts ?? [];
$this->settings->webhook_allow_localhost = $this->webhook_allow_localhost;
$this->settings->save();
$this->dispatch('success', 'Settings updated!');
} catch (\Exception $e) {
@@ -168,6 +187,49 @@ class Advanced extends Component
}
}
/**
* @return array<int, string>|false
*/
private function normalizeWebhookAllowedInternalHosts(): array|false
{
$entries = collect(preg_split('/[,\r\n]+/', $this->webhook_allowed_internal_hosts ?? '') ?: [])
->map(fn (string $entry): string => rtrim(strtolower(trim($entry)), '.'))
->filter()
->unique()
->values();
$invalidEntries = $entries->reject(fn (string $entry): bool => $this->isValidWebhookAllowlistEntry($entry));
if ($invalidEntries->isNotEmpty()) {
$this->dispatch('error', 'Invalid webhook internal allowlist entries: '.$invalidEntries->implode(', '));
return false;
}
$this->webhook_allowed_internal_hosts = $entries->implode(',');
return $entries->all();
}
private function isValidWebhookAllowlistEntry(string $entry): bool
{
if (filter_var($entry, FILTER_VALIDATE_IP)) {
return true;
}
if (str_contains($entry, '/')) {
[$ip, $mask] = array_pad(explode('/', $entry, 2), 2, null);
$isIpv6 = filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) !== false;
$maxMask = $isIpv6 ? 128 : 32;
return filter_var($ip, FILTER_VALIDATE_IP) !== false
&& is_numeric($mask)
&& (int) $mask >= 0
&& (int) $mask <= $maxMask;
}
return filter_var($entry, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) !== false;
}
public function toggleRegistration($password): bool
{
if (! verifyPasswordConfirmation($password, $this)) {
-2
View File
@@ -47,8 +47,6 @@ class Index extends Component
public bool $forceSaveDomains = false;
public $buildActivityId = null;
protected array $messages = [
'fqdn.url' => 'Invalid instance URL.',
'fqdn.max' => 'URL must not exceed 255 characters.',
+43
View File
@@ -6,6 +6,8 @@ use App\Jobs\CheckForUpdatesJob;
use App\Models\InstanceSettings;
use App\Models\Server;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\Log;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Validate;
use Livewire\Component;
@@ -26,6 +28,9 @@ class Updates extends Component
#[Validate('boolean')]
public bool $is_auto_update_enabled;
#[Validate('required|string|in:docker.io,ghcr.io')]
public string $docker_registry_url;
public function mount()
{
if (! isInstanceAdmin()) {
@@ -39,6 +44,7 @@ class Updates extends Component
$this->auto_update_frequency = $this->settings->auto_update_frequency;
$this->update_check_frequency = $this->settings->update_check_frequency;
$this->is_auto_update_enabled = $this->settings->is_auto_update_enabled;
$this->docker_registry_url = $this->settings->docker_registry_url ?: 'docker.io';
}
public function instantSave()
@@ -50,16 +56,51 @@ class Updates extends Component
'auto_update_frequency' => ['required', 'string'],
]);
}
$validated = $this->validate([
'docker_registry_url' => ['required', 'string', 'in:docker.io,ghcr.io'],
]);
$this->settings->auto_update_frequency = $this->auto_update_frequency;
$this->settings->update_check_frequency = $this->update_check_frequency;
$this->settings->is_auto_update_enabled = $this->is_auto_update_enabled;
$this->settings->docker_registry_url = $validated['docker_registry_url'];
$this->syncRegistryUrlToEnv($validated['docker_registry_url']);
$this->settings->save();
$this->dispatch('success', 'Settings updated!');
} catch (ValidationException $e) {
throw $e;
} catch (\Exception $e) {
return handleError($e, $this);
}
}
protected function syncRegistryUrlToEnv(string $registryUrl): void
{
if (! $this->server) {
return;
}
try {
instant_remote_process([
$this->registryEnvSyncCommand($registryUrl),
], $this->server);
} catch (\Exception $e) {
Log::warning('Failed to sync REGISTRY_URL to .env', [
'error' => $e->getMessage(),
]);
throw new \RuntimeException('Failed to sync REGISTRY_URL to .env. Settings were not saved.', previous: $e);
}
}
private function registryEnvSyncCommand(string $registryUrl): string
{
$envFile = '/data/coolify/source/.env';
$sedExpression = escapeshellarg("s|^REGISTRY_URL=.*|REGISTRY_URL={$registryUrl}|");
$registryLine = escapeshellarg("REGISTRY_URL={$registryUrl}");
return "if grep -q '^REGISTRY_URL=' {$envFile}; then sed -i {$sedExpression} {$envFile}; else printf '%s\\n' {$registryLine} >> {$envFile}; fi";
}
public function submit()
{
try {
@@ -89,6 +130,8 @@ class Updates extends Component
if ($this->server) {
$this->server->setupDynamicProxyConfiguration();
}
} catch (ValidationException $e) {
throw $e;
} catch (\Exception $e) {
return handleError($e, $this);
}
+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;
}
}
+8
View File
@@ -46,6 +46,8 @@ class InstanceSettings extends Model
'dev_helper_version',
'is_wire_navigate_enabled',
'is_mcp_server_enabled',
'webhook_allowed_internal_hosts',
'webhook_allow_localhost',
];
protected $casts = [
@@ -69,10 +71,16 @@ class InstanceSettings extends Model
'sentinel_token' => 'encrypted',
'is_wire_navigate_enabled' => 'boolean',
'is_mcp_server_enabled' => 'boolean',
'webhook_allowed_internal_hosts' => 'array',
'webhook_allow_localhost' => 'boolean',
];
protected static function booted(): void
{
static::created(function () {
Once::flush();
});
static::updated(function ($settings) {
// Clear once() cache so subsequent calls get fresh data
Once::flush();
+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',
];
@@ -33,6 +34,7 @@ class LocalFileVolume extends BaseModel
'resource_type',
'resource_id',
'is_directory',
'is_host_file',
'chown',
'chmod',
'is_based_on_git',
@@ -44,6 +46,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));
});
@@ -70,6 +76,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) {
@@ -124,6 +134,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) {
@@ -161,6 +175,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) {
@@ -171,26 +189,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;
+16 -5
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;
@@ -147,12 +148,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',
@@ -162,10 +173,10 @@ class S3Storage extends BaseModel
'bucket' => $this['bucket'],
'endpoint' => $this['endpoint'],
'use_path_style_endpoint' => true,
'http' => [
'http' => array_merge(SafeWebhookUrl::httpClientOptions($this['endpoint']), [
'connect_timeout' => self::CONNECTION_TIMEOUT_SECONDS,
'timeout' => self::REQUEST_TIMEOUT_SECONDS,
],
]),
]);
// Test the connection by listing files with ListObjectsV2 (S3)
$disk->files();
+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()
+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(),
]);
}
}
+540 -37
View File
@@ -2,19 +2,28 @@
namespace App\Rules;
use App\Models\InstanceSettings;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
use Illuminate\Support\Facades\Log;
use PurplePixie\PhpDns\DNSQuery;
use PurplePixie\PhpDns\DNSTypes;
use Throwable;
class SafeWebhookUrl implements ValidationRule
{
/**
* @param (Closure(string): array<int, string>)|null $resolver
*/
public function __construct(private ?Closure $resolver = null) {}
/**
* Run the validation rule.
*
* Validates that a webhook URL is safe for server-side requests.
* Blocks loopback addresses, cloud metadata endpoints (link-local),
* and dangerous hostnames while allowing private network IPs
* for self-hosted deployments.
* private/reserved ranges, and dangerous hostnames unless the
* instance operator explicitly allowlists the intranet target.
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
@@ -38,64 +47,558 @@ class SafeWebhookUrl implements ValidationRule
return;
}
if (str_ends_with($host, '.')) {
$fail('The :attribute host must not end with a trailing dot.');
return;
}
$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 ($this->isBlockedHostname($hostForDns) && ! $this->isAllowedHostname($hostForDns)) {
$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->isAllowedIp($hostForIpCheck, $hostForDns)) {
$this->logBlockedIp($attribute, $host, $hostForIpCheck);
$fail('The :attribute must not point to private, reserved, loopback, or link-local addresses.');
return;
}
return;
}
$resolvedIps = $this->resolveHost($hostForDns);
if ($resolvedIps === []) {
$fail('The :attribute host could not be resolved.');
return;
}
foreach ($resolvedIps as $resolvedIp) {
if (! $this->isAllowedIp($resolvedIp, $hostForDns)) {
$this->logBlockedIp($attribute, $host, $resolvedIp);
$fail('The :attribute must not point to private, reserved, loopback, or link-local addresses.');
return;
}
}
}
private function isLoopback(string $ip): bool
/**
* Build HTTP client options that pin the validated host to the resolved IPs.
*
* @return array<string, mixed>
*/
public static function httpClientOptions(string $url): array
{
// 127.0.0.0/8, 0.0.0.0
if ($ip === '0.0.0.0' || str_starts_with($ip, '127.')) {
$options = ['allow_redirects' => false];
if (! defined('CURLOPT_RESOLVE')) {
throw new \RuntimeException('Webhook URL DNS pinning is unavailable.');
}
$target = self::resolveUrlForRequest($url);
if ($target['ips'] === [] || filter_var($target['host'], FILTER_VALIDATE_IP)) {
return $options;
}
$options['curl'] = [
CURLOPT_RESOLVE => array_map(
fn (string $ip): string => sprintf('%s:%d:%s', $target['host'], $target['port'], filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? '['.$ip.']' : $ip),
$target['ips'],
),
];
return $options;
}
/**
* Build mc --resolve mappings that pin the endpoint host for S3 backups.
*
* @return array<int, string>
*/
public static function minioClientResolveOptions(string $url): array
{
$target = self::resolveUrlForRequest($url);
if ($target['ips'] === [] || filter_var($target['host'], FILTER_VALIDATE_IP)) {
return [];
}
return array_map(
fn (string $ip): string => sprintf('%s:%d=%s', $target['host'], $target['port'], filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? '['.$ip.']' : $ip),
$target['ips'],
);
}
public static function redactedUrlForLog(string $url): string
{
$scheme = parse_url($url, PHP_URL_SCHEME);
$host = parse_url($url, PHP_URL_HOST);
$port = parse_url($url, PHP_URL_PORT);
if (! is_string($scheme) || ! is_string($host)) {
return '[invalid-url]';
}
return strtolower($scheme).'://'.strtolower($host).($port ? ':'.$port : '');
}
/**
* @return array{host: string, port: int, ips: array<int, string>}
*/
private static function resolveUrlForRequest(string $url): array
{
$rule = new self;
$host = parse_url($url, PHP_URL_HOST);
if (! is_string($host) || $host === '') {
throw new \RuntimeException('Webhook URL host could not be resolved.');
}
if (str_ends_with($host, '.')) {
throw new \RuntimeException('Webhook URL host must not end with a trailing dot.');
}
$scheme = strtolower(parse_url($url, PHP_URL_SCHEME) ?? '');
$port = parse_url($url, PHP_URL_PORT) ?: ($scheme === 'https' ? 443 : 80);
$hostForDns = rtrim($rule->normalizeHostForIpCheck(strtolower($host)), '.');
if (filter_var($hostForDns, FILTER_VALIDATE_IP)) {
if (! $rule->isAllowedIp($hostForDns, $hostForDns)) {
throw new \RuntimeException('Webhook URL resolved to an unsafe IP address.');
}
return ['host' => $hostForDns, 'port' => $port, 'ips' => []];
}
$resolvedIps = $rule->resolveHost($hostForDns);
if ($resolvedIps === []) {
throw new \RuntimeException('Webhook URL host could not be resolved.');
}
foreach ($resolvedIps as $resolvedIp) {
if (! $rule->isAllowedIp($resolvedIp, $hostForDns)) {
throw new \RuntimeException('Webhook URL resolved to an unsafe IP address.');
}
}
return ['host' => $hostForDns, 'port' => $port, 'ips' => $resolvedIps];
}
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));
}
if ($host === 'localhost') {
return ['127.0.0.1', '::1'];
}
$customDnsServers = $this->customDnsServers();
if ($customDnsServers !== []) {
return $this->resolveHostWithCustomDnsServers($host, $customDnsServers);
}
$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));
}
/**
* @param array<int, string> $dnsServers
* @return array<int, string>
*/
private function resolveHostWithCustomDnsServers(string $host, array $dnsServers): array
{
$ips = [];
foreach ($dnsServers as $dnsServer) {
foreach ([DNSTypes::NAME_A, DNSTypes::NAME_AAAA] as $type) {
try {
$query = new DNSQuery($dnsServer, 53, 5);
$records = $query->query($host, $type);
if ($records === false || $query->hasError()) {
continue;
}
foreach ($records as $record) {
if ($record->getType() === $type && filter_var($record->getData(), FILTER_VALIDATE_IP)) {
$ips[] = $record->getData();
}
}
} catch (Throwable) {
continue;
}
}
}
return array_values(array_unique($ips));
}
/**
* @return array<int, string>
*/
private function customDnsServers(): array
{
$servers = $this->instanceSettings()?->custom_dns_servers ?? '';
if (! is_string($servers)) {
return [];
}
return array_values(array_filter(array_map(
fn (string $server): string => trim($server),
explode(',', $servers),
), fn (string $server): bool => filter_var($server, FILTER_VALIDATE_IP) !== false));
}
private function isAllowedIp(string $ip, string $host): bool
{
$embeddedIpv4 = $this->extractIpv4FromMappedIpv6($ip);
if ($embeddedIpv4 !== null) {
$ip = $embeddedIpv4;
}
if ($this->isPublicIp($ip)) {
return true;
}
// IPv6 loopback
$normalized = @inet_pton($ip);
if ($this->isLocalhostIp($ip)) {
return $this->allowLocalhost()
&& ($this->isAllowedHostname($host) || $this->isAllowlistedIp($ip));
}
return $normalized !== false && $normalized === inet_pton('::1');
if ($this->isPrivateIp($ip)) {
return $this->isAllowedHostname($host) || $this->isAllowlistedIp($ip);
}
return $this->isAllowlistedIp($ip);
}
private function isLinkLocal(string $ip): bool
private function isPublicIp(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)) {
$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
&& ! $this->isSpecialUseIpv4($embeddedIpv4);
}
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE) !== false
&& ! $this->isSpecialUseIp($ip);
}
private function isLocalhostIp(string $ip): bool
{
$embeddedIpv4 = $this->extractIpv4FromMappedIpv6($ip);
if ($embeddedIpv4 !== null) {
return $this->isLocalhostIp($embeddedIpv4);
}
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return $this->ipv4InCidr($ip, '127.0.0.0/8');
}
return @inet_pton($ip) === @inet_pton('::1');
}
private function isPrivateIp(string $ip): bool
{
$embeddedIpv4 = $this->extractIpv4FromMappedIpv6($ip);
if ($embeddedIpv4 !== null) {
$ip = $embeddedIpv4;
}
return filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE) === false
&& filter_var($ip, FILTER_VALIDATE_IP) !== false;
}
private function isSpecialUseIp(string $ip): bool
{
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return $this->isSpecialUseIpv4($ip);
}
return $this->isSpecialUseIpv6($ip);
}
private function isSpecialUseIpv4(string $ip): bool
{
foreach ([
'0.0.0.0/8',
'100.64.0.0/10',
'127.0.0.0/8',
'169.254.0.0/16',
'192.0.0.0/24',
'192.0.2.0/24',
'198.18.0.0/15',
'198.51.100.0/24',
'203.0.113.0/24',
'224.0.0.0/4',
'240.0.0.0/4',
'255.255.255.255/32',
] as $cidr) {
if ($this->ipv4InCidr($ip, $cidr)) {
return true;
}
}
return false;
}
private function isSpecialUseIpv6(string $ip): bool
{
$ipBytes = @inet_pton($ip);
if ($ipBytes === false) {
return false;
}
$long = ip2long($ip);
foreach ([
'::/128',
'::1/128',
'::ffff:0:0/96',
'64:ff9b::/96',
'100::/64',
'2001::/23',
'2001:2::/48',
'2001:db8::/32',
'2002::/16',
'fc00::/7',
'fe80::/10',
'ff00::/8',
] as $cidr) {
[$network, $prefix] = explode('/', $cidr, 2);
$networkBytes = @inet_pton($network);
if ($networkBytes !== false && $this->binaryInCidr($ipBytes, $networkBytes, (int) $prefix)) {
return true;
}
}
return $long !== false && ($long >> 16) === (ip2long('169.254.0.0') >> 16);
return false;
}
private function isBlockedHostname(string $host): bool
{
return in_array($host, ['localhost'], true)
|| str_ends_with($host, '.local')
|| str_ends_with($host, '.internal')
|| str_ends_with($host, '.cluster.local');
}
private function isAllowedHostname(string $host): bool
{
foreach ($this->allowlistEntries() as $entry) {
if (! str_contains($entry, '/') && strtolower($entry) === $host) {
return true;
}
}
return false;
}
private function isAllowlistedIp(string $ip): bool
{
foreach ($this->allowlistEntries() as $entry) {
if (str_contains($entry, '/')) {
if ($this->ipInCidr($ip, $entry)) {
return true;
}
continue;
}
if (filter_var($entry, FILTER_VALIDATE_IP) && @inet_pton($entry) === @inet_pton($ip)) {
return true;
}
}
return false;
}
/**
* @return array<int, string>
*/
private function allowlistEntries(): array
{
$entries = $this->instanceSettings()?->webhook_allowed_internal_hosts ?? [];
if (is_string($entries)) {
$entries = explode(',', $entries);
}
if (! is_array($entries)) {
return [];
}
return array_values(array_filter(array_map(
fn (mixed $entry): string => rtrim(strtolower(trim((string) $entry)), '.'),
$entries,
)));
}
private function allowLocalhost(): bool
{
return (bool) ($this->instanceSettings()?->webhook_allow_localhost ?? false);
}
private function instanceSettings(): ?InstanceSettings
{
try {
return InstanceSettings::query()->find(0);
} catch (Throwable) {
return null;
}
}
private function ipInCidr(string $ip, string $cidr): bool
{
[$network, $prefix] = array_pad(explode('/', $cidr, 2), 2, null);
if ($network === null || $prefix === null || ! is_numeric($prefix)) {
return false;
}
if (filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) && filter_var($network, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) {
return $this->ipv4InCidr($ip, $cidr);
}
if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) || ! filter_var($network, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) {
return false;
}
$prefix = (int) $prefix;
if ($prefix < 0 || $prefix > 128) {
return false;
}
$ipBytes = @inet_pton($ip);
$networkBytes = @inet_pton($network);
if ($ipBytes === false || $networkBytes === false) {
return false;
}
return $this->binaryInCidr($ipBytes, $networkBytes, $prefix);
}
private function ipv4InCidr(string $ip, string $cidr): bool
{
[$network, $prefix] = array_pad(explode('/', $cidr, 2), 2, null);
if ($network === null || $prefix === null || ! is_numeric($prefix)) {
return false;
}
$prefix = (int) $prefix;
if ($prefix < 0 || $prefix > 32) {
return false;
}
$ipLong = ip2long($ip);
$networkLong = ip2long($network);
if ($ipLong === false || $networkLong === false) {
return false;
}
$mask = $prefix === 0 ? 0 : (-1 << (32 - $prefix));
return ($ipLong & $mask) === ($networkLong & $mask);
}
private function binaryInCidr(string $ipBytes, string $networkBytes, int $prefix): bool
{
$bytes = intdiv($prefix, 8);
$bits = $prefix % 8;
if ($bytes > 0 && substr($ipBytes, 0, $bytes) !== substr($networkBytes, 0, $bytes)) {
return false;
}
if ($bits === 0) {
return true;
}
$mask = 0xFF << (8 - $bits) & 0xFF;
return (ord($ipBytes[$bytes]) & $mask) === (ord($networkBytes[$bytes]) & $mask);
}
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.');
}
}
}
+181
View File
@@ -93,12 +93,27 @@ class ValidationPatterns
*/
public const DOCKER_NETWORK_PATTERN = '/^[a-zA-Z0-9][a-zA-Z0-9._-]*$/';
/**
* 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 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';
/**
* Characters that are valid in some URL positions but unsafe for values
* that are later reused in shell assignment contexts.
*/
public const APPLICATION_DOMAIN_FORBIDDEN_PATTERN = '/[`$;&|<>()\\\\\r\n]/';
/**
* Pattern for SQL-safe unquoted database identifiers (usernames, database names).
* Allows letters, digits, underscore; first char must be letter or underscore.
@@ -177,6 +192,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.
*/
@@ -486,6 +517,156 @@ class ValidationPatterns
return ['nullable', 'string', 'max:'.$maxLength, 'regex:'.self::SHELL_SAFE_COMMAND_PATTERN];
}
/**
* Get validation rules for comma-separated application URL fields.
*/
public static function applicationDomainRules(int $maxLength = 2048): array
{
return [
'nullable',
'string',
'max:'.$maxLength,
function (string $attribute, mixed $value, \Closure $fail): void {
foreach (self::validateApplicationDomains($value) as $error) {
$fail($error);
}
},
];
}
/**
* Validate a comma-separated list of application URLs.
*
* @return array<int, string>
*/
public static function validateApplicationDomains(mixed $value): array
{
if (blank($value)) {
return [];
}
if (! is_string($value)) {
return ['The domains field must be a string.'];
}
$errors = [];
foreach (self::applicationDomainList($value) as $url) {
if (preg_match(self::APPLICATION_DOMAIN_FORBIDDEN_PATTERN, $url) === 1) {
$errors[] = "Invalid URL: {$url}";
continue;
}
if (! filter_var($url, FILTER_VALIDATE_URL)) {
$errors[] = "Invalid URL: {$url}";
continue;
}
$scheme = parse_url($url, PHP_URL_SCHEME) ?? '';
if (! in_array(strtolower($scheme), ['http', 'https'], true)) {
$errors[] = "Invalid URL scheme: {$scheme} for URL: {$url}. Only http and https are supported.";
continue;
}
if (blank(parse_url($url, PHP_URL_HOST))) {
$errors[] = "Invalid URL: {$url}";
}
}
return $errors;
}
/**
* Normalize a comma-separated application URL list for storage.
*/
public static function normalizeApplicationDomains(?string $value): ?string
{
$urls = self::applicationDomainList($value);
if ($urls === []) {
return null;
}
return collect($urls)
->map(fn (string $url) => self::normalizeApplicationDomainUrl($url))
->implode(',');
}
/**
* Normalize URL components that are case-insensitive while preserving
* case-sensitive path, query, and fragment components.
*/
private static function normalizeApplicationDomainUrl(string $url): string
{
$components = parse_url($url);
if ($components === false) {
return $url;
}
$normalized = '';
if (isset($components['scheme'])) {
$normalized .= strtolower($components['scheme']).'://';
}
if (isset($components['user'])) {
$normalized .= $components['user'];
if (isset($components['pass'])) {
$normalized .= ':'.$components['pass'];
}
$normalized .= '@';
}
if (isset($components['host'])) {
$normalized .= strtolower($components['host']);
}
if (isset($components['port'])) {
$normalized .= ':'.$components['port'];
}
if (isset($components['path'])) {
$normalized .= $components['path'];
}
if (array_key_exists('query', $components)) {
$normalized .= '?'.$components['query'];
}
if (array_key_exists('fragment', $components)) {
$normalized .= '#'.$components['fragment'];
}
return $normalized;
}
/**
* Split a comma-separated application URL list into trimmed URL strings.
*
* @return array<int, string>
*/
public static function applicationDomainList(?string $value): array
{
if (blank($value)) {
return [];
}
return str($value)
->replaceStart(',', '')
->replaceEnd(',', '')
->trim()
->explode(',')
->map(fn (string $url) => trim($url))
->filter(fn (string $url) => filled($url))
->values()
->all();
}
/**
* Get validation rules for Docker volume name fields
*/