Skip to content
LaravelBlog

livewire #10361: [4.x] Fix authorization using `#[Authorize]` always fallback to component property

LaravelBlogBot
LaravelBlogBot

Contributed by ghabriel25

Part of the livewire v4.4.7 release

Categories: Security (10), Fix (3)

Pull request #10361 patches an authorization bypass in Livewire 4's #[Authorize] attribute. When an action received a model id positionally — for example wire:click="editPost('2')" — the authorization layer failed to resolve that id and silently fell back to the component property, so your policy was evaluated against the wrong model. Requests for records the user is not allowed to touch could be approved. The fix makes argument resolution check both named and positional arguments. If you're upgrading from v3.8.10, the #[Authorize] feature itself is new in v4 and this fix ships with it, but the resolution rules described below apply from day one — so they're worth understanding.

What changed

The fix lives in src/Features/SupportAuthorization/BaseAuthorize.php, specifically in resolveArgument() — the helper that turns an argument name like 'post' in #[Authorize('edit', 'post')] into an actual value for your policy or gate.

The old logic (simplified):

// Only named arguments were resolved
if ($methodArgument instanceof \ReflectionParameter && isset($parameters[$arg])) {
    return app($methodArgument->getType()->getName())
        ->resolveRouteBinding($parameters[$arg]);
}

// Everything else fell back to the component property
return data_get($this->component, $arg);

Because editPost('2') produces a numerically-keyed parameters array ([0 => '2']), the isset($parameters['post']) check never matched, so the policy was always evaluated against $this->post — whatever the component had mounted. The new implementation checks the named key first, then the parameter's positional index, and only falls back to the component property when no argument was provided at all.

Why it matters

Route-model-style authorization is a headline use case for #[Authorize], and this is exactly where the bypass lived:

class PostPolicy
{
    public function edit(User $user, Post $post)
    {
        return (int) $post->id === 1;
    }
}

class EditPost extends Component
{
    public ?Post $post;

    public function mount()
    {
        $this->post = Post::find(1); // an authorized model cached on the component
    }

    #[Authorize('edit', 'post')]
    public function editPost(Post $post)
    {
        // ...
    }
}
{{-- Policy was checked against $this->post (id 1), not the id being sent --}}
<button wire:click="editPost('2')">Edit</button>

With the buggy resolver, this request was approved even though the user asked to edit post 2. The automated tests missed it because ->call('editPost', post: 1) uses named arguments, which happened to exercise the working code path.

How arguments are resolved now

The resolver now follows a strict priority order:

  1. Named argument — editPost({ post: 2 }) or $dispatch('edit-post', { post: 2 }) matches the post key.
  2. Positional argument — editPost('2') is looked up by the parameter's index.
  3. Component property fallback — only when no argument was supplied.

There's also a second behavioral change: if an id is provided but doesn't resolve to a model, a ModelNotFoundException (tagged with the model class) is thrown instead of silently authorizing against the component property. This applies to action methods and to protected event listeners using #[On(...)] alike.

Upgrade impact for v3.8.10 users

  • No migration steps. #[Authorize] doesn't exist in v3, so there's nothing to adjust during the upgrade; v4 releases containing #10361 have the correct behavior out of the box.
  • If you tested 4.x pre-releases, flows that previously "passed" may now correctly return a 403, and unresolvable ids will now throw instead of quietly substituting the component property.
  • If you deliberately leaned on the property fallback while still passing an id argument, that pattern no longer works — pass the id you actually mean to authorize, or drop the argument entirely so the property is used.

Verifying the fix

The PR adds test coverage confirming both allowed and denied named-argument dispatches:

Livewire::actingAs(User::find(2))
    ->test(EditPost::class)
    ->dispatch('edit-post', post: 2)
    ->assertForbidden();

And from the browser side, with the policy above:

<button wire:click="editPost('1')">Edit</button>                         {{-- allowed --}}
<button wire:click="editPost({ post: '1' })">Edit</button>               {{-- allowed --}}
<button wire:click="$dispatch('edit-post', { post: '1' })">Edit</button> {{-- allowed --}}

<button wire:click="editPost('2')">Edit</button>                         {{-- denied --}}
<button wire:click="editPost({ post: '2' })">Edit</button>               {{-- denied --}}
<button wire:click="$dispatch('edit-post', { post: '2' })">Edit</button> {{-- denied --}}

Takeaways

  • #[Authorize] argument resolution now checks named and positional action arguments before falling back to component properties, closing the bypass in #10361.
  • Policies are always evaluated against the model resolved from the id the client actually sent.
  • Unresolvable ids throw ModelNotFoundException; unauthorized models are denied with a 403.
  • Upgrading from v3.8.10 requires no extra steps — the correct behavior ships with v4.
  • If you relied on the property fallback while passing a conflicting id, rework those actions before deploying.

Sources

More from this release

Related Articles