diff --git a/composer.json b/composer.json index b54a884..e89da91 100644 --- a/composer.json +++ b/composer.json @@ -30,10 +30,13 @@ }, "minimum-stability": "dev", "prefer-stable": true, + "scripts": { + "test": "vendor/bin/phpunit" + }, "require-dev": { "phpunit/phpunit": "^10.4", "laravel/framework": "^10.15.0|^11.0", "orchestra/testbench": "^8.21.0|^9.1", - "livewire/livewire": "^3.5" + "livewire/livewire": "^3.5|^4.0" } } diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..1a965ec --- /dev/null +++ b/docs/README.md @@ -0,0 +1,80 @@ +# Livewire Strict - Documentation + +Livewire Strict enforces additional security measures for your [Livewire](https://livewire.laravel.com) components, preventing common attack vectors that exploit Livewire's frontend-exposed surface. + +## Installation + +```bash +composer require wire-elements/livewire-strict +``` + +The package auto-registers its service provider via Laravel's package discovery. + +## Quick Start + +Enable all features at once in your `AppServiceProvider`: + +```php +use WireElements\LivewireStrict\LivewireStrict; + +class AppServiceProvider extends ServiceProvider +{ + public function boot(): void + { + LivewireStrict::enableAll(); + } +} +``` + +Or enable features individually: + +```php +LivewireStrict::lockProperties(); +LivewireStrict::signedActions(ttl: 300); +``` + +## Features + +| Feature | What it protects | Docs | +|---------|-----------------|------| +| [Locked Properties](locked-properties.md) | Prevents frontend from modifying public properties | [Read more →](locked-properties.md) | +| [Signed Actions](signed-actions.md) | Makes action calls tamper-proof with HMAC signatures | [Read more →](signed-actions.md) | + +## How it works + +Every Livewire request sends a JSON payload from the browser to the server. An attacker can craft these payloads manually to: + +1. **Modify any public property** - e.g., changing `$price` or `$user_id` +2. **Alter action parameters** - e.g., changing `wire:click="delete(5)"` to `delete(999)` + +Livewire Strict closes these gaps by requiring explicit opt-in for property modifications and cryptographic signing for sensitive action calls. + +## Configuration + +### Scoping to specific components + +All features accept a `components` parameter to scope enforcement: + +```php +// All components under App\Livewire (default) +LivewireStrict::lockProperties(); + +// Specific namespace +LivewireStrict::lockProperties(components: 'App\Livewire\Admin\*'); + +// Specific component +LivewireStrict::lockProperties(components: App\Livewire\Checkout::class); + +// Multiple patterns +LivewireStrict::lockProperties(components: [ + 'App\Livewire\Admin\*', + 'App\Livewire\Checkout', +]); +``` + +## Requirements + +- PHP 8.1+ +- Laravel 10, 11, or later +- Livewire 3.5+ or 4.0+ +- A valid `APP_KEY` (required for signed actions) 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..8cafd2f --- /dev/null +++ b/docs/signed-actions.md @@ -0,0 +1,172 @@ +# Signed Actions + +Makes Livewire action calls tamper-proof by signing the method name, parameters, and component instance with HMAC-SHA256. Prevents attackers from modifying action parameters in the DOM. + +## The Problem + +When you write `wire:click="delete({{ $post->id }})"`, Livewire renders the method call directly in the HTML. An attacker can use browser DevTools to change the argument before clicking: + +```html + + + + + +``` + +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 a purpose-specific key derived from your `APP_KEY` (domain-separated so that other subsystems sharing the same key cannot produce cross-valid signatures) +2. The signed payload is encoded as a base64 string and rendered as `__callSigned('eyJ...')` +3. **When clicked**, the `SupportSignedActions` hook intercepts the call and verifies in sequence: payload structure → field types → HMAC signature → expiry (if TTL is set) → component ID match. Only then is the method executed +4. Direct calls to `#[Signed]` methods (e.g., `$wire.call('delete', 5)`) are **blocked** + +### What's protected + +| Attack | Result | +|--------|--------| +| Change parameters in DOM | ❌ HMAC verification fails | +| Call signed method directly via JS | ❌ Blocked - must use signed payload | +| Call `__callSigned` with no payload | ❌ Blocked - payload parameter required | +| Replay payload on different component | ❌ Component ID mismatch | +| Tamper with expiration timestamp | ❌ HMAC verification fails | +| Use expired payload | ❌ `ExpiredSignedActionException` thrown | + +> **Note:** Valid payloads can be replayed on the same component (e.g., clicking a button multiple times). This is intentional - Blade buttons render a fixed payload that must remain usable. Use TTL to limit the replay window. + +### Requirements + +- Signed actions require a valid `APP_KEY` to be configured. If the key is missing, a `RuntimeException` is thrown immediately when encoding or verifying a payload. +- Components must **not** define their own `__callSigned()` method - this name is reserved by the signed-action hook. If a collision is detected, a `LogicException` is thrown. + +## Payload Expiration + +Set a TTL to limit how long signed payloads remain valid: + +```php +// Payloads expire after 5 minutes +LivewireStrict::signedActions(ttl: 300); + +// No expiration (default) +LivewireStrict::signedActions(); + +// Explicitly no expiration (0 is treated the same as null) +LivewireStrict::signedActions(ttl: 0); +``` + +With a TTL, payloads include a signed timestamp. After expiration, the action is rejected with an `ExpiredSignedActionException`. The timestamp is part of the HMAC, so attackers cannot extend it. + +### Per-method TTL + +You can override the global TTL on individual methods using the `ttl` parameter on `#[Signed]`: + +```php +use WireElements\LivewireStrict\Attributes\Signed; + +class OrderManager extends Component +{ + // Uses the global TTL + #[Signed] + public function archive(int $orderId) { ... } + + // Stricter: expires after 30 seconds + #[Signed(ttl: 30)] + public function refund(int $orderId, int $amount) { ... } + + // No expiration, even if global TTL is set + #[Signed(ttl: 0)] + public function viewDetails(int $orderId) { ... } +} +``` + +Per-method TTL takes precedence over the global TTL. If a method has no `ttl` parameter, the global TTL is used. + +Negative TTL values are rejected with an `InvalidArgumentException`, both at the global level and per-method level. + +**Choosing a TTL:** Consider how long a page stays open before a user interacts. For admin panels, 5-15 minutes is reasonable. For long-lived dashboards, use a longer TTL or disable expiration. + +## Scoping + +```php +// Only admin components +LivewireStrict::signedActions(components: 'App\Livewire\Admin\*'); + +// Specific component with 10-minute expiry +LivewireStrict::signedActions( + components: App\Livewire\PostList::class, + ttl: 600 +); +``` + +## When to Use Signed Actions + +**Use `#[Signed]` for methods where the parameters are security-sensitive:** +- Deleting records: `delete($id)` +- Changing roles/permissions: `updateRole($userId, $role)` +- Financial operations: `refund($orderId, $amount)` +- Any action where a tampered parameter leads to unauthorized behavior + +**You don't need `#[Signed]` for (but it still works if you do):** +- Methods with no parameters: `loadMore()`, `refresh()` +- Methods where parameters come from locked server-side state +- Methods that re-validate authorization internally regardless of input diff --git a/src/Attributes/Signed.php b/src/Attributes/Signed.php new file mode 100644 index 0000000..f90dfc5 --- /dev/null +++ b/src/Attributes/Signed.php @@ -0,0 +1,51 @@ +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 && $attribute->getName() === $method) + ->first(); + + if ($signed?->ttl !== null) { + return $signed->ttl; + } + + return $globalTtl; + } +} 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/Exceptions/ExpiredSignedActionException.php b/src/Features/SupportSignedActions/Exceptions/ExpiredSignedActionException.php new file mode 100644 index 0000000..78862e3 --- /dev/null +++ b/src/Features/SupportSignedActions/Exceptions/ExpiredSignedActionException.php @@ -0,0 +1,15 @@ + 0` check normalizes 0 to null so the payload has no expiry. + */ + public static function forComponent(object $component, string $method, mixed ...$params): self + { + $ttl = Signed::resolveMethodTtl($component, $method, SupportSignedActions::$ttl); + + return new self( + componentId: $component->getId(), + method: $method, + params: $params, + 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 + */ + 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, + ); + + // Validate types of the decoded payload to avoid TypeError and ensure predictable failures. + 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']; + + $instance = new self( + componentId: (string) $decoded['id'], + method: (string) $decoded['method'], + params: $decoded['params'], + expiry: $decoded['exp'] ?? null, + ); + + throw_unless(hash_equals($instance->sign(), $sig), InvalidSignedActionException::class, $instance->method); + + throw_if( + isset($instance->expiry) && Carbon::now()->timestamp > $instance->expiry, + ExpiredSignedActionException::class, + $instance->method, + ); + + throw_unless($instance->componentId === $component->getId(), InvalidSignedActionException::class, $instance->method); + + return $instance; + } + + /** + * Encode the payload into a signed, base64-encoded string. + */ + public function encode(): string + { + 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); + } + + /** + * Compute the HMAC-SHA256 signature for this payload. + */ + private function sign(): string + { + 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 new file mode 100644 index 0000000..3f02379 --- /dev/null +++ b/src/Features/SupportSignedActions/SupportSignedActions.php @@ -0,0 +1,77 @@ +checkIsRequired()) { + return; + } + + if ($method === '__callSigned') { + $this->handleSignedCall($params, $returnEarly); + + return; + } + + if ($this->methodIsSigned($method)) { + throw new InvalidSignedActionException($method); + } + } + + private function handleSignedCall(array $params, callable $returnEarly): void + { + 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) + ); + } + + private function methodIsSigned(string $method): bool + { + return $this->component + ->getAttributes() + ->whereInstanceOf(Signed::class) + ->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 new file mode 100644 index 0000000..ab3af4c --- /dev/null +++ b/src/Features/SupportSignedActions/UnitTest.php @@ -0,0 +1,966 @@ +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_allows_non_signed_methods() + { + LivewireStrict::signedActions(components: 'WireElements\*'); + + Livewire::test(new class extends TestSignedComponent + { + public function save() + { + $this->result = 'saved'; + } + }) + ->call('save') + ->assertSet('result', 'saved'); + } + + 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\*'); + + $component = Livewire::test(new class extends TestSignedComponent + { + #[Signed] + public function delete(int $id) + { + $this->result = $id; + } + }); + + $payload = SignedPayload::forComponent($component->instance(), 'delete', 5); + + $component + ->call('__callSigned', $payload->encode()) + ->assertSet('result', 5); + } + + 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); + + LivewireStrict::signedActions(components: 'WireElements\*'); + + $component = Livewire::test(new class extends TestSignedComponent + { + #[Signed] + public function delete(int $id) + { + $this->result = $id; + } + }); + + $encoded = SignedPayload::forComponent($component->instance(), 'delete', 5)->encode(); + + $decoded = json_decode(base64_decode($encoded), true); + $decoded['params'] = [999]; + $tampered = base64_encode(json_encode($decoded)); + + $component->call('__callSigned', $tampered); + } + + public function test_rejects_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 = (new SignedPayload('wrong-id', 'delete', [5]))->encode(); + + $component->call('__callSigned', $payload); + } + + 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_payload_targeting_non_signed_method() + { + $this->expectException(InvalidSignedActionException::class); + + LivewireStrict::signedActions(components: 'WireElements\*'); + + $component = Livewire::test(new class extends TestSignedComponent + { + public function save() + { + $this->result = 'should not run'; + } + }); + + $payload = (new SignedPayload($component->instance()->getId(), 'save'))->encode(); + + $component->call('__callSigned', $payload); + } + + // ────────────────────────────────────────────────────────── + // Component matching + // ────────────────────────────────────────────────────────── + + public function test_enforces_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_ignores_non_matching_namespace() + { + 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); + } + + // ────────────────────────────────────────────────────────── + // TTL: global expiration + // ────────────────────────────────────────────────────────── + + public function test_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 = SignedPayload::forComponent($component->instance(), 'delete', 5); + + $component + ->call('__callSigned', $payload->encode()) + ->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 = SignedPayload::forComponent($component->instance(), 'delete', 5); + + $this->travel(301)->seconds(); + + $component->call('__callSigned', $payload->encode()); + } + + public function test_payload_without_ttl_never_expires() + { + LivewireStrict::signedActions(components: 'WireElements\*'); + + $component = Livewire::test(new class extends TestSignedComponent + { + #[Signed] + public function delete(int $id) + { + $this->result = $id; + } + }); + + $payload = SignedPayload::forComponent($component->instance(), 'delete', 5); + + $this->travel(7)->days(); + + $component + ->call('__callSigned', $payload->encode()) + ->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; + } + }); + + $encoded = SignedPayload::forComponent($component->instance(), 'delete', 5)->encode(); + + $decoded = json_decode(base64_decode($encoded), true); + $decoded['exp'] = time() + 99999; + $tampered = base64_encode(json_encode($decoded)); + + $this->travel(120)->seconds(); + + $component->call('__callSigned', $tampered); + } + + // ────────────────────────────────────────────────────────── + // 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 + { + #[Signed(ttl: 60)] + public function delete(int $id) + { + $this->result = $id; + } + }); + + $payload = SignedPayload::forComponent($component->instance(), 'delete', 5); + + // 61s past per-method TTL of 60, but within global TTL of 300 + $this->travel(61)->seconds(); + + $component->call('__callSigned', $payload->encode()); + } + + 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; + } + }); + + $payload = SignedPayload::forComponent($component->instance(), 'delete', 5); + + $this->travel(30)->seconds(); + + $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 + { + #[Signed] + public function delete(int $id) + { + $this->result = $id; + } + }); + + $payload = SignedPayload::forComponent($component->instance(), 'delete', 5); + + $this->travel(121)->seconds(); + + $component->call('__callSigned', $payload->encode()); + } + + public function test_per_method_ttl_zero_disables_expiration() + { + LivewireStrict::signedActions(components: 'WireElements\*', ttl: 60); + + $component = Livewire::test(new class extends TestSignedComponent + { + #[Signed(ttl: 0)] + public function delete(int $id) + { + $this->result = $id; + } + }); + + $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 = SignedPayload::forComponent($component->instance(), 'delete', 5); + + $this->travel(9999)->seconds(); + + $component + ->call('__callSigned', $payload->encode()) + ->assertSet('result', 5); + } + + // ────────────────────────────────────────────────────────── + // 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_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\*'); + + $component = Livewire::test(new class extends TestSignedComponent + { + public int $counter = 0; + + #[Signed] + public function increment() + { + $this->counter++; + } + }); + + $encoded = SignedPayload::forComponent($component->instance(), 'increment')->encode(); + + $component + ->call('__callSigned', $encoded) + ->assertSet('counter', 1) + ->call('__callSigned', $encoded) + ->assertSet('counter', 2) + ->call('__callSigned', $encoded) + ->assertSet('counter', 3); + } + + public function test_missing_app_key_throws_runtime_exception() + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('No application key set.'); + + config()->set('app.key', null); + + (new SignedPayload('test-id', 'delete', [5]))->encode(); + } + + public function test_rejects_component_defining_callSigned_method() + { + $this->expectException(\LogicException::class); + $this->expectExceptionMessage('defines a __callSigned method'); + + 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'); + } + + // ────────────────────────────────────────────────────────── + // 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\*'); + + $component = Livewire::test(new class extends TestSignedComponent + { + #[Signed] + public function delete(int $id) + { + $this->result = $id; + } + }); + + $payload = SignedPayload::forComponent($component->instance(), 'delete', 5); + $action = $payload->toAction(); + + $this->assertStringStartsWith("__callSigned('", $action); + $this->assertStringEndsWith("')", $action); + + // 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); + } + + // ────────────────────────────────────────────────────────── + // 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); + } +} + +// ────────────────────────────────────────────────────────── +// Test components +// ────────────────────────────────────────────────────────── + +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..c34c8b5 100644 --- a/src/LivewireStrict.php +++ b/src/LivewireStrict.php @@ -3,7 +3,9 @@ namespace WireElements\LivewireStrict; use Illuminate\Support\Arr; +use WireElements\LivewireStrict\Attributes\Signed; use WireElements\LivewireStrict\Features\SupportLockedProperties\SupportLockedProperties; +use WireElements\LivewireStrict\Features\SupportSignedActions\SupportSignedActions; class LivewireStrict { @@ -13,6 +15,25 @@ 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. + * - 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 > 0 ? $ttl : null; + SupportSignedActions::$enabled = $shouldSignActions; + SupportSignedActions::$components = Arr::wrap($components); + } + public static function enableAll($condition = true) { if (! $condition) { @@ -20,5 +41,6 @@ public static function enableAll($condition = true) } self::lockProperties(); + self::signedActions(); } } diff --git a/src/LivewireStrictServiceProvider.php b/src/LivewireStrictServiceProvider.php index 5923ccf..6cde87d 100644 --- a/src/LivewireStrictServiceProvider.php +++ b/src/LivewireStrictServiceProvider.php @@ -2,15 +2,28 @@ 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 + { + // $__livewire is the component instance injected by Livewire's Blade rendering. + Blade::directive('livewireAction', function ($expression) { + return "toAction(); ?>"; + }); } }