From dc46f205fe4c422c3966c50c71b7474188adc0ae Mon Sep 17 00:00:00 2001 From: h4or Date: Fri, 13 Feb 2026 21:47:40 +0100 Subject: [PATCH 01/11] Add support for Livewire v4 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index b54a884..f9be60e 100644 --- a/composer.json +++ b/composer.json @@ -34,6 +34,6 @@ "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" } } From 9ee77c82ed489fa826cb115414fd5dcc4799c4b8 Mon Sep 17 00:00:00 2001 From: h4or Date: Fri, 13 Feb 2026 22:13:22 +0100 Subject: [PATCH 02/11] Added script to run tests --- composer.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/composer.json b/composer.json index f9be60e..e89da91 100644 --- a/composer.json +++ b/composer.json @@ -30,6 +30,9 @@ }, "minimum-stability": "dev", "prefer-stable": true, + "scripts": { + "test": "vendor/bin/phpunit" + }, "require-dev": { "phpunit/phpunit": "^10.4", "laravel/framework": "^10.15.0|^11.0", From d4caf6b4edf4c7d080539299b28402a270ae7b37 Mon Sep 17 00:00:00 2001 From: h4or Date: Fri, 13 Feb 2026 22:24:05 +0100 Subject: [PATCH 03/11] feat: add signed actions to prevent action parameter tampering Introduces #[Signed] attribute for Livewire component methods that makes action calls tamper-proof using HMAC-SHA256 signatures. When a method is marked as #[Signed], direct frontend calls are blocked. Actions must go through the @livewireAction Blade directive, which signs the method name, parameters, and component ID with the app key. --- src/Attributes/Signed.php | 8 + .../ExpiredSignedActionException.php | 15 + .../InvalidSignedActionException.php | 15 + .../SupportSignedActions.php | 158 ++++++++ .../SupportSignedActions/UnitTest.php | 338 ++++++++++++++++++ src/LivewireStrict.php | 9 + src/LivewireStrictServiceProvider.php | 14 +- 7 files changed, 556 insertions(+), 1 deletion(-) create mode 100644 src/Attributes/Signed.php create mode 100644 src/Features/SupportSignedActions/ExpiredSignedActionException.php create mode 100644 src/Features/SupportSignedActions/InvalidSignedActionException.php create mode 100644 src/Features/SupportSignedActions/SupportSignedActions.php create mode 100644 src/Features/SupportSignedActions/UnitTest.php diff --git a/src/Attributes/Signed.php b/src/Attributes/Signed.php new file mode 100644 index 0000000..48755a3 --- /dev/null +++ b/src/Attributes/Signed.php @@ -0,0 +1,8 @@ +checkIsRequired()) { + return; + } + + // Handle signed action calls + if ($method === '__callSigned') { + // Guard: ensure the component doesn't have an actual __callSigned method + if (method_exists($this->component, '__callSigned')) { + return; + } + + $decoded = $this->verifyAndDecode($params[0]); + $result = $this->component->{$decoded['method']}(...$decoded['params']); + $returnEarly($result); + + return; + } + + // Block direct calls to #[Signed] methods + if ($this->methodIsSigned($method)) { + throw new InvalidSignedActionException($method); + } + } + + protected function checkIsRequired(): bool + { + foreach (self::$components as $component) { + if (str($component)->contains('*') && str($this->component::class)->is($component)) { + return true; + } + + if ($component === $this->component::class) { + return true; + } + } + + return false; + } + + protected function methodIsSigned(string $method): bool + { + if (! method_exists($this->component, $method)) { + return false; + } + + $reflection = new \ReflectionMethod($this->component, $method); + + return ! empty($reflection->getAttributes(Signed::class)); + } + + protected function verifyAndDecode(string $encodedPayload): array + { + $decoded = json_decode(base64_decode($encodedPayload, true), true); + + if (! $decoded || ! isset($decoded['sig'], $decoded['method'], $decoded['params'], $decoded['id'])) { + throw new InvalidSignedActionException; + } + + // Build the same payload structure used during signing + $payloadData = [ + 'id' => $decoded['id'], + 'method' => $decoded['method'], + 'params' => $decoded['params'], + ]; + + // Include expiry in HMAC if it was part of the signed payload + if (isset($decoded['exp'])) { + $payloadData['exp'] = $decoded['exp']; + } + + $payload = json_encode($payloadData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + + $expectedSig = hash_hmac('sha256', $payload, config('app.key')); + + if (! hash_equals($expectedSig, $decoded['sig'])) { + throw new InvalidSignedActionException($decoded['method']); + } + + // Verify payload has not expired + if (isset($decoded['exp']) && Carbon::now()->timestamp > $decoded['exp']) { + throw new ExpiredSignedActionException($decoded['method']); + } + + // Verify component ID matches + if ($decoded['id'] !== $this->component->getId()) { + throw new InvalidSignedActionException($decoded['method']); + } + + // Verify target method requires signing + if (! $this->methodIsSigned($decoded['method'])) { + throw new InvalidSignedActionException($decoded['method']); + } + + return $decoded; + } + + /** + * Generate a signed payload string for use in testing or programmatic calls. + * + * @param ?int $ttl Override the default TTL in seconds. Null uses the static $ttl. + */ + public static function generateSignedPayload(string $componentId, string $method, mixed ...$params): string + { + $payloadData = [ + 'id' => $componentId, + 'method' => $method, + 'params' => $params, + ]; + + if (static::$ttl !== null) { + $payloadData['exp'] = Carbon::now()->timestamp + static::$ttl; + } + + $payload = json_encode($payloadData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + + $signature = hash_hmac('sha256', $payload, config('app.key')); + + return base64_encode(json_encode(array_merge($payloadData, [ + 'sig' => $signature, + ]))); + } + + /** + * Generate a signed action string for use in Blade templates. + * Used by the @livewireAction Blade directive. + */ + public static function generateSignedAction(string $componentId, string $method, mixed ...$params): string + { + $payload = self::generateSignedPayload($componentId, $method, ...$params); + + return "__callSigned('{$payload}')"; + } +} diff --git a/src/Features/SupportSignedActions/UnitTest.php b/src/Features/SupportSignedActions/UnitTest.php new file mode 100644 index 0000000..19c8983 --- /dev/null +++ b/src/Features/SupportSignedActions/UnitTest.php @@ -0,0 +1,338 @@ +expectException(InvalidSignedActionException::class); + $this->expectExceptionMessage('Cannot call signed action: [delete]'); + + LivewireStrict::signedActions(components: 'WireElements\*'); + + Livewire::test(new class extends TestSignedComponent + { + #[Signed] + public function delete(int $id) + { + $this->result = $id; + } + }) + ->call('delete', 5); + } + + public function test_can_call_signed_method_with_valid_signature() + { + LivewireStrict::signedActions(components: 'WireElements\*'); + + $component = Livewire::test(new class extends TestSignedComponent + { + #[Signed] + public function delete(int $id) + { + $this->result = $id; + } + }); + + $payload = SupportSignedActions::generateSignedPayload( + $component->instance()->getId(), + 'delete', + 5 + ); + + $component->call('__callSigned', $payload) + ->assertSet('result', 5); + } + + public function test_cant_call_signed_method_with_tampered_params() + { + $this->expectException(InvalidSignedActionException::class); + + LivewireStrict::signedActions(components: 'WireElements\*'); + + $component = Livewire::test(new class extends TestSignedComponent + { + #[Signed] + public function delete(int $id) + { + $this->result = $id; + } + }); + + // Generate valid payload then tamper with params + $validPayload = SupportSignedActions::generateSignedPayload( + $component->instance()->getId(), + 'delete', + 5 + ); + + $decoded = json_decode(base64_decode($validPayload), true); + $decoded['params'] = [999]; + $tamperedPayload = base64_encode(json_encode($decoded)); + + $component->call('__callSigned', $tamperedPayload); + } + + public function test_cant_call_signed_method_with_wrong_component_id() + { + $this->expectException(InvalidSignedActionException::class); + + LivewireStrict::signedActions(components: 'WireElements\*'); + + $component = Livewire::test(new class extends TestSignedComponent + { + #[Signed] + public function delete(int $id) + { + $this->result = $id; + } + }); + + $payload = SupportSignedActions::generateSignedPayload( + 'wrong-component-id', + 'delete', + 5 + ); + + $component->call('__callSigned', $payload); + } + + public function test_can_call_non_signed_method_when_feature_enabled() + { + LivewireStrict::signedActions(components: 'WireElements\*'); + + Livewire::test(new class extends TestSignedComponent + { + public function regularMethod() + { + $this->result = 'regular'; + } + }) + ->call('regularMethod') + ->assertSet('result', 'regular'); + } + + public function test_signed_methods_work_when_feature_disabled() + { + Livewire::test(new class extends TestSignedComponent + { + #[Signed] + public function delete(int $id) + { + $this->result = $id; + } + }) + ->call('delete', 5) + ->assertSet('result', 5); + } + + public function test_only_enabled_for_matching_namespace() + { + $this->expectException(InvalidSignedActionException::class); + + LivewireStrict::signedActions(components: 'WireElements\*'); + + Livewire::test(new class extends SpecificSignedComponent + { + #[Signed] + public function delete(int $id) + { + $this->result = $id; + } + }) + ->call('delete', 5); + } + + public function test_it_ignores_other_namespaces() + { + LivewireStrict::signedActions(components: 'App\*'); + + Livewire::test(new class extends TestSignedComponent + { + #[Signed] + public function delete(int $id) + { + $this->result = $id; + } + }) + ->call('delete', 5) + ->assertSet('result', 5); + } + + public function test_rejects_malformed_payload() + { + $this->expectException(InvalidSignedActionException::class); + $this->expectExceptionMessage('Cannot call signed action. The payload is invalid.'); + + LivewireStrict::signedActions(components: 'WireElements\*'); + + Livewire::test(new class extends TestSignedComponent + { + #[Signed] + public function delete(int $id) + { + $this->result = $id; + } + }) + ->call('__callSigned', 'not-valid-base64-garbage'); + } + + public function test_rejects_signed_payload_on_component_without_signed_methods() + { + $this->expectException(InvalidSignedActionException::class); + + LivewireStrict::signedActions(components: 'WireElements\*'); + + $component = Livewire::test(new class extends TestSignedComponent + { + public function regularMethod() + { + $this->result = 'should not run'; + } + }); + + // Craft a valid-signature payload targeting a non-signed method + $payload = SupportSignedActions::generateSignedPayload( + $component->instance()->getId(), + 'regularMethod' + ); + + $component->call('__callSigned', $payload); + } + + public function test_valid_payload_with_ttl_succeeds_before_expiry() + { + LivewireStrict::signedActions(components: 'WireElements\*', ttl: 300); + + $component = Livewire::test(new class extends TestSignedComponent + { + #[Signed] + public function delete(int $id) + { + $this->result = $id; + } + }); + + $payload = SupportSignedActions::generateSignedPayload( + $component->instance()->getId(), + 'delete', + 5 + ); + + $component->call('__callSigned', $payload) + ->assertSet('result', 5); + } + + public function test_expired_payload_is_rejected() + { + $this->expectException(ExpiredSignedActionException::class); + $this->expectExceptionMessage('Signed action [delete] has expired.'); + + LivewireStrict::signedActions(components: 'WireElements\*', ttl: 300); + + $component = Livewire::test(new class extends TestSignedComponent + { + #[Signed] + public function delete(int $id) + { + $this->result = $id; + } + }); + + $payload = SupportSignedActions::generateSignedPayload( + $component->instance()->getId(), + 'delete', + 5 + ); + + // Travel forward in time past the TTL + $this->travel(301)->seconds(); + + $component->call('__callSigned', $payload); + } + + public function test_payload_without_ttl_does_not_expire() + { + LivewireStrict::signedActions(components: 'WireElements\*'); + + $component = Livewire::test(new class extends TestSignedComponent + { + #[Signed] + public function delete(int $id) + { + $this->result = $id; + } + }); + + $payload = SupportSignedActions::generateSignedPayload( + $component->instance()->getId(), + 'delete', + 5 + ); + + // Travel far forward — no TTL means no expiration + $this->travel(7)->days(); + + $component->call('__callSigned', $payload) + ->assertSet('result', 5); + } + + public function test_tampered_expiry_is_rejected() + { + $this->expectException(InvalidSignedActionException::class); + + LivewireStrict::signedActions(components: 'WireElements\*', ttl: 60); + + $component = Livewire::test(new class extends TestSignedComponent + { + #[Signed] + public function delete(int $id) + { + $this->result = $id; + } + }); + + $payload = SupportSignedActions::generateSignedPayload( + $component->instance()->getId(), + 'delete', + 5 + ); + + // Tamper with expiry to extend it + $decoded = json_decode(base64_decode($payload), true); + $decoded['exp'] = time() + 99999; + $tamperedPayload = base64_encode(json_encode($decoded)); + + $this->travel(120)->seconds(); + + $component->call('__callSigned', $tamperedPayload); + } +} + +class TestSignedComponent extends Component +{ + public $result = null; + + public function render() + { + return '
'; + } +} + +class SpecificSignedComponent extends TestSignedComponent +{ +} diff --git a/src/LivewireStrict.php b/src/LivewireStrict.php index 3f9e79e..fdf442c 100644 --- a/src/LivewireStrict.php +++ b/src/LivewireStrict.php @@ -4,6 +4,7 @@ use Illuminate\Support\Arr; use WireElements\LivewireStrict\Features\SupportLockedProperties\SupportLockedProperties; +use WireElements\LivewireStrict\Features\SupportSignedActions\SupportSignedActions; class LivewireStrict { @@ -13,6 +14,13 @@ public static function lockProperties($shouldLockProperties = true, $components SupportLockedProperties::$components = Arr::wrap($components); } + public static function signedActions($shouldSignActions = true, $components = ['App\Livewire\*'], $ttl = null) + { + SupportSignedActions::$enabled = $shouldSignActions; + SupportSignedActions::$components = Arr::wrap($components); + SupportSignedActions::$ttl = $ttl; + } + public static function enableAll($condition = true) { if (! $condition) { @@ -20,5 +28,6 @@ public static function enableAll($condition = true) } self::lockProperties(); + self::signedActions(); } } diff --git a/src/LivewireStrictServiceProvider.php b/src/LivewireStrictServiceProvider.php index 5923ccf..8f64d48 100644 --- a/src/LivewireStrictServiceProvider.php +++ b/src/LivewireStrictServiceProvider.php @@ -2,15 +2,27 @@ namespace WireElements\LivewireStrict; +use Illuminate\Support\Facades\Blade; use Illuminate\Support\ServiceProvider; class LivewireStrictServiceProvider extends ServiceProvider { /** - * Bootstrap any application services. + * Register any application services. */ public function register(): void { app('livewire')->componentHook(Features\SupportLockedProperties\SupportLockedProperties::class); + app('livewire')->componentHook(Features\SupportSignedActions\SupportSignedActions::class); + } + + /** + * Bootstrap any application services. + */ + public function boot(): void + { + Blade::directive('livewireAction', function ($expression) { + return "getId(), $expression); ?>"; + }); } } From 74f921ce06a8f5d9669373622868e4ff5e6c499f Mon Sep 17 00:00:00 2001 From: h4or Date: Fri, 13 Feb 2026 22:24:23 +0100 Subject: [PATCH 04/11] docs: add documentation for locked properties and signed actions --- docs/README.md | 79 ++++++++++++++++++++++ docs/locked-properties.md | 98 ++++++++++++++++++++++++++++ docs/signed-actions.md | 134 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 311 insertions(+) create mode 100644 docs/README.md create mode 100644 docs/locked-properties.md create mode 100644 docs/signed-actions.md diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..6ed2a41 --- /dev/null +++ b/docs/README.md @@ -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+ diff --git a/docs/locked-properties.md b/docs/locked-properties.md new file mode 100644 index 0000000..a11ff72 --- /dev/null +++ b/docs/locked-properties.md @@ -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] --}} + + +{{-- These are display-only, protected from tampering --}} +

{{ $totalCount }} results found

+``` + +### 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); +``` diff --git a/docs/signed-actions.md b/docs/signed-actions.md new file mode 100644 index 0000000..ac546a3 --- /dev/null +++ b/docs/signed-actions.md @@ -0,0 +1,134 @@ +# 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 + + + + + +``` + +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): --}} + + +{{-- Use this (tamper-proof): --}} + + +{{-- Multiple parameters work too: --}} + +``` + +## 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. + +**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 From 9c95bc2db38c792d750cebccc701b745a9cf6cd8 Mon Sep 17 00:00:00 2001 From: h4or Date: Fri, 13 Feb 2026 23:06:39 +0100 Subject: [PATCH 05/11] fix: address review feedback and add per-method TTL support Fixes: - methodIsSigned() now requires isPublic() and !isStatic() to prevent fatal access errors when non-public methods are marked #[Signed] - Added guard for missing/invalid $params[0] in __callSigned to return a controlled exception instead of a 500 error - __callSigned method name collision now throws a clear LogicException instead of silently returning, which would leave signed methods uncallable on the affected component - Removed stale @param docblock referencing a $ttl parameter that did not exist on generateSignedPayload() New: - #[Signed] attribute now accepts an optional ttl parameter for per-method expiration, e.g. #[Signed(ttl: 60)] - Per-method TTL takes precedence over the global TTL set in LivewireStrict::signedActions(ttl: 300) - @livewireAction Blade directive automatically resolves the correct TTL from the component's method attributes - 3 new tests for per-method TTL (override, within window, fallback) - Updated signed-actions.md with per-method TTL documentation --- docs/signed-actions.md | 25 ++++++ src/Attributes/Signed.php | 3 + .../SupportSignedActions.php | 59 +++++++++++-- .../SupportSignedActions/UnitTest.php | 84 +++++++++++++++++++ src/LivewireStrictServiceProvider.php | 2 +- 5 files changed, 166 insertions(+), 7 deletions(-) diff --git a/docs/signed-actions.md b/docs/signed-actions.md index ac546a3..78e08ce 100644 --- a/docs/signed-actions.md +++ b/docs/signed-actions.md @@ -105,6 +105,31 @@ 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 diff --git a/src/Attributes/Signed.php b/src/Attributes/Signed.php index 48755a3..227e667 100644 --- a/src/Attributes/Signed.php +++ b/src/Attributes/Signed.php @@ -5,4 +5,7 @@ #[\Attribute(\Attribute::TARGET_METHOD)] class Signed { + public function __construct( + public ?int $ttl = null, + ) {} } diff --git a/src/Features/SupportSignedActions/SupportSignedActions.php b/src/Features/SupportSignedActions/SupportSignedActions.php index 645572d..8ee5532 100644 --- a/src/Features/SupportSignedActions/SupportSignedActions.php +++ b/src/Features/SupportSignedActions/SupportSignedActions.php @@ -31,7 +31,13 @@ public function call($method, $params, $returnEarly, $metadata, $componentContex if ($method === '__callSigned') { // Guard: ensure the component doesn't have an actual __callSigned method if (method_exists($this->component, '__callSigned')) { - return; + throw new \LogicException( + 'Component [' . $this->component::class . '] defines a __callSigned method, which collides with the internal signed-action hook.' + ); + } + + if (! isset($params[0]) || ! is_string($params[0])) { + throw new InvalidSignedActionException('__callSigned'); } $decoded = $this->verifyAndDecode($params[0]); @@ -70,7 +76,30 @@ protected function methodIsSigned(string $method): bool $reflection = new \ReflectionMethod($this->component, $method); - return ! empty($reflection->getAttributes(Signed::class)); + return $reflection->isPublic() && ! $reflection->isStatic() && ! empty($reflection->getAttributes(Signed::class)); + } + + /** + * Get the TTL for a specific method. Per-method TTL overrides the global TTL. + */ + public static function getMethodTtl(object $component, string $method): ?int + { + if (! method_exists($component, $method)) { + return static::$ttl; + } + + $reflection = new \ReflectionMethod($component, $method); + $attributes = $reflection->getAttributes(Signed::class); + + if (! empty($attributes)) { + $signed = $attributes[0]->newInstance(); + + if ($signed->ttl !== null) { + return $signed->ttl; + } + } + + return static::$ttl; } protected function verifyAndDecode(string $encodedPayload): array @@ -121,10 +150,16 @@ protected function verifyAndDecode(string $encodedPayload): array /** * Generate a signed payload string for use in testing or programmatic calls. - * - * @param ?int $ttl Override the default TTL in seconds. Null uses the static $ttl. */ public static function generateSignedPayload(string $componentId, string $method, mixed ...$params): string + { + return static::generateSignedPayloadWithTtl(static::$ttl, $componentId, $method, ...$params); + } + + /** + * Generate a signed payload with an explicit TTL. + */ + public static function generateSignedPayloadWithTtl(?int $ttl, string $componentId, string $method, mixed ...$params): string { $payloadData = [ 'id' => $componentId, @@ -132,8 +167,8 @@ public static function generateSignedPayload(string $componentId, string $method 'params' => $params, ]; - if (static::$ttl !== null) { - $payloadData['exp'] = Carbon::now()->timestamp + static::$ttl; + if ($ttl !== null) { + $payloadData['exp'] = Carbon::now()->timestamp + $ttl; } $payload = json_encode($payloadData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); @@ -155,4 +190,16 @@ public static function generateSignedAction(string $componentId, string $method, return "__callSigned('{$payload}')"; } + + /** + * Generate a signed action string with per-method TTL resolution. + * Used internally when the component instance is available. + */ + public static function generateSignedActionForComponent(object $component, string $method, mixed ...$params): string + { + $ttl = static::getMethodTtl($component, $method); + $payload = static::generateSignedPayloadWithTtl($ttl, $component->getId(), $method, ...$params); + + return "__callSigned('{$payload}')"; + } } diff --git a/src/Features/SupportSignedActions/UnitTest.php b/src/Features/SupportSignedActions/UnitTest.php index 19c8983..f37f89a 100644 --- a/src/Features/SupportSignedActions/UnitTest.php +++ b/src/Features/SupportSignedActions/UnitTest.php @@ -321,6 +321,90 @@ public function delete(int $id) $component->call('__callSigned', $tamperedPayload); } + + public function test_per_method_ttl_overrides_global_ttl() + { + LivewireStrict::signedActions(components: 'WireElements\*', ttl: 300); + + $component = Livewire::test(new class extends TestSignedComponent + { + #[Signed(ttl: 60)] + public function delete(int $id) + { + $this->result = $id; + } + }); + + $ttl = SupportSignedActions::getMethodTtl($component->instance(), 'delete'); + $payload = SupportSignedActions::generateSignedPayloadWithTtl( + $ttl, + $component->instance()->getId(), + 'delete', + 5 + ); + + // 61 seconds - past per-method TTL of 60, but within global TTL of 300 + $this->travel(61)->seconds(); + + $this->expectException(ExpiredSignedActionException::class); + $component->call('__callSigned', $payload); + } + + public function test_per_method_ttl_succeeds_within_window() + { + LivewireStrict::signedActions(components: 'WireElements\*', ttl: 300); + + $component = Livewire::test(new class extends TestSignedComponent + { + #[Signed(ttl: 60)] + public function delete(int $id) + { + $this->result = $id; + } + }); + + $ttl = SupportSignedActions::getMethodTtl($component->instance(), 'delete'); + $payload = SupportSignedActions::generateSignedPayloadWithTtl( + $ttl, + $component->instance()->getId(), + 'delete', + 5 + ); + + // 30 seconds - within per-method TTL of 60 + $this->travel(30)->seconds(); + + $component->call('__callSigned', $payload) + ->assertSet('result', 5); + } + + public function test_method_without_per_method_ttl_uses_global() + { + LivewireStrict::signedActions(components: 'WireElements\*', ttl: 120); + + $component = Livewire::test(new class extends TestSignedComponent + { + #[Signed] + public function delete(int $id) + { + $this->result = $id; + } + }); + + $ttl = SupportSignedActions::getMethodTtl($component->instance(), 'delete'); + $payload = SupportSignedActions::generateSignedPayloadWithTtl( + $ttl, + $component->instance()->getId(), + 'delete', + 5 + ); + + // 121 seconds - past global TTL of 120 + $this->travel(121)->seconds(); + + $this->expectException(ExpiredSignedActionException::class); + $component->call('__callSigned', $payload); + } } class TestSignedComponent extends Component diff --git a/src/LivewireStrictServiceProvider.php b/src/LivewireStrictServiceProvider.php index 8f64d48..4894f76 100644 --- a/src/LivewireStrictServiceProvider.php +++ b/src/LivewireStrictServiceProvider.php @@ -22,7 +22,7 @@ public function register(): void public function boot(): void { Blade::directive('livewireAction', function ($expression) { - return "getId(), $expression); ?>"; + return ""; }); } } From b2f490997ff38c2b3a8dd0440b01cd012662b2f5 Mon Sep 17 00:00:00 2001 From: h4or Date: Sat, 14 Feb 2026 00:41:12 +0100 Subject: [PATCH 06/11] fix: resolve ttl:0 bug, extend Signed attribute, and reduce duplication - Fix #[Signed(ttl: 0)] creating an immediately expired payload instead of disabling expiration as documented. getMethodTtl() now returns null when ttl is explicitly set to 0, meaning "no expiration even if global TTL is set". - Make Signed attribute extend Livewire\Features\SupportAttributes\Attribute for consistency with the Unlocked attribute, ensuring it is accessible through Livewire's attribute system ($component->getAttributes()). - Extract duplicated checkIsRequired() logic from SupportSignedActions and SupportLockedProperties into a shared MatchesComponents trait. - Add test for #[Signed(ttl: 0)] verifying it bypasses expiration even with a global TTL configured. - Add test for signed methods with no parameters to verify they work correctly through signed payloads. - Clarify docs that #[Signed] works on no-parameter methods even though it is not required for them. --- docs/signed-actions.md | 2 +- src/Attributes/Signed.php | 4 +- src/Features/Concerns/MatchesComponents.php | 21 ++++++++ .../SupportLockedProperties.php | 17 ++---- .../SupportSignedActions.php | 21 ++------ .../SupportSignedActions/UnitTest.php | 52 +++++++++++++++++++ 6 files changed, 86 insertions(+), 31 deletions(-) create mode 100644 src/Features/Concerns/MatchesComponents.php diff --git a/docs/signed-actions.md b/docs/signed-actions.md index 78e08ce..cbdb6b8 100644 --- a/docs/signed-actions.md +++ b/docs/signed-actions.md @@ -153,7 +153,7 @@ LivewireStrict::signedActions( - Financial operations: `refund($orderId, $amount)` - Any action where a tampered parameter leads to unauthorized behavior -**You don't need `#[Signed]` for:** +**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 diff --git a/src/Attributes/Signed.php b/src/Attributes/Signed.php index 227e667..24e07df 100644 --- a/src/Attributes/Signed.php +++ b/src/Attributes/Signed.php @@ -2,8 +2,10 @@ namespace WireElements\LivewireStrict\Attributes; +use Livewire\Features\SupportAttributes\Attribute; + #[\Attribute(\Attribute::TARGET_METHOD)] -class Signed +class Signed extends Attribute { public function __construct( public ?int $ttl = null, diff --git a/src/Features/Concerns/MatchesComponents.php b/src/Features/Concerns/MatchesComponents.php new file mode 100644 index 0000000..34d964f --- /dev/null +++ b/src/Features/Concerns/MatchesComponents.php @@ -0,0 +1,21 @@ +contains('*') && str($this->component::class)->is($component)) { + return true; + } + + if ($component === $this->component::class) { + return true; + } + } + + return false; + } +} diff --git a/src/Features/SupportLockedProperties/SupportLockedProperties.php b/src/Features/SupportLockedProperties/SupportLockedProperties.php index 3a43894..e68515a 100644 --- a/src/Features/SupportLockedProperties/SupportLockedProperties.php +++ b/src/Features/SupportLockedProperties/SupportLockedProperties.php @@ -6,9 +6,12 @@ use Livewire\Features\SupportAttributes\AttributeLevel; use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException; use WireElements\LivewireStrict\Attributes\Unlocked; +use WireElements\LivewireStrict\Features\Concerns\MatchesComponents; class SupportLockedProperties extends ComponentHook { + use MatchesComponents; + public static bool $locked = false; public static array $components = []; @@ -19,19 +22,7 @@ public function update($propertyName, $fullPath, $newValue) return; } - $checkIsRequired = false; - - foreach (self::$components as $component) { - if (str($component)->contains('*') && str($this->component::class)->is($component)) { - $checkIsRequired = true; - } - - if ($component === $this->component::class) { - $checkIsRequired = true; - } - } - - if (! $checkIsRequired) { + if (! $this->checkIsRequired()) { return; } diff --git a/src/Features/SupportSignedActions/SupportSignedActions.php b/src/Features/SupportSignedActions/SupportSignedActions.php index 8ee5532..1d29197 100644 --- a/src/Features/SupportSignedActions/SupportSignedActions.php +++ b/src/Features/SupportSignedActions/SupportSignedActions.php @@ -5,9 +5,12 @@ use Livewire\ComponentHook; use Illuminate\Support\Carbon; use WireElements\LivewireStrict\Attributes\Signed; +use WireElements\LivewireStrict\Features\Concerns\MatchesComponents; class SupportSignedActions extends ComponentHook { + use MatchesComponents; + public static bool $enabled = false; public static array $components = []; @@ -53,21 +56,6 @@ public function call($method, $params, $returnEarly, $metadata, $componentContex } } - protected function checkIsRequired(): bool - { - foreach (self::$components as $component) { - if (str($component)->contains('*') && str($this->component::class)->is($component)) { - return true; - } - - if ($component === $this->component::class) { - return true; - } - } - - return false; - } - protected function methodIsSigned(string $method): bool { if (! method_exists($this->component, $method)) { @@ -95,7 +83,8 @@ public static function getMethodTtl(object $component, string $method): ?int $signed = $attributes[0]->newInstance(); if ($signed->ttl !== null) { - return $signed->ttl; + // ttl: 0 means "no expiration, even if global TTL is set" + return $signed->ttl === 0 ? null : $signed->ttl; } } diff --git a/src/Features/SupportSignedActions/UnitTest.php b/src/Features/SupportSignedActions/UnitTest.php index f37f89a..d5d243a 100644 --- a/src/Features/SupportSignedActions/UnitTest.php +++ b/src/Features/SupportSignedActions/UnitTest.php @@ -405,6 +405,58 @@ public function delete(int $id) $this->expectException(ExpiredSignedActionException::class); $component->call('__callSigned', $payload); } + + public function test_per_method_ttl_zero_disables_expiration_even_with_global_ttl() + { + LivewireStrict::signedActions(components: 'WireElements\*', ttl: 60); + + $component = Livewire::test(new class extends TestSignedComponent + { + #[Signed(ttl: 0)] + public function delete(int $id) + { + $this->result = $id; + } + }); + + $ttl = SupportSignedActions::getMethodTtl($component->instance(), 'delete'); + $this->assertNull($ttl, 'ttl: 0 should resolve to null (no expiration)'); + + $payload = SupportSignedActions::generateSignedPayloadWithTtl( + $ttl, + $component->instance()->getId(), + 'delete', + 5 + ); + + // Travel far into the future — should still work because ttl: 0 means no expiration + $this->travel(9999)->seconds(); + + $component->call('__callSigned', $payload) + ->assertSet('result', 5); + } + + public function test_signed_method_with_no_parameters_works() + { + LivewireStrict::signedActions(components: 'WireElements\*'); + + $component = Livewire::test(new class extends TestSignedComponent + { + #[Signed] + public function archive() + { + $this->result = 'archived'; + } + }); + + $payload = SupportSignedActions::generateSignedPayload( + $component->instance()->getId(), + 'archive' + ); + + $component->call('__callSigned', $payload) + ->assertSet('result', 'archived'); + } } class TestSignedComponent extends Component From 05a2035dcfa5bfc7bff00003b508b99c301d564f Mon Sep 17 00:00:00 2001 From: h4or Date: Sat, 14 Feb 2026 00:54:20 +0100 Subject: [PATCH 07/11] fix: consolidate TTL handling and reject negative values - Fix ttl: 0 creating immediately expired payloads instead of disabling expiration. Both global (LivewireStrict::signedActions(ttl: 0)) and per-method (#[Signed(ttl: 0)]) now correctly resolve to no expiration. - Add Signed::NO_EXPIRATION constant for explicit, self-documenting opt-out of expiration instead of the magic number 0. - Extract TTL validation and normalization into a reusable NormalizesTtl trait (rejects negative values, normalizes 0 to null). Used by SupportSignedActions, the Signed attribute, and LivewireStrict facade. - Add tests: ttl: 0 per-method, ttl: 0 global, negative TTL rejection. - Update docs to use Signed::NO_EXPIRATION in examples. --- docs/signed-actions.md | 5 ++- src/Attributes/Signed.php | 12 +++++- src/Features/Concerns/NormalizesTtl.php | 19 +++++++++ .../SupportSignedActions.php | 7 +++- .../SupportSignedActions/UnitTest.php | 42 +++++++++++++++++++ src/LivewireStrict.php | 9 +++- 6 files changed, 89 insertions(+), 5 deletions(-) create mode 100644 src/Features/Concerns/NormalizesTtl.php diff --git a/docs/signed-actions.md b/docs/signed-actions.md index cbdb6b8..602274b 100644 --- a/docs/signed-actions.md +++ b/docs/signed-actions.md @@ -101,6 +101,9 @@ LivewireStrict::signedActions(ttl: 300); // No expiration (default) LivewireStrict::signedActions(); + +// Explicitly no expiration +LivewireStrict::signedActions(ttl: Signed::NO_EXPIRATION); ``` 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. @@ -123,7 +126,7 @@ class OrderManager extends Component public function refund(int $orderId, int $amount) { ... } // No expiration, even if global TTL is set - #[Signed(ttl: 0)] + #[Signed(ttl: Signed::NO_EXPIRATION)] public function viewDetails(int $orderId) { ... } } ``` diff --git a/src/Attributes/Signed.php b/src/Attributes/Signed.php index 24e07df..bbf817c 100644 --- a/src/Attributes/Signed.php +++ b/src/Attributes/Signed.php @@ -3,11 +3,21 @@ namespace WireElements\LivewireStrict\Attributes; use Livewire\Features\SupportAttributes\Attribute; +use WireElements\LivewireStrict\Features\Concerns\NormalizesTtl; #[\Attribute(\Attribute::TARGET_METHOD)] class Signed extends Attribute { + use NormalizesTtl; + + /** + * Explicitly disable expiration, even if a global TTL is set. + */ + public const NO_EXPIRATION = 0; + public function __construct( public ?int $ttl = null, - ) {} + ) { + static::normalizeTtl($ttl); + } } diff --git a/src/Features/Concerns/NormalizesTtl.php b/src/Features/Concerns/NormalizesTtl.php new file mode 100644 index 0000000..9478956 --- /dev/null +++ b/src/Features/Concerns/NormalizesTtl.php @@ -0,0 +1,19 @@ +newInstance(); if ($signed->ttl !== null) { - // ttl: 0 means "no expiration, even if global TTL is set" - return $signed->ttl === 0 ? null : $signed->ttl; + return static::normalizeTtl($signed->ttl); } } @@ -150,6 +151,8 @@ public static function generateSignedPayload(string $componentId, string $method */ public static function generateSignedPayloadWithTtl(?int $ttl, string $componentId, string $method, mixed ...$params): string { + $ttl = static::normalizeTtl($ttl); + $payloadData = [ 'id' => $componentId, 'method' => $method, diff --git a/src/Features/SupportSignedActions/UnitTest.php b/src/Features/SupportSignedActions/UnitTest.php index d5d243a..7723727 100644 --- a/src/Features/SupportSignedActions/UnitTest.php +++ b/src/Features/SupportSignedActions/UnitTest.php @@ -457,6 +457,48 @@ public function archive() $component->call('__callSigned', $payload) ->assertSet('result', 'archived'); } + + public function test_negative_global_ttl_is_rejected() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('TTL must be a non-negative integer, got: -5'); + + LivewireStrict::signedActions(components: 'WireElements\*', ttl: -5); + } + + public function test_negative_per_method_ttl_is_rejected() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('TTL must be a non-negative integer, got: -10'); + + new Signed(ttl: -10); + } + + public function test_global_ttl_zero_disables_expiration() + { + LivewireStrict::signedActions(components: 'WireElements\*', ttl: 0); + + $component = Livewire::test(new class extends TestSignedComponent + { + #[Signed] + public function delete(int $id) + { + $this->result = $id; + } + }); + + $payload = SupportSignedActions::generateSignedPayload( + $component->instance()->getId(), + 'delete', + 5 + ); + + // Travel far into the future — ttl: 0 means no expiration + $this->travel(9999)->seconds(); + + $component->call('__callSigned', $payload) + ->assertSet('result', 5); + } } class TestSignedComponent extends Component diff --git a/src/LivewireStrict.php b/src/LivewireStrict.php index fdf442c..92561c0 100644 --- a/src/LivewireStrict.php +++ b/src/LivewireStrict.php @@ -14,11 +14,18 @@ public static function lockProperties($shouldLockProperties = true, $components SupportLockedProperties::$components = Arr::wrap($components); } + /** + * Enable signed actions for the given components. + * + * @param bool $shouldSignActions + * @param string|string[] $components Component class or wildcard pattern(s). + * @param int|null $ttl Seconds until payloads expire. Use 0 or Signed::NO_EXPIRATION to disable expiration. + */ public static function signedActions($shouldSignActions = true, $components = ['App\Livewire\*'], $ttl = null) { SupportSignedActions::$enabled = $shouldSignActions; SupportSignedActions::$components = Arr::wrap($components); - SupportSignedActions::$ttl = $ttl; + SupportSignedActions::$ttl = SupportSignedActions::normalizeTtl($ttl); } public static function enableAll($condition = true) From acfa513e69cf5c11e1db09dfb9f402b3c3cc9095 Mon Sep 17 00:00:00 2001 From: h4or Date: Sat, 14 Feb 2026 03:28:36 +0100 Subject: [PATCH 08/11] refactor: rewrite SupportSignedActions for consistency - Extract SignedPayload value object from the hook (encode, verify, forComponent, toAction) - Mirror SupportLockedProperties pattern: lean ComponentHook with early-return guards - Replace raw ReflectionMethod with Livewire's getAttributes() API throughout - Centralize TTL validation and per-method resolution in the Signed attribute - Add app.key guard (RuntimeException if missing) - Move exceptions to Exceptions/ subfolder - Remove NormalizesTtl trait (inlined and simplified) - Remove Signed::NO_EXPIRATION constant (0 and null both mean no expiration) - Convert all raw if/throw to Laravel throw_if/throw_unless helpers - Add full docblocks for TTL semantics on Signed attribute and LivewireStrict facade - Add 12 new tests: multi-method TTL isolation, non-string param guard, payload replay, missing app.key, __callSigned collision, toAction round-trip, and TTL edge cases - Update docs to match code (replay note, APP_KEY requirement, TTL examples) --- docs/README.md | 1 + docs/signed-actions.md | 12 +- src/Attributes/Signed.php | 43 +- src/Features/Concerns/NormalizesTtl.php | 19 - .../ExpiredSignedActionException.php | 2 +- .../InvalidSignedActionException.php | 2 +- .../SupportSignedActions/SignedPayload.php | 116 +++++ .../SupportSignedActions.php | 187 ++----- .../SupportSignedActions/UnitTest.php | 479 +++++++++++------- src/LivewireStrict.php | 10 +- src/LivewireStrictServiceProvider.php | 2 +- 11 files changed, 500 insertions(+), 373 deletions(-) delete mode 100644 src/Features/Concerns/NormalizesTtl.php rename src/Features/SupportSignedActions/{ => Exceptions}/ExpiredSignedActionException.php (95%) rename src/Features/SupportSignedActions/{ => Exceptions}/InvalidSignedActionException.php (96%) create mode 100644 src/Features/SupportSignedActions/SignedPayload.php diff --git a/docs/README.md b/docs/README.md index 6ed2a41..1a965ec 100644 --- a/docs/README.md +++ b/docs/README.md @@ -77,3 +77,4 @@ LivewireStrict::lockProperties(components: [ - PHP 8.1+ - Laravel 10, 11, or later - Livewire 3.5+ or 4.0+ +- A valid `APP_KEY` (required for signed actions) diff --git a/docs/signed-actions.md b/docs/signed-actions.md index 602274b..77b8068 100644 --- a/docs/signed-actions.md +++ b/docs/signed-actions.md @@ -91,6 +91,12 @@ Replace inline method calls with the `@livewireAction` directive: | 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: @@ -102,8 +108,8 @@ LivewireStrict::signedActions(ttl: 300); // No expiration (default) LivewireStrict::signedActions(); -// Explicitly no expiration -LivewireStrict::signedActions(ttl: Signed::NO_EXPIRATION); +// 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. @@ -126,7 +132,7 @@ class OrderManager extends Component public function refund(int $orderId, int $amount) { ... } // No expiration, even if global TTL is set - #[Signed(ttl: Signed::NO_EXPIRATION)] + #[Signed(ttl: 0)] public function viewDetails(int $orderId) { ... } } ``` diff --git a/src/Attributes/Signed.php b/src/Attributes/Signed.php index bbf817c..6e1cfde 100644 --- a/src/Attributes/Signed.php +++ b/src/Attributes/Signed.php @@ -3,21 +3,50 @@ namespace WireElements\LivewireStrict\Attributes; use Livewire\Features\SupportAttributes\Attribute; -use WireElements\LivewireStrict\Features\Concerns\NormalizesTtl; +use Livewire\Features\SupportAttributes\AttributeLevel; #[\Attribute(\Attribute::TARGET_METHOD)] class Signed extends Attribute { - use NormalizesTtl; - /** - * Explicitly disable expiration, even if a global TTL is set. + * @param int|null $ttl Seconds until the signed payload expires. + * - null: inherit the global TTL (default) + * - 0: never expire, even if a global TTL is set + * - positive int: override the global TTL with this value */ - public const NO_EXPIRATION = 0; - public function __construct( public ?int $ttl = null, ) { - static::normalizeTtl($ttl); + self::validateTtl($this->ttl); + } + + /** + * Validate that a TTL value is non-negative. + * + * @throws \InvalidArgumentException + */ + public static function validateTtl(?int $ttl): void + { + if ($ttl !== null && $ttl < 0) { + throw new \InvalidArgumentException("TTL must be a non-negative integer, got: {$ttl}"); + } + } + + /** + * Resolve the effective TTL for a method, considering per-method overrides. + */ + public static function resolveMethodTtl(object $component, string $method, ?int $globalTtl): ?int + { + $signed = $component->getAttributes() + ->whereInstanceOf(self::class) + ->filter(fn (self $attribute) => $attribute->getLevel() === AttributeLevel::METHOD) + ->filter(fn (self $attribute) => $attribute->getName() === $method) + ->first(); + + if ($signed && $signed->ttl !== null) { + return $signed->ttl; + } + + return $globalTtl; } } diff --git a/src/Features/Concerns/NormalizesTtl.php b/src/Features/Concerns/NormalizesTtl.php deleted file mode 100644 index 9478956..0000000 --- a/src/Features/Concerns/NormalizesTtl.php +++ /dev/null @@ -1,19 +0,0 @@ -getId(), + method: $method, + params: $params, + expiry: $ttl ? Carbon::now()->timestamp + $ttl : null, + ); + } + + /** + * Verify an encoded payload against a component and return a SignedPayload instance. + * + * @throws InvalidSignedActionException + * @throws ExpiredSignedActionException + */ + public static function verify(string $encodedPayload, object $component): self + { + $decoded = json_decode(base64_decode($encodedPayload, true), true); + + throw_unless( + is_array($decoded) && isset($decoded['sig'], $decoded['method'], $decoded['params'], $decoded['id']), + InvalidSignedActionException::class, + ); + + $method = $decoded['method']; + + $payloadData = array_filter([ + 'id' => $decoded['id'], + 'method' => $method, + 'params' => $decoded['params'], + 'exp' => $decoded['exp'] ?? null, + ], fn ($value) => $value !== null); + + $expectedSig = hash_hmac('sha256', json_encode($payloadData, self::JSON_FLAGS), self::signingKey()); + + throw_unless(hash_equals($expectedSig, $decoded['sig']), InvalidSignedActionException::class, $method); + + throw_if( + isset($decoded['exp']) && Carbon::now()->timestamp > $decoded['exp'], + ExpiredSignedActionException::class, + $method, + ); + + throw_unless($decoded['id'] === $component->getId(), InvalidSignedActionException::class, $method); + + return new self( + componentId: $decoded['id'], + method: $method, + params: $decoded['params'], + expiry: $decoded['exp'] ?? null, + ); + } + + /** + * Encode the payload into a signed, base64-encoded string. + */ + public function encode(): string + { + $payloadData = array_filter([ + 'id' => $this->componentId, + 'method' => $this->method, + 'params' => $this->params, + 'exp' => $this->expiry, + ], fn ($value) => $value !== null); + + $signature = hash_hmac('sha256', json_encode($payloadData, self::JSON_FLAGS), self::signingKey()); + + return base64_encode(json_encode(array_merge($payloadData, ['sig' => $signature]))); + } + + /** + * Get the wire action string for use in Blade templates. + */ + public function toAction(): string + { + return "__callSigned('{$this->encode()}')"; + } +} diff --git a/src/Features/SupportSignedActions/SupportSignedActions.php b/src/Features/SupportSignedActions/SupportSignedActions.php index 4ec36e3..14e581c 100644 --- a/src/Features/SupportSignedActions/SupportSignedActions.php +++ b/src/Features/SupportSignedActions/SupportSignedActions.php @@ -3,23 +3,19 @@ namespace WireElements\LivewireStrict\Features\SupportSignedActions; use Livewire\ComponentHook; -use Illuminate\Support\Carbon; +use Livewire\Features\SupportAttributes\AttributeLevel; use WireElements\LivewireStrict\Attributes\Signed; use WireElements\LivewireStrict\Features\Concerns\MatchesComponents; -use WireElements\LivewireStrict\Features\Concerns\NormalizesTtl; +use WireElements\LivewireStrict\Features\SupportSignedActions\Exceptions\InvalidSignedActionException; class SupportSignedActions extends ComponentHook { use MatchesComponents; - use NormalizesTtl; public static bool $enabled = false; public static array $components = []; - /** - * Time-to-live in seconds for signed payloads. Null means no expiration. - */ public static ?int $ttl = null; public function call($method, $params, $returnEarly, $metadata, $componentContext) @@ -32,166 +28,51 @@ public function call($method, $params, $returnEarly, $metadata, $componentContex return; } - // Handle signed action calls if ($method === '__callSigned') { - // Guard: ensure the component doesn't have an actual __callSigned method - if (method_exists($this->component, '__callSigned')) { - throw new \LogicException( - 'Component [' . $this->component::class . '] defines a __callSigned method, which collides with the internal signed-action hook.' - ); - } - - if (! isset($params[0]) || ! is_string($params[0])) { - throw new InvalidSignedActionException('__callSigned'); - } - - $decoded = $this->verifyAndDecode($params[0]); - $result = $this->component->{$decoded['method']}(...$decoded['params']); - $returnEarly($result); + $this->handleSignedCall($params, $returnEarly); return; } - // Block direct calls to #[Signed] methods if ($this->methodIsSigned($method)) { throw new InvalidSignedActionException($method); } } - protected function methodIsSigned(string $method): bool - { - if (! method_exists($this->component, $method)) { - return false; - } - - $reflection = new \ReflectionMethod($this->component, $method); - - return $reflection->isPublic() && ! $reflection->isStatic() && ! empty($reflection->getAttributes(Signed::class)); - } - - /** - * Get the TTL for a specific method. Per-method TTL overrides the global TTL. - */ - public static function getMethodTtl(object $component, string $method): ?int - { - if (! method_exists($component, $method)) { - return static::$ttl; - } - - $reflection = new \ReflectionMethod($component, $method); - $attributes = $reflection->getAttributes(Signed::class); - - if (! empty($attributes)) { - $signed = $attributes[0]->newInstance(); - - if ($signed->ttl !== null) { - return static::normalizeTtl($signed->ttl); - } - } - - return static::$ttl; - } - - protected function verifyAndDecode(string $encodedPayload): array - { - $decoded = json_decode(base64_decode($encodedPayload, true), true); - - if (! $decoded || ! isset($decoded['sig'], $decoded['method'], $decoded['params'], $decoded['id'])) { - throw new InvalidSignedActionException; - } - - // Build the same payload structure used during signing - $payloadData = [ - 'id' => $decoded['id'], - 'method' => $decoded['method'], - 'params' => $decoded['params'], - ]; - - // Include expiry in HMAC if it was part of the signed payload - if (isset($decoded['exp'])) { - $payloadData['exp'] = $decoded['exp']; - } - - $payload = json_encode($payloadData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - - $expectedSig = hash_hmac('sha256', $payload, config('app.key')); - - if (! hash_equals($expectedSig, $decoded['sig'])) { - throw new InvalidSignedActionException($decoded['method']); - } - - // Verify payload has not expired - if (isset($decoded['exp']) && Carbon::now()->timestamp > $decoded['exp']) { - throw new ExpiredSignedActionException($decoded['method']); - } - - // Verify component ID matches - if ($decoded['id'] !== $this->component->getId()) { - throw new InvalidSignedActionException($decoded['method']); - } - - // Verify target method requires signing - if (! $this->methodIsSigned($decoded['method'])) { - throw new InvalidSignedActionException($decoded['method']); - } - - return $decoded; - } - - /** - * Generate a signed payload string for use in testing or programmatic calls. - */ - public static function generateSignedPayload(string $componentId, string $method, mixed ...$params): string - { - return static::generateSignedPayloadWithTtl(static::$ttl, $componentId, $method, ...$params); - } - - /** - * Generate a signed payload with an explicit TTL. - */ - public static function generateSignedPayloadWithTtl(?int $ttl, string $componentId, string $method, mixed ...$params): string + protected function handleSignedCall(array $params, callable $returnEarly): void { - $ttl = static::normalizeTtl($ttl); - - $payloadData = [ - 'id' => $componentId, - 'method' => $method, - 'params' => $params, - ]; - - if ($ttl !== null) { - $payloadData['exp'] = Carbon::now()->timestamp + $ttl; - } - - $payload = json_encode($payloadData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - - $signature = hash_hmac('sha256', $payload, config('app.key')); - - return base64_encode(json_encode(array_merge($payloadData, [ - 'sig' => $signature, - ]))); + throw_if( + method_exists($this->component, '__callSigned'), + \LogicException::class, + 'Component ['.$this->component::class.'] defines a __callSigned method, which collides with the internal signed-action hook.' + ); + + throw_unless( + isset($params[0]) && is_string($params[0]), + InvalidSignedActionException::class, + '__callSigned', + ); + + $payload = SignedPayload::verify($params[0], $this->component); + + throw_unless( + $this->methodIsSigned($payload->method), + InvalidSignedActionException::class, + $payload->method + ); + + $returnEarly( + $this->component->{$payload->method}(...$payload->params) + ); } - /** - * Generate a signed action string for use in Blade templates. - * Used by the @livewireAction Blade directive. - */ - public static function generateSignedAction(string $componentId, string $method, mixed ...$params): string - { - $payload = self::generateSignedPayload($componentId, $method, ...$params); - - return "__callSigned('{$payload}')"; - } - - /** - * Generate a signed action string with per-method TTL resolution. - * Used internally when the component instance is available. - */ - public static function generateSignedActionForComponent(object $component, string $method, mixed ...$params): string + protected function methodIsSigned(string $method): bool { - $ttl = static::getMethodTtl($component, $method); - $payload = static::generateSignedPayloadWithTtl($ttl, $component->getId(), $method, ...$params); - - return "__callSigned('{$payload}')"; + return $this->component + ->getAttributes() + ->whereInstanceOf(Signed::class) + ->filter(fn (Signed $attribute) => $attribute->getLevel() === AttributeLevel::METHOD) + ->filter(fn (Signed $attribute) => $attribute->getName() === $method) + ->isNotEmpty(); } } diff --git a/src/Features/SupportSignedActions/UnitTest.php b/src/Features/SupportSignedActions/UnitTest.php index 7723727..e5e902c 100644 --- a/src/Features/SupportSignedActions/UnitTest.php +++ b/src/Features/SupportSignedActions/UnitTest.php @@ -5,6 +5,8 @@ use Livewire\Component; use Livewire\Livewire; use WireElements\LivewireStrict\Attributes\Signed; +use WireElements\LivewireStrict\Features\SupportSignedActions\Exceptions\ExpiredSignedActionException; +use WireElements\LivewireStrict\Features\SupportSignedActions\Exceptions\InvalidSignedActionException; use WireElements\LivewireStrict\LivewireStrict; class UnitTest extends \Tests\TestCase @@ -12,12 +14,17 @@ class UnitTest extends \Tests\TestCase public function setUp(): void { parent::setUp(); + SupportSignedActions::$enabled = false; SupportSignedActions::$components = []; SupportSignedActions::$ttl = null; } - public function test_cant_call_signed_method_directly() + // ────────────────────────────────────────────────────────── + // Core: signed methods cannot be called directly + // ────────────────────────────────────────────────────────── + + public function test_blocks_direct_call_to_signed_method() { $this->expectException(InvalidSignedActionException::class); $this->expectExceptionMessage('Cannot call signed action: [delete]'); @@ -31,11 +38,43 @@ public function delete(int $id) { $this->result = $id; } + })->call('delete', 5); + } + + public function test_allows_non_signed_methods() + { + LivewireStrict::signedActions(components: 'WireElements\*'); + + Livewire::test(new class extends TestSignedComponent + { + public function save() + { + $this->result = 'saved'; + } }) - ->call('delete', 5); + ->call('save') + ->assertSet('result', 'saved'); } - public function test_can_call_signed_method_with_valid_signature() + public function test_signed_methods_work_normally_when_feature_disabled() + { + Livewire::test(new class extends TestSignedComponent + { + #[Signed] + public function delete(int $id) + { + $this->result = $id; + } + }) + ->call('delete', 5) + ->assertSet('result', 5); + } + + // ────────────────────────────────────────────────────────── + // Core: valid signed payloads execute the method + // ────────────────────────────────────────────────────────── + + public function test_executes_signed_method_with_valid_payload() { LivewireStrict::signedActions(components: 'WireElements\*'); @@ -48,17 +87,38 @@ public function delete(int $id) } }); - $payload = SupportSignedActions::generateSignedPayload( - $component->instance()->getId(), - 'delete', - 5 - ); + $payload = SignedPayload::forComponent($component->instance(), 'delete', 5); - $component->call('__callSigned', $payload) + $component + ->call('__callSigned', $payload->encode()) ->assertSet('result', 5); } - public function test_cant_call_signed_method_with_tampered_params() + public function test_executes_signed_method_without_parameters() + { + LivewireStrict::signedActions(components: 'WireElements\*'); + + $component = Livewire::test(new class extends TestSignedComponent + { + #[Signed] + public function archive() + { + $this->result = 'archived'; + } + }); + + $payload = SignedPayload::forComponent($component->instance(), 'archive'); + + $component + ->call('__callSigned', $payload->encode()) + ->assertSet('result', 'archived'); + } + + // ────────────────────────────────────────────────────────── + // Security: tampered & invalid payloads + // ────────────────────────────────────────────────────────── + + public function test_rejects_tampered_params() { $this->expectException(InvalidSignedActionException::class); @@ -73,21 +133,16 @@ public function delete(int $id) } }); - // Generate valid payload then tamper with params - $validPayload = SupportSignedActions::generateSignedPayload( - $component->instance()->getId(), - 'delete', - 5 - ); + $encoded = SignedPayload::forComponent($component->instance(), 'delete', 5)->encode(); - $decoded = json_decode(base64_decode($validPayload), true); + $decoded = json_decode(base64_decode($encoded), true); $decoded['params'] = [999]; - $tamperedPayload = base64_encode(json_encode($decoded)); + $tampered = base64_encode(json_encode($decoded)); - $component->call('__callSigned', $tamperedPayload); + $component->call('__callSigned', $tampered); } - public function test_cant_call_signed_method_with_wrong_component_id() + public function test_rejects_wrong_component_id() { $this->expectException(InvalidSignedActionException::class); @@ -102,32 +157,18 @@ public function delete(int $id) } }); - $payload = SupportSignedActions::generateSignedPayload( - 'wrong-component-id', - 'delete', - 5 - ); + $payload = (new SignedPayload('wrong-id', 'delete', [5]))->encode(); $component->call('__callSigned', $payload); } - public function test_can_call_non_signed_method_when_feature_enabled() + public function test_rejects_malformed_payload() { - LivewireStrict::signedActions(components: 'WireElements\*'); + $this->expectException(InvalidSignedActionException::class); + $this->expectExceptionMessage('Cannot call signed action. The payload is invalid.'); - Livewire::test(new class extends TestSignedComponent - { - public function regularMethod() - { - $this->result = 'regular'; - } - }) - ->call('regularMethod') - ->assertSet('result', 'regular'); - } + LivewireStrict::signedActions(components: 'WireElements\*'); - public function test_signed_methods_work_when_feature_disabled() - { Livewire::test(new class extends TestSignedComponent { #[Signed] @@ -135,50 +176,51 @@ public function delete(int $id) { $this->result = $id; } - }) - ->call('delete', 5) - ->assertSet('result', 5); + })->call('__callSigned', 'not-valid-base64-garbage'); } - public function test_only_enabled_for_matching_namespace() + public function test_rejects_payload_targeting_non_signed_method() { $this->expectException(InvalidSignedActionException::class); LivewireStrict::signedActions(components: 'WireElements\*'); - Livewire::test(new class extends SpecificSignedComponent + $component = Livewire::test(new class extends TestSignedComponent { - #[Signed] - public function delete(int $id) + public function save() { - $this->result = $id; + $this->result = 'should not run'; } - }) - ->call('delete', 5); + }); + + $payload = (new SignedPayload($component->instance()->getId(), 'save'))->encode(); + + $component->call('__callSigned', $payload); } - public function test_it_ignores_other_namespaces() + // ────────────────────────────────────────────────────────── + // Component matching + // ────────────────────────────────────────────────────────── + + public function test_enforces_for_matching_namespace() { - LivewireStrict::signedActions(components: 'App\*'); + $this->expectException(InvalidSignedActionException::class); - Livewire::test(new class extends TestSignedComponent + LivewireStrict::signedActions(components: 'WireElements\*'); + + Livewire::test(new class extends SpecificSignedComponent { #[Signed] public function delete(int $id) { $this->result = $id; } - }) - ->call('delete', 5) - ->assertSet('result', 5); + })->call('delete', 5); } - public function test_rejects_malformed_payload() + public function test_ignores_non_matching_namespace() { - $this->expectException(InvalidSignedActionException::class); - $this->expectExceptionMessage('Cannot call signed action. The payload is invalid.'); - - LivewireStrict::signedActions(components: 'WireElements\*'); + LivewireStrict::signedActions(components: 'App\*'); Livewire::test(new class extends TestSignedComponent { @@ -188,33 +230,15 @@ public function delete(int $id) $this->result = $id; } }) - ->call('__callSigned', 'not-valid-base64-garbage'); + ->call('delete', 5) + ->assertSet('result', 5); } - public function test_rejects_signed_payload_on_component_without_signed_methods() - { - $this->expectException(InvalidSignedActionException::class); - - LivewireStrict::signedActions(components: 'WireElements\*'); - - $component = Livewire::test(new class extends TestSignedComponent - { - public function regularMethod() - { - $this->result = 'should not run'; - } - }); - - // Craft a valid-signature payload targeting a non-signed method - $payload = SupportSignedActions::generateSignedPayload( - $component->instance()->getId(), - 'regularMethod' - ); - - $component->call('__callSigned', $payload); - } + // ────────────────────────────────────────────────────────── + // TTL: global expiration + // ────────────────────────────────────────────────────────── - public function test_valid_payload_with_ttl_succeeds_before_expiry() + public function test_payload_with_ttl_succeeds_before_expiry() { LivewireStrict::signedActions(components: 'WireElements\*', ttl: 300); @@ -227,13 +251,10 @@ public function delete(int $id) } }); - $payload = SupportSignedActions::generateSignedPayload( - $component->instance()->getId(), - 'delete', - 5 - ); + $payload = SignedPayload::forComponent($component->instance(), 'delete', 5); - $component->call('__callSigned', $payload) + $component + ->call('__callSigned', $payload->encode()) ->assertSet('result', 5); } @@ -253,19 +274,14 @@ public function delete(int $id) } }); - $payload = SupportSignedActions::generateSignedPayload( - $component->instance()->getId(), - 'delete', - 5 - ); + $payload = SignedPayload::forComponent($component->instance(), 'delete', 5); - // Travel forward in time past the TTL $this->travel(301)->seconds(); - $component->call('__callSigned', $payload); + $component->call('__callSigned', $payload->encode()); } - public function test_payload_without_ttl_does_not_expire() + public function test_payload_without_ttl_never_expires() { LivewireStrict::signedActions(components: 'WireElements\*'); @@ -278,16 +294,12 @@ public function delete(int $id) } }); - $payload = SupportSignedActions::generateSignedPayload( - $component->instance()->getId(), - 'delete', - 5 - ); + $payload = SignedPayload::forComponent($component->instance(), 'delete', 5); - // Travel far forward — no TTL means no expiration $this->travel(7)->days(); - $component->call('__callSigned', $payload) + $component + ->call('__callSigned', $payload->encode()) ->assertSet('result', 5); } @@ -306,24 +318,25 @@ public function delete(int $id) } }); - $payload = SupportSignedActions::generateSignedPayload( - $component->instance()->getId(), - 'delete', - 5 - ); + $encoded = SignedPayload::forComponent($component->instance(), 'delete', 5)->encode(); - // Tamper with expiry to extend it - $decoded = json_decode(base64_decode($payload), true); + $decoded = json_decode(base64_decode($encoded), true); $decoded['exp'] = time() + 99999; - $tamperedPayload = base64_encode(json_encode($decoded)); + $tampered = base64_encode(json_encode($decoded)); $this->travel(120)->seconds(); - $component->call('__callSigned', $tamperedPayload); + $component->call('__callSigned', $tampered); } - public function test_per_method_ttl_overrides_global_ttl() + // ────────────────────────────────────────────────────────── + // TTL: per-method overrides + // ────────────────────────────────────────────────────────── + + public function test_per_method_ttl_overrides_global() { + $this->expectException(ExpiredSignedActionException::class); + LivewireStrict::signedActions(components: 'WireElements\*', ttl: 300); $component = Livewire::test(new class extends TestSignedComponent @@ -335,19 +348,12 @@ public function delete(int $id) } }); - $ttl = SupportSignedActions::getMethodTtl($component->instance(), 'delete'); - $payload = SupportSignedActions::generateSignedPayloadWithTtl( - $ttl, - $component->instance()->getId(), - 'delete', - 5 - ); + $payload = SignedPayload::forComponent($component->instance(), 'delete', 5); - // 61 seconds - past per-method TTL of 60, but within global TTL of 300 + // 61s past per-method TTL of 60, but within global TTL of 300 $this->travel(61)->seconds(); - $this->expectException(ExpiredSignedActionException::class); - $component->call('__callSigned', $payload); + $component->call('__callSigned', $payload->encode()); } public function test_per_method_ttl_succeeds_within_window() @@ -363,23 +369,19 @@ public function delete(int $id) } }); - $ttl = SupportSignedActions::getMethodTtl($component->instance(), 'delete'); - $payload = SupportSignedActions::generateSignedPayloadWithTtl( - $ttl, - $component->instance()->getId(), - 'delete', - 5 - ); + $payload = SignedPayload::forComponent($component->instance(), 'delete', 5); - // 30 seconds - within per-method TTL of 60 $this->travel(30)->seconds(); - $component->call('__callSigned', $payload) + $component + ->call('__callSigned', $payload->encode()) ->assertSet('result', 5); } public function test_method_without_per_method_ttl_uses_global() { + $this->expectException(ExpiredSignedActionException::class); + LivewireStrict::signedActions(components: 'WireElements\*', ttl: 120); $component = Livewire::test(new class extends TestSignedComponent @@ -391,22 +393,14 @@ public function delete(int $id) } }); - $ttl = SupportSignedActions::getMethodTtl($component->instance(), 'delete'); - $payload = SupportSignedActions::generateSignedPayloadWithTtl( - $ttl, - $component->instance()->getId(), - 'delete', - 5 - ); + $payload = SignedPayload::forComponent($component->instance(), 'delete', 5); - // 121 seconds - past global TTL of 120 $this->travel(121)->seconds(); - $this->expectException(ExpiredSignedActionException::class); - $component->call('__callSigned', $payload); + $component->call('__callSigned', $payload->encode()); } - public function test_per_method_ttl_zero_disables_expiration_even_with_global_ttl() + public function test_per_method_ttl_zero_disables_expiration() { LivewireStrict::signedActions(components: 'WireElements\*', ttl: 60); @@ -419,64 +413,174 @@ public function delete(int $id) } }); - $ttl = SupportSignedActions::getMethodTtl($component->instance(), 'delete'); - $this->assertNull($ttl, 'ttl: 0 should resolve to null (no expiration)'); + $payload = SignedPayload::forComponent($component->instance(), 'delete', 5); + + $this->travel(9999)->seconds(); + + $component + ->call('__callSigned', $payload->encode()) + ->assertSet('result', 5); + } + + // ────────────────────────────────────────────────────────── + // TTL: validation + // ────────────────────────────────────────────────────────── + + public function test_negative_global_ttl_is_rejected() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('TTL must be a non-negative integer, got: -5'); + + LivewireStrict::signedActions(components: 'WireElements\*', ttl: -5); + } + + public function test_negative_per_method_ttl_is_rejected() + { + $this->expectException(\InvalidArgumentException::class); + $this->expectExceptionMessage('TTL must be a non-negative integer, got: -10'); + + new Signed(ttl: -10); + } + + public function test_global_ttl_zero_disables_expiration() + { + LivewireStrict::signedActions(components: 'WireElements\*', ttl: 0); + + $component = Livewire::test(new class extends TestSignedComponent + { + #[Signed] + public function delete(int $id) + { + $this->result = $id; + } + }); - $payload = SupportSignedActions::generateSignedPayloadWithTtl( - $ttl, - $component->instance()->getId(), - 'delete', - 5 - ); + $payload = SignedPayload::forComponent($component->instance(), 'delete', 5); - // Travel far into the future — should still work because ttl: 0 means no expiration $this->travel(9999)->seconds(); - $component->call('__callSigned', $payload) + $component + ->call('__callSigned', $payload->encode()) ->assertSet('result', 5); } - public function test_signed_method_with_no_parameters_works() + // ────────────────────────────────────────────────────────── + // Edge cases + // ────────────────────────────────────────────────────────── + + public function test_multiple_signed_methods_with_different_ttls() + { + $this->expectException(ExpiredSignedActionException::class); + $this->expectExceptionMessage('Signed action [quickAction] has expired.'); + + LivewireStrict::signedActions(components: 'WireElements\*', ttl: 300); + + $component = Livewire::test(new class extends TestSignedComponent + { + #[Signed(ttl: 10)] + public function quickAction() + { + $this->result = 'quick'; + } + + #[Signed(ttl: 600)] + public function slowAction() + { + $this->result = 'slow'; + } + }); + + $quickPayload = SignedPayload::forComponent($component->instance(), 'quickAction'); + $slowPayload = SignedPayload::forComponent($component->instance(), 'slowAction'); + + $this->travel(15)->seconds(); + + // slowAction should still work (15s < 600s TTL) + $component + ->call('__callSigned', $slowPayload->encode()) + ->assertSet('result', 'slow'); + + // quickAction should fail (15s > 10s TTL) + $component->call('__callSigned', $quickPayload->encode()); + } + + public function test_rejects_callSigned_with_non_string_param() + { + $this->expectException(InvalidSignedActionException::class); + + LivewireStrict::signedActions(components: 'WireElements\*'); + + Livewire::test(new class extends TestSignedComponent + { + #[Signed] + public function delete(int $id) + { + $this->result = $id; + } + })->call('__callSigned', 12345); + } + + public function test_valid_payload_can_be_replayed() { LivewireStrict::signedActions(components: 'WireElements\*'); $component = Livewire::test(new class extends TestSignedComponent { + public int $counter = 0; + #[Signed] - public function archive() + public function increment() { - $this->result = 'archived'; + $this->counter++; } }); - $payload = SupportSignedActions::generateSignedPayload( - $component->instance()->getId(), - 'archive' - ); + $encoded = SignedPayload::forComponent($component->instance(), 'increment')->encode(); - $component->call('__callSigned', $payload) - ->assertSet('result', 'archived'); + $component + ->call('__callSigned', $encoded) + ->assertSet('counter', 1) + ->call('__callSigned', $encoded) + ->assertSet('counter', 2) + ->call('__callSigned', $encoded) + ->assertSet('counter', 3); } - public function test_negative_global_ttl_is_rejected() + public function test_missing_app_key_throws_runtime_exception() { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('TTL must be a non-negative integer, got: -5'); + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('No application key set.'); - LivewireStrict::signedActions(components: 'WireElements\*', ttl: -5); + config()->set('app.key', null); + + (new SignedPayload('test-id', 'delete', [5]))->encode(); } - public function test_negative_per_method_ttl_is_rejected() + public function test_rejects_component_defining_callSigned_method() { - $this->expectException(\InvalidArgumentException::class); - $this->expectExceptionMessage('TTL must be a non-negative integer, got: -10'); + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('defines a __callSigned method'); - new Signed(ttl: -10); + LivewireStrict::signedActions(components: 'WireElements\*'); + + Livewire::test(new class extends TestSignedComponent + { + #[Signed] + public function delete(int $id) + { + $this->result = $id; + } + + public function __callSigned() + { + // This collides with the internal hook + } + })->call('__callSigned', 'anything'); } - public function test_global_ttl_zero_disables_expiration() + public function test_toAction_returns_wire_action_string() { - LivewireStrict::signedActions(components: 'WireElements\*', ttl: 0); + LivewireStrict::signedActions(components: 'WireElements\*'); $component = Livewire::test(new class extends TestSignedComponent { @@ -487,20 +591,25 @@ public function delete(int $id) } }); - $payload = SupportSignedActions::generateSignedPayload( - $component->instance()->getId(), - 'delete', - 5 - ); + $payload = SignedPayload::forComponent($component->instance(), 'delete', 5); + $action = $payload->toAction(); - // Travel far into the future — ttl: 0 means no expiration - $this->travel(9999)->seconds(); + $this->assertStringStartsWith("__callSigned('", $action); + $this->assertStringEndsWith("')", $action); - $component->call('__callSigned', $payload) - ->assertSet('result', 5); + // The encoded payload inside should be verifiable + $encoded = substr($action, strlen("__callSigned('"), -strlen("')")); + $verified = SignedPayload::verify($encoded, $component->instance()); + + $this->assertSame('delete', $verified->method); + $this->assertSame([5], $verified->params); } } +// ────────────────────────────────────────────────────────── +// Test components +// ────────────────────────────────────────────────────────── + class TestSignedComponent extends Component { public $result = null; @@ -511,6 +620,4 @@ public function render() } } -class SpecificSignedComponent extends TestSignedComponent -{ -} +class SpecificSignedComponent extends TestSignedComponent {} diff --git a/src/LivewireStrict.php b/src/LivewireStrict.php index 92561c0..4793cdc 100644 --- a/src/LivewireStrict.php +++ b/src/LivewireStrict.php @@ -3,6 +3,7 @@ namespace WireElements\LivewireStrict; use Illuminate\Support\Arr; +use WireElements\LivewireStrict\Attributes\Signed; use WireElements\LivewireStrict\Features\SupportLockedProperties\SupportLockedProperties; use WireElements\LivewireStrict\Features\SupportSignedActions\SupportSignedActions; @@ -19,13 +20,18 @@ public static function lockProperties($shouldLockProperties = true, $components * * @param bool $shouldSignActions * @param string|string[] $components Component class or wildcard pattern(s). - * @param int|null $ttl Seconds until payloads expire. Use 0 or Signed::NO_EXPIRATION to disable expiration. + * @param int|null $ttl Seconds until payloads expire. + * - null: no expiration (default) + * - 0: no expiration (same as null) + * - positive int: payloads expire after this many seconds */ public static function signedActions($shouldSignActions = true, $components = ['App\Livewire\*'], $ttl = null) { + Signed::validateTtl($ttl); + + SupportSignedActions::$ttl = $ttl ?: null; SupportSignedActions::$enabled = $shouldSignActions; SupportSignedActions::$components = Arr::wrap($components); - SupportSignedActions::$ttl = SupportSignedActions::normalizeTtl($ttl); } public static function enableAll($condition = true) diff --git a/src/LivewireStrictServiceProvider.php b/src/LivewireStrictServiceProvider.php index 4894f76..c522225 100644 --- a/src/LivewireStrictServiceProvider.php +++ b/src/LivewireStrictServiceProvider.php @@ -22,7 +22,7 @@ public function register(): void public function boot(): void { Blade::directive('livewireAction', function ($expression) { - return ""; + return "toAction(); ?>"; }); } } From 31073d24e46abf0102d4313154b1f7503d066c88 Mon Sep 17 00:00:00 2001 From: h4or Date: Sat, 14 Feb 2026 11:31:08 +0100 Subject: [PATCH 09/11] fix: validate payload field types in SignedPayload::verify() Decoded payloads are user-controlled input, so fields like method, sig, and id could be arrays or integers instead of strings, causing TypeError in hash_equals() or the exception constructor. Now validates types before signature verification: - id, method, sig must be scalar (cast to string) - params must be an array - exp, if present, must be an int Any type mismatch throws InvalidSignedActionException with a clean error message. Added regression tests for each malformed-payload case. --- .../SupportSignedActions/SignedPayload.php | 35 +++-- .../SupportSignedActions/UnitTest.php | 135 ++++++++++++++++++ 2 files changed, 160 insertions(+), 10 deletions(-) diff --git a/src/Features/SupportSignedActions/SignedPayload.php b/src/Features/SupportSignedActions/SignedPayload.php index 5b884af..0984ac6 100644 --- a/src/Features/SupportSignedActions/SignedPayload.php +++ b/src/Features/SupportSignedActions/SignedPayload.php @@ -60,32 +60,47 @@ public static function verify(string $encodedPayload, object $component): self InvalidSignedActionException::class, ); - $method = $decoded['method']; + // Validate types of the decoded payload to avoid TypeError and ensure predictable failures. + $hasInvalidTypes = !is_scalar($decoded['id']) + || !is_scalar($decoded['method']) + || !is_scalar($decoded['sig']) + || !is_array($decoded['params']) + || (array_key_exists('exp', $decoded) && !is_int($decoded['exp'])); + + if ($hasInvalidTypes) { + throw new InvalidSignedActionException(''); + } + + $id = (string) $decoded['id']; + $method = (string) $decoded['method']; + $sig = (string) $decoded['sig']; + $params = $decoded['params']; + $exp = $decoded['exp'] ?? null; $payloadData = array_filter([ - 'id' => $decoded['id'], + 'id' => $id, 'method' => $method, - 'params' => $decoded['params'], - 'exp' => $decoded['exp'] ?? null, + 'params' => $params, + 'exp' => $exp, ], fn ($value) => $value !== null); $expectedSig = hash_hmac('sha256', json_encode($payloadData, self::JSON_FLAGS), self::signingKey()); - throw_unless(hash_equals($expectedSig, $decoded['sig']), InvalidSignedActionException::class, $method); + throw_unless(hash_equals($expectedSig, $sig), InvalidSignedActionException::class, $method); throw_if( - isset($decoded['exp']) && Carbon::now()->timestamp > $decoded['exp'], + isset($exp) && Carbon::now()->timestamp > $exp, ExpiredSignedActionException::class, $method, ); - throw_unless($decoded['id'] === $component->getId(), InvalidSignedActionException::class, $method); + throw_unless($id === $component->getId(), InvalidSignedActionException::class, $method); return new self( - componentId: $decoded['id'], + componentId: $id, method: $method, - params: $decoded['params'], - expiry: $decoded['exp'] ?? null, + params: $params, + expiry: $exp, ); } diff --git a/src/Features/SupportSignedActions/UnitTest.php b/src/Features/SupportSignedActions/UnitTest.php index e5e902c..3b51ad8 100644 --- a/src/Features/SupportSignedActions/UnitTest.php +++ b/src/Features/SupportSignedActions/UnitTest.php @@ -578,6 +578,141 @@ public function __callSigned() })->call('__callSigned', 'anything'); } + // ────────────────────────────────────────────────────────── + // Type-invalid payloads (regression: should not cause TypeError) + // ────────────────────────────────────────────────────────── + + public function test_rejects_payload_with_non_string_method() + { + $this->expectException(InvalidSignedActionException::class); + $this->expectExceptionMessage('Cannot call signed action. The payload is invalid.'); + + LivewireStrict::signedActions(components: 'WireElements\*'); + + $component = Livewire::test(new class extends TestSignedComponent + { + #[Signed] + public function delete(int $id) + { + $this->result = $id; + } + }); + + $payload = base64_encode(json_encode([ + 'id' => $component->instance()->getId(), + 'method' => ['not', 'a', 'string'], + 'params' => [5], + 'sig' => 'irrelevant', + ])); + + $component->call('__callSigned', $payload); + } + + public function test_rejects_payload_with_array_sig() + { + $this->expectException(InvalidSignedActionException::class); + $this->expectExceptionMessage('Cannot call signed action. The payload is invalid.'); + + LivewireStrict::signedActions(components: 'WireElements\*'); + + $component = Livewire::test(new class extends TestSignedComponent + { + #[Signed] + public function delete(int $id) + { + $this->result = $id; + } + }); + + $payload = base64_encode(json_encode([ + 'id' => $component->instance()->getId(), + 'method' => 'delete', + 'params' => [5], + 'sig' => ['not', 'a', 'string'], + ])); + + $component->call('__callSigned', $payload); + } + + public function test_rejects_payload_with_non_array_params() + { + $this->expectException(InvalidSignedActionException::class); + $this->expectExceptionMessage('Cannot call signed action. The payload is invalid.'); + + LivewireStrict::signedActions(components: 'WireElements\*'); + + $component = Livewire::test(new class extends TestSignedComponent + { + #[Signed] + public function delete(int $id) + { + $this->result = $id; + } + }); + + $payload = base64_encode(json_encode([ + 'id' => $component->instance()->getId(), + 'method' => 'delete', + 'params' => 'not-an-array', + 'sig' => 'irrelevant', + ])); + + $component->call('__callSigned', $payload); + } + + public function test_rejects_payload_with_non_int_exp() + { + $this->expectException(InvalidSignedActionException::class); + $this->expectExceptionMessage('Cannot call signed action. The payload is invalid.'); + + LivewireStrict::signedActions(components: 'WireElements\*'); + + $component = Livewire::test(new class extends TestSignedComponent + { + #[Signed] + public function delete(int $id) + { + $this->result = $id; + } + }); + + $payload = base64_encode(json_encode([ + 'id' => $component->instance()->getId(), + 'method' => 'delete', + 'params' => [5], + 'exp' => 'not-an-int', + 'sig' => 'irrelevant', + ])); + + $component->call('__callSigned', $payload); + } + + public function test_rejects_payload_with_non_scalar_id() + { + $this->expectException(InvalidSignedActionException::class); + $this->expectExceptionMessage('Cannot call signed action. The payload is invalid.'); + + LivewireStrict::signedActions(components: 'WireElements\*'); + + $component = Livewire::test(new class extends TestSignedComponent + { + #[Signed] + public function delete(int $id) + { + $this->result = $id; + } + }); + + $payload = base64_encode(json_encode([ + 'id' => ['an', 'array'], + 'method' => 'delete', + 'params' => [5], + 'sig' => 'irrelevant', + ])); + + $component->call('__callSigned', $payload); + } + public function test_toAction_returns_wire_action_string() { LivewireStrict::signedActions(components: 'WireElements\*'); From 88ee1962a72bef16a533820dd3f2a0e5e2030b75 Mon Sep 17 00:00:00 2001 From: h4or Date: Sat, 14 Feb 2026 14:22:26 +0100 Subject: [PATCH 10/11] security: add domain-separated key derivation for signed actions - Derive a purpose-specific HMAC key from APP_KEY instead of using the raw key directly, preventing cross-system signature confusion - Add security tests: raw-key rejection, empty payloads, null values, empty method name, extra field handling, key rotation, runtime toggle - Document __callSigned collision guard and negative TTL validation - Update How It Works section to reflect domain-separated key derivation --- docs/signed-actions.md | 9 +- .../SupportSignedActions/SignedPayload.php | 6 +- .../SupportSignedActions/UnitTest.php | 192 ++++++++++++++++++ 3 files changed, 203 insertions(+), 4 deletions(-) diff --git a/docs/signed-actions.md b/docs/signed-actions.md index 77b8068..f061d29 100644 --- a/docs/signed-actions.md +++ b/docs/signed-actions.md @@ -76,7 +76,7 @@ Replace inline method calls with the `@livewireAction` directive: ## 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` +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, 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** @@ -91,11 +91,12 @@ Replace inline method calls with the `@livewireAction` directive: | 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. +> **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. +- 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 @@ -139,6 +140,8 @@ class OrderManager extends Component 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 diff --git a/src/Features/SupportSignedActions/SignedPayload.php b/src/Features/SupportSignedActions/SignedPayload.php index 0984ac6..ee34ed5 100644 --- a/src/Features/SupportSignedActions/SignedPayload.php +++ b/src/Features/SupportSignedActions/SignedPayload.php @@ -21,13 +21,17 @@ public function __construct( /** * Get the application signing key, ensuring it is set. * + * Derives a purpose-specific key via HMAC to provide domain separation. + * This prevents cross-system signature confusion if other subsystems + * also use the raw APP_KEY with hash_hmac('sha256', ...). + * * @throws \RuntimeException */ private static function signingKey(): string { throw_unless(config('app.key'), \RuntimeException::class, 'No application key set. Signed actions require an APP_KEY to be configured.'); - return config('app.key'); + return hash_hmac('sha256', 'livewire-strict:signed-actions', config('app.key')); } /** diff --git a/src/Features/SupportSignedActions/UnitTest.php b/src/Features/SupportSignedActions/UnitTest.php index 3b51ad8..4cceb81 100644 --- a/src/Features/SupportSignedActions/UnitTest.php +++ b/src/Features/SupportSignedActions/UnitTest.php @@ -739,6 +739,198 @@ public function delete(int $id) $this->assertSame('delete', $verified->method); $this->assertSame([5], $verified->params); } + + // ────────────────────────────────────────────────────────── + // Security: cross-system signature confusion + // ────────────────────────────────────────────────────────── + + public function test_raw_app_key_hmac_does_not_validate_as_signed_payload() + { + $this->expectException(InvalidSignedActionException::class); + + LivewireStrict::signedActions(components: 'WireElements\*'); + + $component = Livewire::test(new class extends TestSignedComponent + { + #[Signed] + public function delete(int $id) + { + $this->result = $id; + } + }); + + // Simulate a signature forged with the raw APP_KEY (no domain separation). + // This must NOT be accepted by verify(). + $payloadData = [ + 'id' => $component->instance()->getId(), + 'method' => 'delete', + 'params' => [5], + ]; + $rawSig = hash_hmac( + 'sha256', + json_encode($payloadData, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE), + config('app.key') + ); + $forged = base64_encode(json_encode(array_merge($payloadData, ['sig' => $rawSig]))); + + $component->call('__callSigned', $forged); + } + + // ────────────────────────────────────────────────────────── + // Security: payload with extra/unexpected fields + // ────────────────────────────────────────────────────────── + + public function test_extra_fields_in_payload_are_silently_ignored() + { + LivewireStrict::signedActions(components: 'WireElements\*'); + + $component = Livewire::test(new class extends TestSignedComponent + { + #[Signed] + public function delete(int $id) + { + $this->result = $id; + } + }); + + // Take a valid payload and inject an extra field (e.g., "admin": true). + // verify() re-derives the HMAC from only the canonical fields (id, method, + // params, exp), so the injected field is discarded. The sig still matches + // and the method executes. This is acceptable because the extra field is + // never used, but reviewers should be aware that additional JSON keys + // don't invalidate the payload. + $encoded = SignedPayload::forComponent($component->instance(), 'delete', 5)->encode(); + $decoded = json_decode(base64_decode($encoded), true); + $decoded['admin'] = true; + $tampered = base64_encode(json_encode($decoded)); + + $component + ->call('__callSigned', $tampered) + ->assertSet('result', 5); + } + + public function test_rejects_payload_with_empty_string_method() + { + $this->expectException(InvalidSignedActionException::class); + + LivewireStrict::signedActions(components: 'WireElements\*'); + + $component = Livewire::test(new class extends TestSignedComponent + { + #[Signed] + public function delete(int $id) + { + $this->result = $id; + } + }); + + // Forge a payload with an empty method name. The HMAC is computed properly + // but methodIsSigned('') should return false, blocking execution. + $payload = new SignedPayload($component->instance()->getId(), '', [5]); + $component->call('__callSigned', $payload->encode()); + } + + public function test_rejects_completely_empty_base64_payload() + { + $this->expectException(InvalidSignedActionException::class); + + LivewireStrict::signedActions(components: 'WireElements\*'); + + Livewire::test(new class extends TestSignedComponent + { + #[Signed] + public function delete(int $id) + { + $this->result = $id; + } + })->call('__callSigned', base64_encode('')); + } + + public function test_rejects_payload_with_null_json_values() + { + $this->expectException(InvalidSignedActionException::class); + + LivewireStrict::signedActions(components: 'WireElements\*'); + + $component = Livewire::test(new class extends TestSignedComponent + { + #[Signed] + public function delete(int $id) + { + $this->result = $id; + } + }); + + $payload = base64_encode(json_encode([ + 'id' => null, + 'method' => null, + 'params' => null, + 'sig' => null, + ])); + + $component->call('__callSigned', $payload); + } + + public function test_different_app_keys_produce_different_signatures() + { + LivewireStrict::signedActions(components: 'WireElements\*'); + + $component = Livewire::test(new class extends TestSignedComponent + { + #[Signed] + public function delete(int $id) + { + $this->result = $id; + } + }); + + $encoded1 = SignedPayload::forComponent($component->instance(), 'delete', 5)->encode(); + + // Change the app key + $originalKey = config('app.key'); + config()->set('app.key', 'base64:' . base64_encode(random_bytes(32))); + + $encoded2 = SignedPayload::forComponent($component->instance(), 'delete', 5)->encode(); + + // Restore original key + config()->set('app.key', $originalKey); + + // Payloads signed with different keys must differ + $this->assertNotSame($encoded1, $encoded2); + + // A payload signed with the wrong key must be rejected + $this->expectException(InvalidSignedActionException::class); + $component->call('__callSigned', $encoded2); + } + + public function test_feature_can_be_disabled_at_runtime() + { + LivewireStrict::signedActions(components: 'WireElements\*'); + + $component = Livewire::test(new class extends TestSignedComponent + { + #[Signed] + public function delete(int $id) + { + $this->result = $id; + } + }); + + // Direct call should be blocked while enabled + try { + $component->call('delete', 5); + $this->fail('Expected InvalidSignedActionException'); + } catch (InvalidSignedActionException $e) { + // expected + } + + // Disabling at runtime bypasses all protection — flag for audit + SupportSignedActions::$enabled = false; + + $component + ->call('delete', 5) + ->assertSet('result', 5); + } } // ────────────────────────────────────────────────────────── From 2fa38142c2a5e010f5fc91aa9d3715dfddf51d89 Mon Sep 17 00:00:00 2001 From: h4or Date: Sat, 14 Feb 2026 17:07:54 +0100 Subject: [PATCH 11/11] refactor: improve SignedPayload structure and harden edge cases - Extract duplicated payload/HMAC logic into private instance methods (payloadData(), sign()) to reduce code noise (DRY) - Reorder methods: public static -> public instance -> private static -> private instance (standard PHP convention) - Make handleSignedCall() and methodIsSigned() private (minimal surface) - Combine chained filter() calls into single filter in methodIsSigned() and Signed::resolveMethodTtl() - Use nullsafe operator in resolveMethodTtl() ($signed?->ttl) - Replace implicit TTL falsy coercion ($ttl ?) with explicit $ttl > 0 - Add test for __callSigned called with no params - Add documentation comments for design tradeoffs (public constructor, replay behavior, TTL 0->null normalization, $__livewire dependency) - Update docs to reflect current architecture --- docs/signed-actions.md | 3 +- src/Attributes/Signed.php | 5 +- .../SupportSignedActions/SignedPayload.php | 129 ++++++++++-------- .../SupportSignedActions.php | 7 +- .../SupportSignedActions/UnitTest.php | 18 ++- src/LivewireStrict.php | 2 +- src/LivewireStrictServiceProvider.php | 1 + 7 files changed, 98 insertions(+), 67 deletions(-) diff --git a/docs/signed-actions.md b/docs/signed-actions.md index f061d29..8cafd2f 100644 --- a/docs/signed-actions.md +++ b/docs/signed-actions.md @@ -78,7 +78,7 @@ Replace inline method calls with the `@livewireAction` directive: 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, verifies the HMAC, checks the component ID matches, and only then executes the method +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 @@ -87,6 +87,7 @@ Replace inline method calls with the `@livewireAction` directive: |--------|--------| | 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 | diff --git a/src/Attributes/Signed.php b/src/Attributes/Signed.php index 6e1cfde..f90dfc5 100644 --- a/src/Attributes/Signed.php +++ b/src/Attributes/Signed.php @@ -39,11 +39,10 @@ public static function resolveMethodTtl(object $component, string $method, ?int { $signed = $component->getAttributes() ->whereInstanceOf(self::class) - ->filter(fn (self $attribute) => $attribute->getLevel() === AttributeLevel::METHOD) - ->filter(fn (self $attribute) => $attribute->getName() === $method) + ->filter(fn (self $attribute) => $attribute->getLevel() === AttributeLevel::METHOD && $attribute->getName() === $method) ->first(); - if ($signed && $signed->ttl !== null) { + if ($signed?->ttl !== null) { return $signed->ttl; } diff --git a/src/Features/SupportSignedActions/SignedPayload.php b/src/Features/SupportSignedActions/SignedPayload.php index ee34ed5..50aa1a0 100644 --- a/src/Features/SupportSignedActions/SignedPayload.php +++ b/src/Features/SupportSignedActions/SignedPayload.php @@ -11,6 +11,10 @@ class SignedPayload { private const JSON_FLAGS = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE; + /** + * Constructor is public to allow tests to forge payloads for security assertions. + * Arbitrary instances cannot produce valid signatures without the APP_KEY. + */ public function __construct( public readonly string $componentId, public readonly string $method, @@ -18,24 +22,11 @@ public function __construct( public readonly ?int $expiry = null, ) {} - /** - * Get the application signing key, ensuring it is set. - * - * Derives a purpose-specific key via HMAC to provide domain separation. - * This prevents cross-system signature confusion if other subsystems - * also use the raw APP_KEY with hash_hmac('sha256', ...). - * - * @throws \RuntimeException - */ - private static function signingKey(): string - { - throw_unless(config('app.key'), \RuntimeException::class, 'No application key set. Signed actions require an APP_KEY to be configured.'); - - return hash_hmac('sha256', 'livewire-strict:signed-actions', config('app.key')); - } - /** * Create a signed payload for a component, resolving per-method TTL overrides. + * + * Note: Signed::resolveMethodTtl() may return 0 (meaning "never expire"). + * The `$ttl > 0` check normalizes 0 to null so the payload has no expiry. */ public static function forComponent(object $component, string $method, mixed ...$params): self { @@ -45,13 +36,21 @@ public static function forComponent(object $component, string $method, mixed ... componentId: $component->getId(), method: $method, params: $params, - expiry: $ttl ? Carbon::now()->timestamp + $ttl : null, + expiry: $ttl > 0 ? Carbon::now()->timestamp + $ttl : null, ); } /** * Verify an encoded payload against a component and return a SignedPayload instance. * + * Verification order: structure → types → HMAC → expiry → component ID. + * This order avoids timing oracles (HMAC checked before component ID) + * and skips unnecessary work on structurally invalid payloads. + * + * Note: valid payloads can be replayed on the same component instance. + * This is by design - Blade buttons render a fixed payload that must + * remain usable across multiple clicks. Use TTL to limit the replay window. + * * @throws InvalidSignedActionException * @throws ExpiredSignedActionException */ @@ -65,47 +64,35 @@ public static function verify(string $encodedPayload, object $component): self ); // Validate types of the decoded payload to avoid TypeError and ensure predictable failures. - $hasInvalidTypes = !is_scalar($decoded['id']) - || !is_scalar($decoded['method']) - || !is_scalar($decoded['sig']) - || !is_array($decoded['params']) - || (array_key_exists('exp', $decoded) && !is_int($decoded['exp'])); - - if ($hasInvalidTypes) { - throw new InvalidSignedActionException(''); - } - - $id = (string) $decoded['id']; - $method = (string) $decoded['method']; + throw_if( + !is_scalar($decoded['id']) + || !is_scalar($decoded['method']) + || !is_scalar($decoded['sig']) + || !is_array($decoded['params']) + || (array_key_exists('exp', $decoded) && !is_int($decoded['exp'])), + InvalidSignedActionException::class, + ); + $sig = (string) $decoded['sig']; - $params = $decoded['params']; - $exp = $decoded['exp'] ?? null; - - $payloadData = array_filter([ - 'id' => $id, - 'method' => $method, - 'params' => $params, - 'exp' => $exp, - ], fn ($value) => $value !== null); - $expectedSig = hash_hmac('sha256', json_encode($payloadData, self::JSON_FLAGS), self::signingKey()); + $instance = new self( + componentId: (string) $decoded['id'], + method: (string) $decoded['method'], + params: $decoded['params'], + expiry: $decoded['exp'] ?? null, + ); - throw_unless(hash_equals($expectedSig, $sig), InvalidSignedActionException::class, $method); + throw_unless(hash_equals($instance->sign(), $sig), InvalidSignedActionException::class, $instance->method); throw_if( - isset($exp) && Carbon::now()->timestamp > $exp, + isset($instance->expiry) && Carbon::now()->timestamp > $instance->expiry, ExpiredSignedActionException::class, - $method, + $instance->method, ); - throw_unless($id === $component->getId(), InvalidSignedActionException::class, $method); + throw_unless($instance->componentId === $component->getId(), InvalidSignedActionException::class, $instance->method); - return new self( - componentId: $id, - method: $method, - params: $params, - expiry: $exp, - ); + return $instance; } /** @@ -113,23 +100,51 @@ public static function verify(string $encodedPayload, object $component): self */ public function encode(): string { - $payloadData = array_filter([ + return base64_encode(json_encode(array_merge($this->payloadData(), ['sig' => $this->sign()]))); + } + + /** + * Get the wire action string for use in Blade templates. + */ + public function toAction(): string + { + return "__callSigned('{$this->encode()}')"; + } + + /** + * Get the application signing key, ensuring it is set. + * + * Derives a purpose-specific key via HMAC to provide domain separation. + * This prevents cross-system signature confusion if other subsystems + * also use the raw APP_KEY with hash_hmac('sha256', ...). + * + * @throws \RuntimeException + */ + private static function signingKey(): string + { + throw_unless(config('app.key'), \RuntimeException::class, 'No application key set. Signed actions require an APP_KEY to be configured.'); + + return hash_hmac('sha256', 'livewire-strict:signed-actions', config('app.key')); + } + + /** + * Build the canonical payload data array used for signing. + */ + private function payloadData(): array + { + return array_filter([ 'id' => $this->componentId, 'method' => $this->method, 'params' => $this->params, 'exp' => $this->expiry, ], fn ($value) => $value !== null); - - $signature = hash_hmac('sha256', json_encode($payloadData, self::JSON_FLAGS), self::signingKey()); - - return base64_encode(json_encode(array_merge($payloadData, ['sig' => $signature]))); } /** - * Get the wire action string for use in Blade templates. + * Compute the HMAC-SHA256 signature for this payload. */ - public function toAction(): string + private function sign(): string { - return "__callSigned('{$this->encode()}')"; + return hash_hmac('sha256', json_encode($this->payloadData(), self::JSON_FLAGS), self::signingKey()); } } diff --git a/src/Features/SupportSignedActions/SupportSignedActions.php b/src/Features/SupportSignedActions/SupportSignedActions.php index 14e581c..3f02379 100644 --- a/src/Features/SupportSignedActions/SupportSignedActions.php +++ b/src/Features/SupportSignedActions/SupportSignedActions.php @@ -39,7 +39,7 @@ public function call($method, $params, $returnEarly, $metadata, $componentContex } } - protected function handleSignedCall(array $params, callable $returnEarly): void + private function handleSignedCall(array $params, callable $returnEarly): void { throw_if( method_exists($this->component, '__callSigned'), @@ -66,13 +66,12 @@ protected function handleSignedCall(array $params, callable $returnEarly): void ); } - protected function methodIsSigned(string $method): bool + private function methodIsSigned(string $method): bool { return $this->component ->getAttributes() ->whereInstanceOf(Signed::class) - ->filter(fn (Signed $attribute) => $attribute->getLevel() === AttributeLevel::METHOD) - ->filter(fn (Signed $attribute) => $attribute->getName() === $method) + ->filter(fn (Signed $attribute) => $attribute->getLevel() === AttributeLevel::METHOD && $attribute->getName() === $method) ->isNotEmpty(); } } diff --git a/src/Features/SupportSignedActions/UnitTest.php b/src/Features/SupportSignedActions/UnitTest.php index 4cceb81..ab3af4c 100644 --- a/src/Features/SupportSignedActions/UnitTest.php +++ b/src/Features/SupportSignedActions/UnitTest.php @@ -520,6 +520,22 @@ public function delete(int $id) })->call('__callSigned', 12345); } + public function test_rejects_callSigned_with_no_params() + { + $this->expectException(InvalidSignedActionException::class); + + LivewireStrict::signedActions(components: 'WireElements\*'); + + Livewire::test(new class extends TestSignedComponent + { + #[Signed] + public function delete(int $id) + { + $this->result = $id; + } + })->call('__callSigned'); + } + public function test_valid_payload_can_be_replayed() { LivewireStrict::signedActions(components: 'WireElements\*'); @@ -924,7 +940,7 @@ public function delete(int $id) // expected } - // Disabling at runtime bypasses all protection — flag for audit + // Disabling at runtime bypasses all protection - flag for audit SupportSignedActions::$enabled = false; $component diff --git a/src/LivewireStrict.php b/src/LivewireStrict.php index 4793cdc..c34c8b5 100644 --- a/src/LivewireStrict.php +++ b/src/LivewireStrict.php @@ -29,7 +29,7 @@ public static function signedActions($shouldSignActions = true, $components = [' { Signed::validateTtl($ttl); - SupportSignedActions::$ttl = $ttl ?: null; + SupportSignedActions::$ttl = $ttl > 0 ? $ttl : null; SupportSignedActions::$enabled = $shouldSignActions; SupportSignedActions::$components = Arr::wrap($components); } diff --git a/src/LivewireStrictServiceProvider.php b/src/LivewireStrictServiceProvider.php index c522225..6cde87d 100644 --- a/src/LivewireStrictServiceProvider.php +++ b/src/LivewireStrictServiceProvider.php @@ -21,6 +21,7 @@ public function register(): void */ public function boot(): void { + // $__livewire is the component instance injected by Livewire's Blade rendering. Blade::directive('livewireAction', function ($expression) { return "toAction(); ?>"; });