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

757 lines
17 KiB
Markdown

# LiveComponents Security Guide
**Complete security guide for LiveComponents covering CSRF protection, rate limiting, idempotency, authorization, and best practices.**
---
## Table of Contents
1. [CSRF Protection](#csrf-protection)
2. [Rate Limiting](#rate-limiting)
3. [Idempotency](#idempotency)
4. [Action Allow-List](#action-allow-list)
5. [Authorization](#authorization)
6. [Input Validation](#input-validation)
7. [XSS Prevention](#xss-prevention)
8. [Secure State Management](#secure-state-management)
9. [Security Checklist](#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):
```php
// 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):
```html
<!-- 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:
```php
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`):
```env
LIVECOMPONENT_RATE_LIMIT=60 # 60 requests per minute per component
```
**Per-Action Rate Limit**:
```php
#[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
```javascript
// 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**:
```php
#[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**:
```javascript
// 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
```php
#[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
```php
#[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**:
```php
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:
```json
{
"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
```php
#[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:
```php
#[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:
```php
#[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**:
```html
<!-- 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):
```php
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**:
```php
#[Action]
public function updateBalance(float $newBalance): State
{
// Don't trust client-provided balance!
return $this->state->withBalance($newBalance);
}
```
**CORRECT**:
```php
#[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**:
```php
#[Action]
public function deleteUser(int $userId): State
{
// No authorization check!
$this->userService->delete($userId);
return $this->state;
}
```
**CORRECT**:
```php
#[Action]
#[RequiresPermission('users.delete')]
public function deleteUser(int $userId): State
{
$this->userService->delete($userId);
return $this->state;
}
```
### 3. Exposing Sensitive Data
**WRONG**:
```php
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**:
```php
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
```php
#[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
```php
#[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
- [Performance Guide](performance-guide-complete.md) - Optimization techniques
- [End-to-End Guide](end-to-end-guide.md) - Complete development guide
- [API Reference](api-reference-complete.md) - Complete API documentation