Skip to content

Commit 2fa3814

Browse files
committed
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
1 parent 88ee196 commit 2fa3814

7 files changed

Lines changed: 98 additions & 67 deletions

File tree

docs/signed-actions.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ Replace inline method calls with the `@livewireAction` directive:
7878

7979
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)
8080
2. The signed payload is encoded as a base64 string and rendered as `__callSigned('eyJ...')`
81-
3. **When clicked**, the `SupportSignedActions` hook intercepts the call, verifies the HMAC, checks the component ID matches, and only then executes the method
81+
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
8282
4. Direct calls to `#[Signed]` methods (e.g., `$wire.call('delete', 5)`) are **blocked**
8383

8484
### What's protected
@@ -87,6 +87,7 @@ Replace inline method calls with the `@livewireAction` directive:
8787
|--------|--------|
8888
| Change parameters in DOM | ❌ HMAC verification fails |
8989
| Call signed method directly via JS | ❌ Blocked - must use signed payload |
90+
| Call `__callSigned` with no payload | ❌ Blocked - payload parameter required |
9091
| Replay payload on different component | ❌ Component ID mismatch |
9192
| Tamper with expiration timestamp | ❌ HMAC verification fails |
9293
| Use expired payload |`ExpiredSignedActionException` thrown |

src/Attributes/Signed.php

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,11 +39,10 @@ public static function resolveMethodTtl(object $component, string $method, ?int
3939
{
4040
$signed = $component->getAttributes()
4141
->whereInstanceOf(self::class)
42-
->filter(fn (self $attribute) => $attribute->getLevel() === AttributeLevel::METHOD)
43-
->filter(fn (self $attribute) => $attribute->getName() === $method)
42+
->filter(fn (self $attribute) => $attribute->getLevel() === AttributeLevel::METHOD && $attribute->getName() === $method)
4443
->first();
4544

46-
if ($signed && $signed->ttl !== null) {
45+
if ($signed?->ttl !== null) {
4746
return $signed->ttl;
4847
}
4948

src/Features/SupportSignedActions/SignedPayload.php

Lines changed: 72 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -11,31 +11,22 @@ class SignedPayload
1111
{
1212
private const JSON_FLAGS = JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE;
1313

14+
/**
15+
* Constructor is public to allow tests to forge payloads for security assertions.
16+
* Arbitrary instances cannot produce valid signatures without the APP_KEY.
17+
*/
1418
public function __construct(
1519
public readonly string $componentId,
1620
public readonly string $method,
1721
public readonly array $params = [],
1822
public readonly ?int $expiry = null,
1923
) {}
2024

21-
/**
22-
* Get the application signing key, ensuring it is set.
23-
*
24-
* Derives a purpose-specific key via HMAC to provide domain separation.
25-
* This prevents cross-system signature confusion if other subsystems
26-
* also use the raw APP_KEY with hash_hmac('sha256', ...).
27-
*
28-
* @throws \RuntimeException
29-
*/
30-
private static function signingKey(): string
31-
{
32-
throw_unless(config('app.key'), \RuntimeException::class, 'No application key set. Signed actions require an APP_KEY to be configured.');
33-
34-
return hash_hmac('sha256', 'livewire-strict:signed-actions', config('app.key'));
35-
}
36-
3725
/**
3826
* Create a signed payload for a component, resolving per-method TTL overrides.
27+
*
28+
* Note: Signed::resolveMethodTtl() may return 0 (meaning "never expire").
29+
* The `$ttl > 0` check normalizes 0 to null so the payload has no expiry.
3930
*/
4031
public static function forComponent(object $component, string $method, mixed ...$params): self
4132
{
@@ -45,13 +36,21 @@ public static function forComponent(object $component, string $method, mixed ...
4536
componentId: $component->getId(),
4637
method: $method,
4738
params: $params,
48-
expiry: $ttl ? Carbon::now()->timestamp + $ttl : null,
39+
expiry: $ttl > 0 ? Carbon::now()->timestamp + $ttl : null,
4940
);
5041
}
5142

5243
/**
5344
* Verify an encoded payload against a component and return a SignedPayload instance.
5445
*
46+
* Verification order: structure → types → HMAC → expiry → component ID.
47+
* This order avoids timing oracles (HMAC checked before component ID)
48+
* and skips unnecessary work on structurally invalid payloads.
49+
*
50+
* Note: valid payloads can be replayed on the same component instance.
51+
* This is by design - Blade buttons render a fixed payload that must
52+
* remain usable across multiple clicks. Use TTL to limit the replay window.
53+
*
5554
* @throws InvalidSignedActionException
5655
* @throws ExpiredSignedActionException
5756
*/
@@ -65,71 +64,87 @@ public static function verify(string $encodedPayload, object $component): self
6564
);
6665

6766
// Validate types of the decoded payload to avoid TypeError and ensure predictable failures.
68-
$hasInvalidTypes = !is_scalar($decoded['id'])
69-
|| !is_scalar($decoded['method'])
70-
|| !is_scalar($decoded['sig'])
71-
|| !is_array($decoded['params'])
72-
|| (array_key_exists('exp', $decoded) && !is_int($decoded['exp']));
73-
74-
if ($hasInvalidTypes) {
75-
throw new InvalidSignedActionException('');
76-
}
77-
78-
$id = (string) $decoded['id'];
79-
$method = (string) $decoded['method'];
67+
throw_if(
68+
!is_scalar($decoded['id'])
69+
|| !is_scalar($decoded['method'])
70+
|| !is_scalar($decoded['sig'])
71+
|| !is_array($decoded['params'])
72+
|| (array_key_exists('exp', $decoded) && !is_int($decoded['exp'])),
73+
InvalidSignedActionException::class,
74+
);
75+
8076
$sig = (string) $decoded['sig'];
81-
$params = $decoded['params'];
82-
$exp = $decoded['exp'] ?? null;
83-
84-
$payloadData = array_filter([
85-
'id' => $id,
86-
'method' => $method,
87-
'params' => $params,
88-
'exp' => $exp,
89-
], fn ($value) => $value !== null);
9077

91-
$expectedSig = hash_hmac('sha256', json_encode($payloadData, self::JSON_FLAGS), self::signingKey());
78+
$instance = new self(
79+
componentId: (string) $decoded['id'],
80+
method: (string) $decoded['method'],
81+
params: $decoded['params'],
82+
expiry: $decoded['exp'] ?? null,
83+
);
9284

93-
throw_unless(hash_equals($expectedSig, $sig), InvalidSignedActionException::class, $method);
85+
throw_unless(hash_equals($instance->sign(), $sig), InvalidSignedActionException::class, $instance->method);
9486

9587
throw_if(
96-
isset($exp) && Carbon::now()->timestamp > $exp,
88+
isset($instance->expiry) && Carbon::now()->timestamp > $instance->expiry,
9789
ExpiredSignedActionException::class,
98-
$method,
90+
$instance->method,
9991
);
10092

101-
throw_unless($id === $component->getId(), InvalidSignedActionException::class, $method);
93+
throw_unless($instance->componentId === $component->getId(), InvalidSignedActionException::class, $instance->method);
10294

103-
return new self(
104-
componentId: $id,
105-
method: $method,
106-
params: $params,
107-
expiry: $exp,
108-
);
95+
return $instance;
10996
}
11097

11198
/**
11299
* Encode the payload into a signed, base64-encoded string.
113100
*/
114101
public function encode(): string
115102
{
116-
$payloadData = array_filter([
103+
return base64_encode(json_encode(array_merge($this->payloadData(), ['sig' => $this->sign()])));
104+
}
105+
106+
/**
107+
* Get the wire action string for use in Blade templates.
108+
*/
109+
public function toAction(): string
110+
{
111+
return "__callSigned('{$this->encode()}')";
112+
}
113+
114+
/**
115+
* Get the application signing key, ensuring it is set.
116+
*
117+
* Derives a purpose-specific key via HMAC to provide domain separation.
118+
* This prevents cross-system signature confusion if other subsystems
119+
* also use the raw APP_KEY with hash_hmac('sha256', ...).
120+
*
121+
* @throws \RuntimeException
122+
*/
123+
private static function signingKey(): string
124+
{
125+
throw_unless(config('app.key'), \RuntimeException::class, 'No application key set. Signed actions require an APP_KEY to be configured.');
126+
127+
return hash_hmac('sha256', 'livewire-strict:signed-actions', config('app.key'));
128+
}
129+
130+
/**
131+
* Build the canonical payload data array used for signing.
132+
*/
133+
private function payloadData(): array
134+
{
135+
return array_filter([
117136
'id' => $this->componentId,
118137
'method' => $this->method,
119138
'params' => $this->params,
120139
'exp' => $this->expiry,
121140
], fn ($value) => $value !== null);
122-
123-
$signature = hash_hmac('sha256', json_encode($payloadData, self::JSON_FLAGS), self::signingKey());
124-
125-
return base64_encode(json_encode(array_merge($payloadData, ['sig' => $signature])));
126141
}
127142

128143
/**
129-
* Get the wire action string for use in Blade templates.
144+
* Compute the HMAC-SHA256 signature for this payload.
130145
*/
131-
public function toAction(): string
146+
private function sign(): string
132147
{
133-
return "__callSigned('{$this->encode()}')";
148+
return hash_hmac('sha256', json_encode($this->payloadData(), self::JSON_FLAGS), self::signingKey());
134149
}
135150
}

src/Features/SupportSignedActions/SupportSignedActions.php

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ public function call($method, $params, $returnEarly, $metadata, $componentContex
3939
}
4040
}
4141

42-
protected function handleSignedCall(array $params, callable $returnEarly): void
42+
private function handleSignedCall(array $params, callable $returnEarly): void
4343
{
4444
throw_if(
4545
method_exists($this->component, '__callSigned'),
@@ -66,13 +66,12 @@ protected function handleSignedCall(array $params, callable $returnEarly): void
6666
);
6767
}
6868

69-
protected function methodIsSigned(string $method): bool
69+
private function methodIsSigned(string $method): bool
7070
{
7171
return $this->component
7272
->getAttributes()
7373
->whereInstanceOf(Signed::class)
74-
->filter(fn (Signed $attribute) => $attribute->getLevel() === AttributeLevel::METHOD)
75-
->filter(fn (Signed $attribute) => $attribute->getName() === $method)
74+
->filter(fn (Signed $attribute) => $attribute->getLevel() === AttributeLevel::METHOD && $attribute->getName() === $method)
7675
->isNotEmpty();
7776
}
7877
}

src/Features/SupportSignedActions/UnitTest.php

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -520,6 +520,22 @@ public function delete(int $id)
520520
})->call('__callSigned', 12345);
521521
}
522522

