
最近在给项目做 Livewire 安全审计,踩了几个坑,这篇把问题说清楚。
Livewire 组件里的每个 public 属性都会发送到浏览器。每次都是。Livewire 用来维护状态的快照(snapshot)把每个 public 属性的值都明文塞进了 JSON 里。你的用户能看到它们、修改它们、然后发回到服务器。
大多数 Laravel 开发者从来没想过这个。他们写 public $userId 的方式跟在其他 PHP 类里写 protected 属性一模一样。区别在于:普通 PHP 属性只存在于服务端。Livewire 的 public 属性两端都存在,而且客户端不是你的。
这篇文章讲清楚:会出什么问题、线上已经出过什么问题、以及怎么在 30 分钟内审计自己的组件。
Livewire 渲染组件时,会把所有 public 属性序列化成 JSON 快照,嵌入到页面中。每次后续请求(点击按钮、表单提交、wire:model 更新),浏览器把那个快照发回服务器。Livewire 从快照恢复组件状态、执行更新、再发回新的快照。
攻击向量很简单:快照经过浏览器,而浏览器是被用户控制的。
快照里的任何东西都可以在发回来之前被篡改。ID、状态标识、价格、权限——不管你的 public 属性装的是什么。
看这个组件,代码看起来很正常,但其实是存在漏洞的:
use Livewire\Component; class EditProfile extends Component
{ public $userId; public $name; public $email; public function mount($userId) { $this->userId = $userId; $user = User::find($userId); $this->name = $user->name; $this->email = $user->email; } public function save() { User::find($this->userId)->update([ 'name' => $this->name, 'email' => $this->email, ]); }
}
问题在哪:$userId 是 public 的。用户加载自己的资料页面时,会在快照里看到自己的 ID。他打开浏览器开发者工具,把 ID 改成另一个用户的 ID,下次调用 save() 就修改了别人的资料。无需绕过认证,无需 SQL 注入,只是修改了一个服务器无条件信任的 JSON 值。
这种模式在真实代码库里出现的频率非常高。任何组件,只要 public 属性决定了谁的数据被读取或写入,而这个属性没有被锁定或操作没有重新鉴权,那就有漏洞。
2025 年 7 月,Livewire v3(3.0.0-beta.1 到 3.6.3 版本)披露了一个严重漏洞(CVE-2025-54068)。问题出在属性恢复(hydration)机制本身,攻击者可以通过构造恶意的属性更新来实现远程代码执行(RCE)。无需认证,无需用户交互。
该漏洞已在 v3.6.4 中修复。如果你还在跑旧版本,立即升级。
这个 CVE 带来的更广泛教训是:属性恢复管道是一个真实的攻击面。RCE 是最严重的情况,但普通意义上的属性操作(改 ID、切换标志位、修改金额)是任何懂浏览器开发者工具的人现在就能对你的组件做的事。
Livewire v3 引入了 #[Locked] 注解,就是来解决这个问题的。当属性被锁定后,任何从客户端修改它的尝试都会抛出异常。
use Livewire\Attributes\Locked;
use Livewire\Component; class EditProfile extends Component
{ #[Locked] public $userId; public $name; public $email; // ...
}
现在如果有人在快照里修改 $userId,Livewire 会在你的代码运行之前就拒绝这个请求。这是最简单的修复方案,适用于任何在 mount() 里设置之后就不应该再变的属性。
别忘了导入:use Livewire\Attributes\Locked;。漏掉了就是静默失败。
当你把完整的 Eloquent 模型存成属性时,Livewire 会自动保护模型的 ID:
class EditProfile extends Component
{ public User $user; public function mount(User $user) { $this->user = $user; } public function save() { $this->user->update([ 'name' => $this->name, 'email' => $this->email, ]); }
}
Livewire 确保模型的 ID 无法被篡改。无需 #[Locked]。这是大多数操作单个模型的组件推荐的做法。如果你存的是 $postId 或 $userId 这样的纯整数,问问自己为什么不直接存模型。
即使属性被锁定了,操作方法的参数仍然可以被修改。你在 Blade 模板里写的 wire:click="delete({{ $post->id }})" 会把帖子 ID 作为参数发出去,而那个参数在浏览器里是可以改的。
始终做鉴权:
public function delete($postId)
{ $post = Post::findOrFail($postId); if ($post->user_id !== auth()->id()) { abort(403); } $post->delete();
}
永远不要假设从浏览器发来的值和你渲染出去的值是一样的。把每个 Livewire 操作参数当成 POST 请求参数来对待,每次都验证和鉴权。
对你的 Livewire 组件跑以下搜索。每个都能找到一个潜在的漏洞。
找出没有锁定的 ID 属性:
grep -rn 'public \$.*[Ii]d' app/Livewire/ | grep -v '#\[Locked\]'
任何存了 ID 但没有 #[Locked] 或没有作为完整 Eloquent 模型绑定的结果,都是需要修复的对象。
找出信任参数的操作方法:
grep -rn 'function delete\|function update\|function remove\|function approve' app/Livewire/
检查每个结果:方法有没有验证当前登录用户是否有权限在那个特定资源上执行操作?如果从参数直接到数据库查询而没有鉴权,那就是有漏洞的。
找出组件里用 $this->someId 查询但没有鉴权的情况:
grep -rn 'find(\$this->' app/Livewire/
每个 find($this->someId) 后面都应该跟着鉴权检查,或者该属性应该加了 #[Locked]。
修完这些模式之后,写 Pest 测试来尝试篡改锁定属性的值,验证异常是否被正确抛出。自动化测试能在有人不小心移除了 #[Locked] 注解时及时发现回归。
有几件事 Livewire 会帮你处理,知道这些有好处:
中间件重新应用。如果你的 Livewire 组件通过带有认证中间件的路由加载(比如 can:update,post),Livewire 会在每次后续请求时重新应用那个中间件。所以用户加载页面后如果失去了权限,下一次交互就会被拦截。
模型属性 ID。如前所述,把完整的 Eloquent 模型存成 public User $user 会自动保护模型 ID。
校验和验证。Livewire 用应用密钥给快照签名。这能防止完全伪造快照。但它不能防止修改单个属性值,因为校验和覆盖的是快照的结构,而不是可变属性的内容。
Livewire 不保护的东西:普通 public 属性值(整数、字符串、布尔值)、操作方法参数,以及任何你没有显式锁定的属性。
应该给每个 public 属性都加锁吗?
不。wire:model 绑定的属性需要可修改。锁定的是在 mount() 里设置、之后不应该再变化的东西:ID、用户引用、权限标识、任何决定组件操作谁的数据的东西。
#[Locked] 在 Livewire 4 里能用吗?
能。注解在 Livewire v3 和 v4 中都存在。两个版本的导入都是 use Livewire\Attributes\Locked;。
能用 protected 属性代替吗?
protected 属性在 Livewire 请求之间不会持久化。它们适合用来存一次性设置的静态值,但如果任何需要在用户交互之间存活下来的运行时数据必须是 public 属性。这就是为什么 #[Locked] 存在:给你 public 属性的持久化,加上 protected 属性一样的安全性。
这是 Livewire 特有的问题吗?Inertia 也有同样的问题吗?
Inertia 也把 props 发到前端,但 Inertia 的 props 在客户端是只读的。客户端不会在后续请求里把它们发回来。Livewire 的双向同步才是产生这个攻击面的原因。Inertia 表单使用显式的 POST 请求并验证数据,所以模式根本不同。
我的应用有登录认证。我还有风险吗?
有。这个攻击不需要未认证。登录用户可以修改自己组件的快照来访问或修改别人的数据。认证证明的是"这个人是谁",鉴权证明的是"这个人被允许做什么"。两个都需要。
每次在 Livewire 组件里写 public $something 的时候,问自己一个问题:如果用户改了这个值会怎样?如果答案是"会有坏事发生",就用 #[Locked] 锁定它,或者直接存完整的 Eloquent 模型。
上面那个 30 分钟审计能抓住最常见的模式。跑一遍、修掉发现的问题、在脑子里把 #[Locked] 当成任何决定数据归属的属性的默认选择。
应用层的安全和服务器层的安全是两个不同的问题。基础设施层面,《VPS 加固指南》讲了怎么关闭端口、隐藏 IP、锁定 SSH。两个层面都重要。