-
-
Notifications
You must be signed in to change notification settings - Fork 2
Add signed actions; Livewire v4 Support and Documentation #3
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 5 commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
dc46f20
Add support for Livewire v4
glasssshouse 9ee77c8
Added script to run tests
glasssshouse d4caf6b
feat: add signed actions to prevent action parameter tampering
glasssshouse 74f921c
docs: add documentation for locked properties and signed actions
glasssshouse 9c95bc2
fix: address review feedback and add per-method TTL support
glasssshouse b2f4909
fix: resolve ttl:0 bug, extend Signed attribute, and reduce duplication
glasssshouse 05a2035
fix: consolidate TTL handling and reject negative values
glasssshouse acfa513
refactor: rewrite SupportSignedActions for consistency
glasssshouse 58195e9
Merge pull request #1 from wire-elements/main
glasssshouse 31073d2
fix: validate payload field types in SignedPayload::verify()
glasssshouse 88ee196
security: add domain-separated key derivation for signed actions
glasssshouse 2fa3814
refactor: improve SignedPayload structure and harden edge cases
glasssshouse File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| # 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+ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| ``` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,159 @@ | ||
| # 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 | | ||
|
|
||
| ## 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(); | ||
| ``` | ||
|
|
||
| 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:** | ||
| - Methods with no parameters: `loadMore()`, `refresh()` | ||
| - Methods where parameters come from locked server-side state | ||
| - Methods that re-validate authorization internally regardless of input |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| <?php | ||
|
|
||
| namespace WireElements\LivewireStrict\Attributes; | ||
|
|
||
| #[\Attribute(\Attribute::TARGET_METHOD)] | ||
| class Signed | ||
| { | ||
| public function __construct( | ||
| public ?int $ttl = null, | ||
| ) {} | ||
| } | ||
15 changes: 15 additions & 0 deletions
15
src/Features/SupportSignedActions/ExpiredSignedActionException.php
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| <?php | ||
|
|
||
| namespace WireElements\LivewireStrict\Features\SupportSignedActions; | ||
|
|
||
| class ExpiredSignedActionException extends \Exception | ||
| { | ||
| public function __construct(string $method = '') | ||
| { | ||
| parent::__construct( | ||
| $method | ||
| ? "Signed action [{$method}] has expired." | ||
| : 'Signed action has expired.' | ||
| ); | ||
| } | ||
| } |
15 changes: 15 additions & 0 deletions
15
src/Features/SupportSignedActions/InvalidSignedActionException.php
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| <?php | ||
|
|
||
| namespace WireElements\LivewireStrict\Features\SupportSignedActions; | ||
|
|
||
| class InvalidSignedActionException extends \Exception | ||
| { | ||
| public function __construct(string $method = '') | ||
| { | ||
| parent::__construct( | ||
| $method | ||
| ? "Cannot call signed action: [{$method}]. The signature is invalid or missing." | ||
| : 'Cannot call signed action. The payload is invalid.' | ||
| ); | ||
| } | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.