523+
public function test_rejects_callSigned_with_no_params()
524+
{
525+
$this->expectException(InvalidSignedActionException::class);
526+
527+
LivewireStrict::signedActions(components: 'WireElements\*');
528+
529+
Livewire::test(new class extends TestSignedComponent
530+
{
531+
#[Signed]
532+
public function delete(int $id)
533+
{
534+
$this->result = $id;
535+
}
536+
})->call('__callSigned');
537+
}
538+
523539
public function test_valid_payload_can_be_replayed()
524540
{
525541
LivewireStrict::signedActions(components: 'WireElements\*');
@@ -924,7 +940,7 @@ public function delete(int $id)
924940
// expected
925941
}
926942

927-
// Disabling at runtime bypasses all protection flag for audit
943+
// Disabling at runtime bypasses all protection - flag for audit
928944
SupportSignedActions::$enabled = false;
929945

930946
$component

src/LivewireStrict.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ public static function signedActions($shouldSignActions = true, $components = ['
2929
{
3030
Signed::validateTtl($ttl);
3131

32-
SupportSignedActions::$ttl = $ttl ?: null;
32+
SupportSignedActions::$ttl = $ttl > 0 ? $ttl : null;
3333
SupportSignedActions::$enabled = $shouldSignActions;
3434
SupportSignedActions::$components = Arr::wrap($components);
3535
}

src/LivewireStrictServiceProvider.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ public function register(): void
2121
*/
2222
public function boot(): void
2323
{
24+
// $__livewire is the component instance injected by Livewire's Blade rendering.
2425
Blade::directive('livewireAction', function ($expression) {
2526
return "<?php echo \\WireElements\\LivewireStrict\\Features\\SupportSignedActions\\SignedPayload::forComponent(\$__livewire, $expression)->toAction(); ?>";
2627
});

0 commit comments

Comments
 (0)