Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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);
```
168 changes: 168 additions & 0 deletions docs/signed-actions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
# 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 your `APP_KEY`
2. The signed payload is encoded as a base64 string and rendered as `__callSigned('eyJ...')`
3. **When clicked**, the `SupportSignedActions` hook intercepts the call, verifies the HMAC, checks the component ID matches, and only then executes the method
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 |
| 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.

## 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.

**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