Files
michaelschiemer/docs/livecomponents/security-guide-complete.md
2025-11-24 21:28:25 +01:00

17 KiB

LiveComponents Security Guide

Complete security guide for LiveComponents covering CSRF protection, rate limiting, idempotency, authorization, and best practices.


Table of Contents

  1. CSRF Protection
  2. Rate Limiting
  3. Idempotency
  4. Action Allow-List
  5. Authorization
  6. Input Validation
  7. XSS Prevention
  8. Secure State Management
  9. Security Checklist

CSRF Protection

How It Works

LiveComponents automatically protect all actions with CSRF tokens:

  1. Token Generation: Unique token generated per component instance
  2. Token Validation: Token validated on every action request
  3. Token Regeneration: Token regenerated after each action

Implementation

Server-Side (Automatic):

// CSRF token is automatically generated and validated
#[Action]
public function increment(): CounterState
{
    // CSRF validation happens automatically before this method is called
    return $this->state->increment();
}

Client-Side (Automatic):

<!-- CSRF token is automatically included in action requests -->
<button data-live-action="increment">Increment</button>

Manual CSRF Token Access

If you need to access CSRF tokens manually:

use App\Framework\LiveComponents\ComponentRegistry;

$registry = container()->get(ComponentRegistry::class);
$csrfToken = $registry->generateCsrfToken($componentId);

CSRF Token Format

  • Length: 32 hexadecimal characters
  • Format: [a-f0-9]{32}
  • Scope: Per component instance (not global)

Best Practices

DO:

  • Let the framework handle CSRF automatically
  • Include CSRF meta tag in base layout: <meta name="csrf-token" content="{csrf_token}">
  • Use HTTPS in production

DON'T:

  • Disable CSRF protection
  • Share CSRF tokens between components
  • Store CSRF tokens in localStorage (use session)

Rate Limiting

Overview

Rate limiting prevents abuse by limiting the number of actions per time period.

Configuration

Global Rate Limit (.env):

LIVECOMPONENT_RATE_LIMIT=60  # 60 requests per minute per component

Per-Action Rate Limit:

#[Action(rateLimit: 10)]  // 10 requests per minute for this action
public function expensiveOperation(): State
{
    // Implementation
}

Rate Limit Headers

When rate limit is exceeded, response includes:

HTTP/1.1 429 Too Many Requests
X-RateLimit-Limit: 60
X-RateLimit-Remaining: 0
X-RateLimit-Reset: 1640995200
Retry-After: 30

Client-Side Handling

// Automatic retry after Retry-After header
LiveComponent.executeAction('counter:demo', 'increment')
    .catch(error => {
        if (error.status === 429) {
            const retryAfter = error.headers['retry-after'];
            console.log(`Rate limited. Retry after ${retryAfter} seconds`);
        }
    });

Best Practices

DO:

  • Set appropriate rate limits based on action cost
  • Use higher limits for read operations, lower for write operations
  • Monitor rate limit violations

DON'T:

  • Set rate limits too low (hurts UX)
  • Set rate limits too high (allows abuse)
  • Ignore rate limit violations

Idempotency

Overview

Idempotency ensures that repeating the same action multiple times has the same effect as performing it once.

Configuration

Per-Action Idempotency:

#[Action(idempotencyTTL: 60)]  // Idempotent for 60 seconds
public function processPayment(float $amount): State
{
    // This action will return cached result if called again within 60 seconds
    return $this->state->processPayment($amount);
}

How It Works

  1. First Request: Action executes, result cached with idempotency key
  2. Subsequent Requests: Same idempotency key returns cached result
  3. After TTL: Cache expires, action can execute again

Idempotency Key

Client-Side:

// Generate unique idempotency key
const idempotencyKey = `payment-${Date.now()}-${Math.random()}`;

LiveComponent.executeAction('payment:demo', 'processPayment', {
    amount: 100.00,
    idempotency_key: idempotencyKey
});

Server-Side (Automatic):

  • Idempotency key extracted from request
  • Key includes: component ID + action name + parameters
  • Cached result returned if key matches

Use Cases

Good for:

  • Payment processing
  • Order creation
  • Email sending
  • External API calls

Not suitable for:

  • Incrementing counters
  • Appending to lists
  • Time-sensitive operations

Best Practices

DO:

  • Use idempotency for critical operations
  • Set appropriate TTL (long enough to prevent duplicates, short enough to allow retries)
  • Include idempotency key in client requests

DON'T:

  • Use idempotency for operations that should execute multiple times
  • Set TTL too long (prevents legitimate retries)
  • Rely solely on idempotency for security

Action Allow-List

Overview

Only methods marked with #[Action] can be called from the client.

Implementation

#[LiveComponent('user-profile')]
final readonly class UserProfileComponent implements LiveComponentContract
{
    // ✅ Can be called from client
    #[Action]
    public function updateProfile(array $data): State
    {
        return $this->state->updateProfile($data);
    }

    // ❌ Cannot be called from client (no #[Action] attribute)
    public function internalMethod(): void
    {
        // This method is not exposed to the client
    }

    // ❌ Reserved methods cannot be actions
    public function mount(): State
    {
        // Reserved method - cannot be called as action
    }
}

