Files
coolify/.cursor/rules/security-patterns.mdc
🏔️ Peak d3f85d777c v4.0.0-beta.420 (#6008)
* chore(version): update coolify-realtime to version 1.0.9 in docker-compose and versions files

* feat(migration): add is_sentinel_enabled column to server_settings with default true

* fix(migration): update default value handling for is_sentinel_enabled column in server_settings

* feat(seeder): dispatch StartProxy action for each server in ProductionSeeder

* feat(seeder): add CheckAndStartSentinelJob dispatch for each server in ProductionSeeder

* fix(seeder): conditionally dispatch CheckAndStartSentinelJob based on server's sentinel status

* feat(seeder): conditionally dispatch StartProxy action based on proxy check result

* refactor(ui): terminal

* refactor(ui): remove terminal header from execute-container-command view

* refactor(ui): remove unnecessary padding from deployment, backup, and logs sections

* fix(service): disable healthcheck logging for Gotenberg (#6005)

* fix(service): Joplin volume name (#5930)

* chore(version): update coolify version to 4.0.0-beta.420 and nightly version to 4.0.0-beta.421

* fix(server): update sentinelUpdatedAt assignment to use server's sentinel_updated_at property

* feat(service): update Changedetection template (#5937)

* chore(service): changedetection remove unused code

* fix(service): audiobookshelf healthcheck command (#5993)

* refactor(service): update Hoarder to their new name karakeep (#5964)

* fix(service): downgrade Evolution API phone version (#5977)

* feat(service): add Miniflux service (#5843)

* refactor(service): karakeep naming and formatting

* refactor(service): improve miniflux

- improve DB url
- add depends_on
- formatting, naming & order

* feat(service): add Pingvin Share service (#5969)

* fix(service): pingvinshare-with-clamav

- add platform to make clamav work
- formatting

* feat(auth): Add Discord OAuth Provider (#5552)

* feat(auth): Add Clerk OAuth Provider (#5553)

* feat(auth): add Zitadel OAuth Provider (#5490)

* Update composer.lock

* fix(ssh): scp requires square brackets for ipv6 (#6001)

* refactor(core): rename API rate limit ENV

* refactor(ui): simplify container selection form in execute-container-command view

* chore(service): Update Evolution API image to the official one (#6031)

* chore(versions): bump coolify versions to v4.0.0-beta.420 and v4.0.0-beta.421

* fix(github): changing github app breaks the webhook. it does not anymore

* feat(service): enhance service status handling and UI updates

* fix(parser): improve FQDN generation and update environment variable handling

* fix(ui):  enhance status refresh buttons with loading indicators

* fix(ui): update confirmation button text for stopping database and service

* fix(routes): update middleware for deploy route to use 'api.ability:deploy'

* fix(ui): refine API token creation form and update helper text for clarity

* fix(ui): adjust layout of deployments section for improved alignment

* chore(dependencies): update composer dependencies to latest versions including resend-laravel to ^0.19.0 and aws-sdk-php to 3.347.0

* refactor(email): streamline SMTP and resend settings logic for improved clarity

* fix(ui): adjust project grid layout and refine server border styling for better visibility

* fix(ui): update border styling for consistency across components and enhance loading indicators

* feat(cleanup): add functionality to delete teams with no members or servers in CleanupStuckedResources command

* refactor(invitation): rename methods for consistency and enhance invitation deletion logic

* refactor(user): streamline user deletion process and enhance team management logic

* fix(ui): add padding to section headers in settings views for improved spacing

* fix(ui): reduce gap between input fields in email settings for better alignment

* fix(docker): conditionally enable gzip compression in Traefik labels based on configuration

* fix(parser): enable gzip compression conditionally for Pocketbase images and streamline service creation logic

* fix(ui): update padding for trademarks policy and enhance spacing in advanced settings section

* feat(ui): add heart icon and enhance popup messaging for sponsorship support

* feat(settings): add sponsorship popup toggle and corresponding database migration

* fix(ui): correct closing tag for sponsorship link in layout popups

* fix(ui): refine wording in sponsorship donation prompt in layout popups

* fix(ui): update navbar icon color and enhance popup layout for sponsorship support

* Update resources/views/livewire/project/shared/health-checks.blade.php

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* Update app/Livewire/Subscription/Index.php

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* fix(ui): add target="_blank" to sponsorship links in layout popups for improved user experience

* fix(models): refine comment wording in User model for clarity on user deletion criteria

* Update app/Providers/RouteServiceProvider.php

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* fix(models): improve user deletion logic in User model to handle team member roles and prevent deletion if user is alone in root team

* fix(ui): update wording in sponsorship prompt for clarity and engagement

---------

Co-authored-by: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Co-authored-by: Khiet Tam Nguyen <86177399+nktnet1@users.noreply.github.com>
Co-authored-by: Carsten <BanditsBacon@users.noreply.github.com>
Co-authored-by: Alberto Rizzi <48057685+albertorizzi@users.noreply.github.com>
Co-authored-by: Jonas Klesen <deklesen@gmail.com>
Co-authored-by: Stew Night. <22344601+stewnight@users.noreply.github.com>
Co-authored-by: Jeffer Marcelino <jeffersunde72@gmail.com>
Co-authored-by: Lucas Eduardo <lucas59356@gmail.com>
Co-authored-by: CrazyTim71 <118295691+CrazyTim71@users.noreply.github.com>
Co-authored-by: Yassir Elmarissi <yassir.elmarissi@hm.edu>
Co-authored-by: Hauke Schnau <hauke@schnau-lilienthal.de>
Co-authored-by: Darren Sisson <74752850+djsisson@users.noreply.github.com>
Co-authored-by: Alkesh Das <67038642+smad-bro@users.noreply.github.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2025-06-26 12:23:08 +02:00

789 lines
22 KiB
Plaintext

---
description:
globs:
alwaysApply: false
---
# Coolify Security Architecture & Patterns
## Security Philosophy
Coolify implements **defense-in-depth security** with multiple layers of protection including authentication, authorization, encryption, network isolation, and secure deployment practices.
## Authentication Architecture
### Multi-Provider Authentication
- **[Laravel Fortify](mdc:config/fortify.php)** - Core authentication scaffolding (4.9KB, 149 lines)
- **[Laravel Sanctum](mdc:config/sanctum.php)** - API token authentication (2.4KB, 69 lines)
- **[Laravel Socialite](mdc:config/services.php)** - OAuth provider integration
### OAuth Integration
- **[OauthSetting.php](mdc:app/Models/OauthSetting.php)** - OAuth provider configurations
- **Supported Providers**:
- Google OAuth
- Microsoft Azure AD
- Clerk
- Authentik
- Discord
- GitHub (via GitHub Apps)
- GitLab
### Authentication Models
```php
// User authentication with team-based access
class User extends Authenticatable
{
use HasApiTokens, HasFactory, Notifiable;
protected $fillable = [
'name', 'email', 'password'
];
protected $hidden = [
'password', 'remember_token'
];
protected $casts = [
'email_verified_at' => 'datetime',
'password' => 'hashed',
];
public function teams(): BelongsToMany
{
return $this->belongsToMany(Team::class)
->withPivot('role')
->withTimestamps();
}
public function currentTeam(): BelongsTo
{
return $this->belongsTo(Team::class, 'current_team_id');
}
}
```
## Authorization & Access Control
### Team-Based Multi-Tenancy
- **[Team.php](mdc:app/Models/Team.php)** - Multi-tenant organization structure (8.9KB, 308 lines)
- **[TeamInvitation.php](mdc:app/Models/TeamInvitation.php)** - Secure team collaboration
- **Role-based permissions** within teams
- **Resource isolation** by team ownership
### Authorization Patterns
```php
// Team-scoped authorization middleware
class EnsureTeamAccess
{
public function handle(Request $request, Closure $next): Response
{
$user = $request->user();
$teamId = $request->route('team');
if (!$user->teams->contains('id', $teamId)) {
abort(403, 'Access denied to team resources');
}
// Set current team context
$user->switchTeam($teamId);
return $next($request);
}
}
// Resource-level authorization policies
class ApplicationPolicy
{
public function view(User $user, Application $application): bool
{
return $user->teams->contains('id', $application->team_id);
}
public function deploy(User $user, Application $application): bool
{
return $this->view($user, $application) &&
$user->hasTeamPermission($application->team_id, 'deploy');
}
public function delete(User $user, Application $application): bool
{
return $this->view($user, $application) &&
$user->hasTeamRole($application->team_id, 'admin');
}
}
```
### Global Scopes for Data Isolation
```php
// Automatic team-based filtering
class Application extends Model
{
protected static function booted(): void
{
static::addGlobalScope('team', function (Builder $builder) {
if (auth()->check() && auth()->user()->currentTeam) {
$builder->whereHas('environment.project', function ($query) {
$query->where('team_id', auth()->user()->currentTeam->id);
});
}
});
}
}
```
## API Security
### Token-Based Authentication
```php
// Sanctum API token management
class PersonalAccessToken extends Model
{
protected $fillable = [
'name', 'token', 'abilities', 'expires_at'
];
protected $casts = [
'abilities' => 'array',
'expires_at' => 'datetime',
'last_used_at' => 'datetime',
];
public function tokenable(): MorphTo
{
return $this->morphTo();
}
public function hasAbility(string $ability): bool
{
return in_array('*', $this->abilities) ||
in_array($ability, $this->abilities);
}
}
```
### API Rate Limiting
```php
// Rate limiting configuration
RateLimiter::for('api', function (Request $request) {
return Limit::perMinute(60)->by($request->user()?->id ?: $request->ip());
});
RateLimiter::for('deployments', function (Request $request) {
return Limit::perMinute(10)->by($request->user()->id);
});
RateLimiter::for('webhooks', function (Request $request) {
return Limit::perMinute(100)->by($request->ip());
});
```
### API Input Validation
```php
// Comprehensive input validation
class StoreApplicationRequest extends FormRequest
{
public function authorize(): bool
{
return $this->user()->can('create', Application::class);
}
public function rules(): array
{
return [
'name' => 'required|string|max:255|regex:/^[a-zA-Z0-9\-_]+$/',
'git_repository' => 'required|url|starts_with:https://',
'git_branch' => 'required|string|max:100|regex:/^[a-zA-Z0-9\-_\/]+$/',
'server_id' => 'required|exists:servers,id',
'environment_id' => 'required|exists:environments,id',
'environment_variables' => 'array',
'environment_variables.*' => 'string|max:1000',
];
}
public function prepareForValidation(): void
{
$this->merge([
'name' => strip_tags($this->name),
'git_repository' => filter_var($this->git_repository, FILTER_SANITIZE_URL),
]);
}
}
```
## SSH Security
### Private Key Management
- **[PrivateKey.php](mdc:app/Models/PrivateKey.php)** - Secure SSH key storage (6.5KB, 247 lines)
- **Encrypted key storage** in database
- **Key rotation** capabilities
- **Access logging** for key usage
### SSH Connection Security
```php
class SshConnection
{
private string $host;
private int $port;
private string $username;
private PrivateKey $privateKey;
public function __construct(Server $server)
{
$this->host = $server->ip;
$this->port = $server->port;
$this->username = $server->user;
$this->privateKey = $server->privateKey;
}
public function connect(): bool
{
$connection = ssh2_connect($this->host, $this->port);
if (!$connection) {
throw new SshConnectionException('Failed to connect to server');
}
// Use private key authentication
$privateKeyContent = decrypt($this->privateKey->private_key);
$publicKeyContent = decrypt($this->privateKey->public_key);
if (!ssh2_auth_pubkey_file($connection, $this->username, $publicKeyContent, $privateKeyContent)) {
throw new SshAuthenticationException('SSH authentication failed');
}
return true;
}
public function execute(string $command): string
{
// Sanitize command to prevent injection
$command = escapeshellcmd($command);
$stream = ssh2_exec($this->connection, $command);
if (!$stream) {
throw new SshExecutionException('Failed to execute command');
}
return stream_get_contents($stream);
}
}
```
## Container Security
### Docker Security Patterns
```php
class DockerSecurityService
{
public function createSecureContainer(Application $application): array
{
return [
'image' => $this->validateImageName($application->docker_image),
'user' => '1000:1000', // Non-root user
'read_only' => true,
'no_new_privileges' => true,
'security_opt' => [
'no-new-privileges:true',
'apparmor:docker-default'
],
'cap_drop' => ['ALL'],
'cap_add' => ['CHOWN', 'SETUID', 'SETGID'], // Minimal capabilities
'tmpfs' => [
'/tmp' => 'rw,noexec,nosuid,size=100m',
'/var/tmp' => 'rw,noexec,nosuid,size=50m'
],
'ulimits' => [
'nproc' => 1024,
'nofile' => 1024
]
];
}
private function validateImageName(string $image): string
{
// Validate image name against allowed registries
$allowedRegistries = ['docker.io', 'ghcr.io', 'quay.io'];
$parser = new DockerImageParser();
$parsed = $parser->parse($image);
if (!in_array($parsed['registry'], $allowedRegistries)) {
throw new SecurityException('Image registry not allowed');
}
return $image;
}
}
```
### Network Isolation
```yaml
# Docker Compose security configuration
version: '3.8'
services:
app:
image: ${APP_IMAGE}
networks:
- app-network
security_opt:
- no-new-privileges:true
- apparmor:docker-default
read_only: true
tmpfs:
- /tmp:rw,noexec,nosuid,size=100m
cap_drop:
- ALL
cap_add:
- CHOWN
- SETUID
- SETGID
networks:
app-network:
driver: bridge
internal: true
ipam:
config:
- subnet: 172.20.0.0/16
```
## SSL/TLS Security
### Certificate Management
- **[SslCertificate.php](mdc:app/Models/SslCertificate.php)** - SSL certificate automation
- **Let's Encrypt** integration for free certificates
- **Automatic renewal** and monitoring
- **Custom certificate** upload support
### SSL Configuration
```php
class SslCertificateService
{
public function generateCertificate(Application $application): SslCertificate
{
$domains = $this->validateDomains($application->getAllDomains());
$certificate = SslCertificate::create([
'application_id' => $application->id,
'domains' => $domains,
'provider' => 'letsencrypt',
'status' => 'pending'
]);
// Generate certificate using ACME protocol
$acmeClient = new AcmeClient();
$certData = $acmeClient->generateCertificate($domains);
$certificate->update([
'certificate' => encrypt($certData['certificate']),
'private_key' => encrypt($certData['private_key']),
'chain' => encrypt($certData['chain']),
'expires_at' => $certData['expires_at'],
'status' => 'active'
]);
return $certificate;
}
private function validateDomains(array $domains): array
{
foreach ($domains as $domain) {
if (!filter_var($domain, FILTER_VALIDATE_DOMAIN)) {
throw new InvalidDomainException("Invalid domain: {$domain}");
}
// Check domain ownership
if (!$this->verifyDomainOwnership($domain)) {
throw new DomainOwnershipException("Domain ownership verification failed: {$domain}");
}
}
return $domains;
}
}
```
## Environment Variable Security
### Secure Configuration Management
```php
class EnvironmentVariable extends Model
{
protected $fillable = [
'key', 'value', 'is_secret', 'application_id'
];
protected $casts = [
'is_secret' => 'boolean',
'value' => 'encrypted' // Automatic encryption for sensitive values
];
public function setValueAttribute($value): void
{
// Automatically encrypt sensitive environment variables
if ($this->isSensitiveKey($this->key)) {
$this->attributes['value'] = encrypt($value);
$this->attributes['is_secret'] = true;
} else {
$this->attributes['value'] = $value;
}
}
public function getValueAttribute($value): string
{
if ($this->is_secret) {
return decrypt($value);
}
return $value;
}
private function isSensitiveKey(string $key): bool
{
$sensitivePatterns = [
'PASSWORD', 'SECRET', 'KEY', 'TOKEN', 'API_KEY',
'DATABASE_URL', 'REDIS_URL', 'PRIVATE', 'CREDENTIAL',
'AUTH', 'CERTIFICATE', 'ENCRYPTION', 'SALT', 'HASH',
'OAUTH', 'JWT', 'BEARER', 'ACCESS', 'REFRESH'
];
foreach ($sensitivePatterns as $pattern) {
if (str_contains(strtoupper($key), $pattern)) {
return true;
}
}
return false;
}
}
```
## Webhook Security
### Webhook Signature Verification
```php
class WebhookSecurityService
{
public function verifyGitHubSignature(Request $request, string $secret): bool
{
$signature = $request->header('X-Hub-Signature-256');
if (!$signature) {
return false;
}
$expectedSignature = 'sha256=' . hash_hmac('sha256', $request->getContent(), $secret);
return hash_equals($expectedSignature, $signature);
}
public function verifyGitLabSignature(Request $request, string $secret): bool
{
$signature = $request->header('X-Gitlab-Token');
return hash_equals($secret, $signature);
}
public function validateWebhookPayload(array $payload): array
{
// Sanitize and validate webhook payload
$validator = Validator::make($payload, [
'repository.clone_url' => 'required|url|starts_with:https://',
'ref' => 'required|string|max:255',
'head_commit.id' => 'required|string|size:40', // Git SHA
'head_commit.message' => 'required|string|max:1000'
]);
if ($validator->fails()) {
throw new InvalidWebhookPayloadException('Invalid webhook payload');
}
return $validator->validated();
}
}
```
## Input Sanitization & Validation
### XSS Prevention
```php
class SecurityMiddleware
{
public function handle(Request $request, Closure $next): Response
{
// Sanitize input data
$input = $request->all();
$sanitized = $this->sanitizeInput($input);
$request->merge($sanitized);
return $next($request);
}
private function sanitizeInput(array $input): array
{
foreach ($input as $key => $value) {
if (is_string($value)) {
// Remove potentially dangerous HTML tags
$input[$key] = strip_tags($value, '<p><br><strong><em>');
// Escape special characters
$input[$key] = htmlspecialchars($input[$key], ENT_QUOTES, 'UTF-8');
} elseif (is_array($value)) {
$input[$key] = $this->sanitizeInput($value);
}
}
return $input;
}
}
```
### SQL Injection Prevention
```php
// Always use parameterized queries and Eloquent ORM
class ApplicationRepository
{
public function findByName(string $name): ?Application
{
// Safe: Uses parameter binding
return Application::where('name', $name)->first();
}
public function searchApplications(string $query): Collection
{
// Safe: Eloquent handles escaping
return Application::where('name', 'LIKE', "%{$query}%")
->orWhere('description', 'LIKE', "%{$query}%")
->get();
}
// NEVER do this - vulnerable to SQL injection
// public function unsafeSearch(string $query): Collection
// {
// return DB::select("SELECT * FROM applications WHERE name LIKE '%{$query}%'");
// }
}
```
## Audit Logging & Monitoring
### Activity Logging
```php
// Using Spatie Activity Log package
class Application extends Model
{
use LogsActivity;
protected static $logAttributes = [
'name', 'git_repository', 'git_branch', 'fqdn'
];
protected static $logOnlyDirty = true;
public function getDescriptionForEvent(string $eventName): string
{
return "Application {$this->name} was {$eventName}";
}
}
// Custom security events
class SecurityEventLogger
{
public function logFailedLogin(string $email, string $ip): void
{
activity('security')
->withProperties([
'email' => $email,
'ip' => $ip,
'user_agent' => request()->userAgent()
])
->log('Failed login attempt');
}
public function logSuspiciousActivity(User $user, string $activity): void
{
activity('security')
->causedBy($user)
->withProperties([
'activity' => $activity,
'ip' => request()->ip(),
'timestamp' => now()
])
->log('Suspicious activity detected');
}
}
```
### Security Monitoring
```php
class SecurityMonitoringService
{
public function detectAnomalousActivity(User $user): bool
{
// Check for unusual login patterns
$recentLogins = $user->activities()
->where('description', 'like', '%login%')
->where('created_at', '>=', now()->subHours(24))
->get();
// Multiple failed attempts
$failedAttempts = $recentLogins->where('description', 'Failed login attempt')->count();
if ($failedAttempts > 5) {
$this->triggerSecurityAlert($user, 'Multiple failed login attempts');
return true;
}
// Login from new location
$uniqueIps = $recentLogins->pluck('properties.ip')->unique();
if ($uniqueIps->count() > 3) {
$this->triggerSecurityAlert($user, 'Login from multiple IP addresses');
return true;
}
return false;
}
private function triggerSecurityAlert(User $user, string $reason): void
{
// Send security notification
$user->notify(new SecurityAlertNotification($reason));
// Log security event
activity('security')
->causedBy($user)
->withProperties(['reason' => $reason])
->log('Security alert triggered');
}
}
```
## Backup Security
### Encrypted Backups
```php
class SecureBackupService
{
public function createEncryptedBackup(ScheduledDatabaseBackup $backup): void
{
$database = $backup->database;
$dumpPath = $this->createDatabaseDump($database);
// Encrypt backup file
$encryptedPath = $this->encryptFile($dumpPath, $backup->encryption_key);
// Upload to secure storage
$this->uploadToSecureStorage($encryptedPath, $backup->s3Storage);
// Clean up local files
unlink($dumpPath);
unlink($encryptedPath);
}
private function encryptFile(string $filePath, string $key): string
{
$data = file_get_contents($filePath);
$encryptedData = encrypt($data, $key);
$encryptedPath = $filePath . '.encrypted';
file_put_contents($encryptedPath, $encryptedData);
return $encryptedPath;
}
}
```
## Security Headers & CORS
### Security Headers Configuration
```php
// Security headers middleware
class SecurityHeadersMiddleware
{
public function handle(Request $request, Closure $next): Response
{
$response = $next($request);
$response->headers->set('X-Content-Type-Options', 'nosniff');
$response->headers->set('X-Frame-Options', 'DENY');
$response->headers->set('X-XSS-Protection', '1; mode=block');
$response->headers->set('Referrer-Policy', 'strict-origin-when-cross-origin');
$response->headers->set('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
if ($request->secure()) {
$response->headers->set('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
}
return $response;
}
}
```
### CORS Configuration
```php
// CORS configuration for API endpoints
return [
'paths' => ['api/*', 'webhooks/*'],
'allowed_methods' => ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'],
'allowed_origins' => [
'https://app.coolify.io',
'https://*.coolify.io'
],
'allowed_origins_patterns' => [],
'allowed_headers' => ['*'],
'exposed_headers' => [],
'max_age' => 0,
'supports_credentials' => true,
];
```
## Security Testing
### Security Test Patterns
```php
// Security-focused tests
test('prevents SQL injection in search', function () {
$user = User::factory()->create();
$maliciousInput = "'; DROP TABLE applications; --";
$response = $this->actingAs($user)
->getJson("/api/v1/applications?search={$maliciousInput}");
$response->assertStatus(200);
// Verify applications table still exists
expect(Schema::hasTable('applications'))->toBeTrue();
});
test('prevents XSS in application names', function () {
$user = User::factory()->create();
$xssPayload = '<script>alert("xss")</script>';
$response = $this->actingAs($user)
->postJson('/api/v1/applications', [
'name' => $xssPayload,
'git_repository' => 'https://github.com/user/repo.git',
'server_id' => Server::factory()->create()->id
]);
$response->assertStatus(422);
});
test('enforces team isolation', function () {
$user1 = User::factory()->create();
$user2 = User::factory()->create();
$team1 = Team::factory()->create();
$team2 = Team::factory()->create();
$user1->teams()->attach($team1);
$user2->teams()->attach($team2);
$application = Application::factory()->create(['team_id' => $team1->id]);
$response = $this->actingAs($user2)
->getJson("/api/v1/applications/{$application->id}");
$response->assertStatus(403);
});
```