Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,10 +30,13 @@
},
"minimum-stability": "dev",
"prefer-stable": true,
"scripts": {
"test": "vendor/bin/phpunit"
},
"require-dev": {
"phpunit/phpunit": "^10.4",
"laravel/framework": "^10.15.0|^11.0",
"orchestra/testbench": "^8.21.0|^9.1",
"livewire/livewire": "^3.5"
"livewire/livewire": "^3.5|^4.0"
}
}
80 changes: 80 additions & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# Livewire Strict - Documentation

Livewire Strict enforces additional security measures for your [Livewire](https://livewire.laravel.com) components, preventing common attack vectors that exploit Livewire's frontend-exposed surface.

## Installation

```bash
composer require wire-elements/livewire-strict
```

The package auto-registers its service provider via Laravel's package discovery.

## Quick Start

Enable all features at once in your `AppServiceProvider`:

```php
use WireElements\LivewireStrict\LivewireStrict;

class AppServiceProvider extends ServiceProvider
{
public function boot(): void
{
LivewireStrict::enableAll();
}
}
```

Or enable features individually:

```php
LivewireStrict::lockProperties();
LivewireStrict::signedActions(ttl: 300);
```

## Features

| Feature | What it protects | Docs |
|---------|-----------------|------|
| [Locked Properties](locked-properties.md) | Prevents frontend from modifying public properties | [Read more →](locked-properties.md) |
| [Signed Actions](signed-actions.md) | Makes action calls tamper-proof with HMAC signatures | [Read more →](signed-actions.md) |

## How it works

Every Livewire request sends a JSON payload from the browser to the server. An attacker can craft these payloads manually to:

1. **Modify any public property** - e.g., changing `$price` or `$user_id`
2. **Alter action parameters** - e.g., changing `wire:click="delete(5)"` to `delete(999)`

Livewire Strict closes these gaps by requiring explicit opt-in for property modifications and cryptographic signing for sensitive action calls.

## Configuration

### Scoping to specific components

All features accept a `components` parameter to scope enforcement:

```php
// All components under App\Livewire (default)
LivewireStrict::lockProperties();

// Specific namespace
LivewireStrict::lockProperties(components: 'App\Livewire\Admin\*');

// Specific component
LivewireStrict::lockProperties(components: App\Livewire\Checkout::class);

// Multiple patterns
LivewireStrict::lockProperties(components: [
'App\Livewire\Admin\*',
'App\Livewire\Checkout',
]);
```

## Requirements

- PHP 8.1+
- Laravel 10, 11, or later
- Livewire 3.5+ or 4.0+
- A valid `APP_KEY` (required for signed actions)
98 changes: 98 additions & 0 deletions docs/locked-properties.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# Locked Properties

Locks all public properties on Livewire components by default, preventing the frontend from modifying them. Properties must be explicitly unlocked with the `#[Unlocked]` attribute.

## The Problem

In Livewire, every public property is writable from the frontend. A malicious user can open browser DevTools and send a crafted request to change any public property - even ones not bound to any input.

```php
class Invoice extends Component
{
public int $invoiceId = 1;
public float $total = 99.99;
public string $status = 'pending';
}
```

An attacker could send a Livewire update to set `$total = 0` or `$status = 'paid'` without any UI interaction.

## Setup

```php
use WireElements\LivewireStrict\LivewireStrict;

// In your AppServiceProvider::boot()
LivewireStrict::lockProperties();
```

## Usage

Once enabled, **all public properties are locked by default**. Any frontend attempt to modify them throws a `CannotUpdateLockedPropertyException`.

### Unlocking specific properties

Use the `#[Unlocked]` attribute on properties that should be writable from the frontend:

```php
use WireElements\LivewireStrict\Attributes\Unlocked;

class SearchUsers extends Component
{
#[Unlocked]
public string $query = ''; // ✅ Frontend can update (e.g., wire:model)

public array $results = []; // 🔒 Locked - only server can modify
public int $totalCount = 0; // 🔒 Locked - only server can modify
}
```

```blade
{{-- This works because $query is #[Unlocked] --}}
<input wire:model="query" type="text" placeholder="Search...">

{{-- These are display-only, protected from tampering --}}
<p>{{ $totalCount }} results found</p>
```

### Unlocking an entire component

If a component needs all properties writable, apply `#[Unlocked]` at the class level:

```php
use WireElements\LivewireStrict\Attributes\Unlocked;

#[Unlocked]
class ContactForm extends Component
{
public string $name = ''; // ✅ Unlocked
public string $email = ''; // ✅ Unlocked
public string $message = ''; // ✅ Unlocked
}
```

### Server-side updates still work

Locked properties can still be modified by server-side code. Only frontend updates are blocked.

```php
class Counter extends Component
{
public int $count = 0; // 🔒 Locked from frontend

public function increment()
{
$this->count++; // ✅ This works - server-side update
}
}
```

## Scoping

```php
// Only components under App\Livewire\Admin
LivewireStrict::lockProperties(components: 'App\Livewire\Admin\*');

// Only a specific component
LivewireStrict::lockProperties(components: App\Livewire\Checkout::class);
```
172 changes: 172 additions & 0 deletions docs/signed-actions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
# Signed Actions

Makes Livewire action calls tamper-proof by signing the method name, parameters, and component instance with HMAC-SHA256. Prevents attackers from modifying action parameters in the DOM.

## The Problem

When you write `wire:click="delete({{ $post->id }})"`, Livewire renders the method call directly in the HTML. An attacker can use browser DevTools to change the argument before clicking:

```html
<!-- Original -->
<button wire:click="delete(5)">Delete</button>

<!-- Attacker changes it to -->
<button wire:click="delete(999)">Delete</button>
```

The server has no way to know the parameter was tampered with.

## Setup

```php
use WireElements\LivewireStrict\LivewireStrict;

// In your AppServiceProvider::boot()
LivewireStrict::signedActions();

// With expiration (recommended)
LivewireStrict::signedActions(ttl: 300); // Payloads expire after 5 minutes
```

## Usage

### 1. Mark sensitive methods with `#[Signed]`

```php
use WireElements\LivewireStrict\Attributes\Signed;

class PostList extends Component
{
#[Signed]
public function delete(int $postId)
{
Post::findOrFail($postId)->delete();
}

#[Signed]
public function updateStatus(int $postId, string $status)
{
Post::findOrFail($postId)->update(['status' => $status]);
}

// Regular methods don't need signing
public function loadMore()
{
$this->page++;
}
}
```

### 2. Use `@livewireAction` in Blade

Replace inline method calls with the `@livewireAction` directive:

```blade
{{-- Instead of this (tamperable): --}}
<button wire:click="delete({{ $post->id }})">Delete</button>

{{-- Use this (tamper-proof): --}}
<button wire:click="@livewireAction('delete', $post->id)">Delete</button>

{{-- Multiple parameters work too: --}}
<button wire:click="@livewireAction('updateStatus', $post->id, 'published')">
Publish
</button>
```

## How It Works

1. **At render time**, `@livewireAction` generates an HMAC-SHA256 signature over the method name, parameters, and component ID using a purpose-specific key derived from your `APP_KEY` (domain-separated so that other subsystems sharing the same key cannot produce cross-valid signatures)
2. The signed payload is encoded as a base64 string and rendered as `__callSigned('eyJ...')`
3. **When clicked**, the `SupportSignedActions` hook intercepts the call and verifies in sequence: payload structure → field types → HMAC signature → expiry (if TTL is set) → component ID match. Only then is the method executed
4. Direct calls to `#[Signed]` methods (e.g., `$wire.call('delete', 5)`) are **blocked**

### What's protected

| Attack | Result |
|--------|--------|
| Change parameters in DOM | ❌ HMAC verification fails |
| Call signed method directly via JS | ❌ Blocked - must use signed payload |
| Call `__callSigned` with no payload | ❌ Blocked - payload parameter required |
| Replay payload on different component | ❌ Component ID mismatch |
| Tamper with expiration timestamp | ❌ HMAC verification fails |
| Use expired payload | ❌ `ExpiredSignedActionException` thrown |

> **Note:** Valid payloads can be replayed on the same component (e.g., clicking a button multiple times). This is intentional - Blade buttons render a fixed payload that must remain usable. Use TTL to limit the replay window.

### Requirements

- Signed actions require a valid `APP_KEY` to be configured. If the key is missing, a `RuntimeException` is thrown immediately when encoding or verifying a payload.
- Components must **not** define their own `__callSigned()` method - this name is reserved by the signed-action hook. If a collision is detected, a `LogicException` is thrown.

## Payload Expiration

Set a TTL to limit how long signed payloads remain valid:

```php
// Payloads expire after 5 minutes
LivewireStrict::signedActions(ttl: 300);

// No expiration (default)
LivewireStrict::signedActions();

// Explicitly no expiration (0 is treated the same as null)
LivewireStrict::signedActions(ttl: 0);
```

With a TTL, payloads include a signed timestamp. After expiration, the action is rejected with an `ExpiredSignedActionException`. The timestamp is part of the HMAC, so attackers cannot extend it.

### Per-method TTL

You can override the global TTL on individual methods using the `ttl` parameter on `#[Signed]`:

```php
use WireElements\LivewireStrict\Attributes\Signed;

class OrderManager extends Component
{
// Uses the global TTL
#[Signed]
public function archive(int $orderId) { ... }

// Stricter: expires after 30 seconds
#[Signed(ttl: 30)]
public function refund(int $orderId, int $amount) { ... }

// No expiration, even if global TTL is set
#[Signed(ttl: 0)]
public function viewDetails(int $orderId) { ... }
}
```

Per-method TTL takes precedence over the global TTL. If a method has no `ttl` parameter, the global TTL is used.

Negative TTL values are rejected with an `InvalidArgumentException`, both at the global level and per-method level.

**Choosing a TTL:** Consider how long a page stays open before a user interacts. For admin panels, 5-15 minutes is reasonable. For long-lived dashboards, use a longer TTL or disable expiration.

## Scoping

```php
// Only admin components
LivewireStrict::signedActions(components: 'App\Livewire\Admin\*');

// Specific component with 10-minute expiry
LivewireStrict::signedActions(
components: App\Livewire\PostList::class,
ttl: 600
);
```

## When to Use Signed Actions

**Use `#[Signed]` for methods where the parameters are security-sensitive:**
- Deleting records: `delete($id)`
- Changing roles/permissions: `updateRole($userId, $role)`
- Financial operations: `refund($orderId, $amount)`
- Any action where a tampered parameter leads to unauthorized behavior

**You don't need `#[Signed]` for (but it still works if you do):**
- Methods with no parameters: `loadMore()`, `refresh()`
- Methods where parameters come from locked server-side state
- Methods that re-validate authorization internally regardless of input
Loading