Reserved Methods

These methods cannot be actions:

  • mount()
  • getRenderData()
  • getId()
  • getState()
  • getData()
  • Methods starting with _ (private convention)

Security Benefits

  • Explicit API: Only intended methods are callable
  • No Accidental Exposure: Internal methods stay internal
  • Clear Intent: #[Action] makes API surface explicit

Best Practices

DO:

  • Mark all public methods that should be callable with #[Action]
  • Keep internal methods without #[Action]
  • Use descriptive action names

DON'T:

  • Mark internal methods with #[Action]
  • Expose sensitive operations without proper authorization
  • Use generic action names like do() or execute()

Authorization

Overview

Use #[RequiresPermission] to restrict actions to authorized users.

Implementation

#[LiveComponent('post-editor')]
final readonly class PostEditorComponent implements LiveComponentContract
{
    #[Action]
    #[RequiresPermission('posts.edit')]
    public function updatePost(array $data): State
    {
        return $this->state->updatePost($data);
    }

    #[Action]
    #[RequiresPermission('posts.delete')]
    public function deletePost(): State
    {
        return $this->state->deletePost();
    }

    // Multiple permissions (user needs ALL)
    #[Action]
    #[RequiresPermission('posts.edit', 'posts.publish')]
    public function publishPost(): State
    {
        return $this->state->publishPost();
    }
}

Permission Checking

Custom Authorization Checker:

use App\Framework\LiveComponents\Security\AuthorizationCheckerInterface;

final readonly class CustomAuthorizationChecker implements AuthorizationCheckerInterface
{
    public function hasPermission(string $permission): bool
    {
        // Check if current user has permission
        return $this->user->hasPermission($permission);
    }

    public function hasAllPermissions(array $permissions): bool
    {
        foreach ($permissions as $permission) {
            if (!$this->hasPermission($permission)) {
                return false;
            }
        }
        return true;
    }
}

Unauthorized Access

When user lacks required permission:

{
    "success": false,
    "error": {
        "code": "UNAUTHORIZED",
        "message": "User does not have required permission: posts.edit"
    }
}

Best Practices

DO:

  • Use authorization for sensitive operations
  • Check permissions server-side (never trust client)
  • Use specific permissions (not generic like 'admin')
  • Log authorization failures

DON'T:

  • Rely on client-side authorization checks
  • Use overly broad permissions
  • Skip authorization for write operations
  • Expose permission names in error messages

Input Validation

Overview

Always validate input in actions before processing.

Implementation

#[Action]
public function updateProfile(array $data): State
{
    // Validate input
    $errors = [];
    
    if (empty($data['name'])) {
        $errors[] = 'Name is required';
    }
    
    if (isset($data['email']) && !filter_var($data['email'], FILTER_VALIDATE_EMAIL)) {
        $errors[] = 'Invalid email address';
    }
    
    if (isset($data['age']) && ($data['age'] < 0 || $data['age'] > 150)) {
        $errors[] = 'Age must be between 0 and 150';
    }
    
    if (!empty($errors)) {
        throw new ValidationException('Validation failed', $errors);
    }
    
    // Process valid data
    return $this->state->updateProfile($data);
}

Type Safety

Use type hints for automatic validation:

#[Action]
public function addAmount(int $amount): State
{
    // PHP automatically validates $amount is an integer
    if ($amount < 0) {
        throw new \InvalidArgumentException('Amount must be positive');
    }
    
    return $this->state->addAmount($amount);
}

Value Objects

Use Value Objects for complex validation:

#[Action]
public function updateEmail(Email $email): State
{
    // Email Value Object validates format automatically
    return $this->state->updateEmail($email);
}

Best Practices

DO:

  • Validate all input in actions
  • Use type hints for automatic validation
  • Use Value Objects for complex data
  • Return clear error messages

DON'T:

  • Trust client input
  • Skip validation for "internal" actions
  • Expose internal validation details
  • Use generic error messages

XSS Prevention

Overview

LiveComponents automatically escape output in templates.

Template Escaping

Automatic Escaping:

<!-- Automatically escaped -->
<div>{user_input}</div>

<!-- Safe HTML (if needed) -->
<div>{html_content|raw}</div>

Best Practices

DO:

  • Let the framework escape output automatically
  • Use |raw filter only when necessary
  • Validate HTML content before storing
  • Use Content Security Policy (CSP)

DON'T:

  • Disable automatic escaping
  • Use |raw with user input
  • Store unvalidated HTML
  • Trust client-provided HTML

Secure State Management

Overview

Component state should never contain sensitive data.

Sensitive Data

DON'T Store:

  • Passwords
  • API keys
  • Credit card numbers
  • Session tokens
  • Private keys

DO Store:

  • User IDs (not passwords)
  • Display preferences
  • UI state
  • Non-sensitive configuration

State Encryption

For sensitive state (if absolutely necessary):

use App\Framework\Encryption\EncryptionService;

final readonly class SecureComponent implements LiveComponentContract
{
    public function __construct(
        private EncryptionService $encryption
    ) {
    }

    public function storeSensitiveData(string $data): State
    {
        $encrypted = $this->encryption->encrypt($data);
        return $this->state->withEncryptedData($encrypted);
    }
}

Best Practices

DO:

  • Keep state minimal
  • Store only necessary data
  • Use server-side storage for sensitive data
  • Encrypt sensitive state if needed

DON'T:

  • Store passwords or secrets in state
  • Include sensitive data in state
  • Trust client-provided state
  • Expose internal implementation details

Security Checklist

Component Development

  • All actions marked with #[Action]
  • No sensitive data in component state
  • Input validation in all actions
  • Authorization checks for sensitive operations
  • Rate limiting configured appropriately
  • Idempotency for critical operations
  • CSRF protection enabled (automatic)
  • Error messages don't expose sensitive information

Testing

  • CSRF token validation tested
  • Rate limiting tested
  • Authorization tested
  • Input validation tested
  • XSS prevention tested
  • Error handling tested

Deployment

  • HTTPS enabled
  • CSRF protection enabled
  • Rate limits configured
  • Error reporting configured
  • Security headers configured
  • Content Security Policy configured

Common Security Pitfalls

1. Trusting Client State

WRONG:

#[Action]
public function updateBalance(float $newBalance): State
{
    // Don't trust client-provided balance!
    return $this->state->withBalance($newBalance);
}

CORRECT:

#[Action]
public function addToBalance(float $amount): State
{
    // Calculate new balance server-side
    $newBalance = $this->state->balance + $amount;
    return $this->state->withBalance($newBalance);
}

2. Skipping Authorization

WRONG:

#[Action]
public function deleteUser(int $userId): State
{
    // No authorization check!
    $this->userService->delete($userId);
    return $this->state;
}

CORRECT:

#[Action]
#[RequiresPermission('users.delete')]
public function deleteUser(int $userId): State
{
    $this->userService->delete($userId);
    return $this->state;
}

3. Exposing Sensitive Data

WRONG:

public function getRenderData(): ComponentRenderData
{
    return new ComponentRenderData(
        templatePath: 'user-profile',
        data: [
            'password' => $this->user->password,  // ❌ Never expose passwords!
            'api_key' => $this->user->apiKey,     // ❌ Never expose API keys!
        ]
    );
}

CORRECT:

public function getRenderData(): ComponentRenderData
{
    return new ComponentRenderData(
        templatePath: 'user-profile',
        data: [
            'username' => $this->user->username,
            'email' => $this->user->email,
            // Only include safe, display data
        ]
    );
}

Security Patterns

Pattern 1: Secure File Upload

#[LiveComponent('file-uploader')]
final readonly class FileUploaderComponent implements LiveComponentContract, SupportsFileUpload
{
    public function validateUpload(UploadedFile $file): array
    {
        $errors = [];
        
        // Check file type
        $allowedTypes = ['image/jpeg', 'image/png'];
        if (!in_array($file->getMimeType(), $allowedTypes)) {
            $errors[] = 'Invalid file type';
        }
        
        // Check file size
        if ($file->getSize() > 5 * 1024 * 1024) {
            $errors[] = 'File too large';
        }
        
        // Check file extension
        $extension = pathinfo($file->getName(), PATHINFO_EXTENSION);
        $allowedExtensions = ['jpg', 'jpeg', 'png'];
        if (!in_array(strtolower($extension), $allowedExtensions)) {
            $errors[] = 'Invalid file extension';
        }
        
        return $errors;
    }

    #[Action]
    #[RequiresPermission('files.upload')]
    public function handleUpload(UploadedFile $file): State
    {
        // Additional server-side validation
        $errors = $this->validateUpload($file);
        if (!empty($errors)) {
            throw new ValidationException('Upload validation failed', $errors);
        }
        
        // Process upload securely
        $path = $this->storage->store($file);
        return $this->state->withUploadedFile($path);
    }
}

Pattern 2: Secure Payment Processing

#[LiveComponent('payment')]
final readonly class PaymentComponent implements LiveComponentContract
{
    #[Action]
    #[RequiresPermission('payments.process')]
    #[Action(rateLimit: 5, idempotencyTTL: 300)]  // 5 per minute, 5 min idempotency
    public function processPayment(
        float $amount,
        string $paymentMethod,
        string $idempotencyKey
    ): State {
        // Validate amount
        if ($amount <= 0 || $amount > 10000) {
            throw new ValidationException('Invalid amount');
        }
        
        // Validate payment method
        $allowedMethods = ['credit_card', 'paypal'];
        if (!in_array($paymentMethod, $allowedMethods)) {
            throw new ValidationException('Invalid payment method');
        }
        
        // Process payment (idempotent)
        $transaction = $this->paymentService->process(
            $amount,
            $paymentMethod,
            $idempotencyKey
        );
        
        return $this->state->withTransaction($transaction);
    }
}

Next Steps