如何在 Symfony 中实现 CSRF 防护

CSRF,即跨站请求伪造,是一种诱导用户在不知情、未同意的情况下对 Web 应用执行操作的攻击。攻击利用 Web 应用对用户浏览器的信任,例如浏览器携带的会话 Cookie。下面是原文用来说明攻击的示例:攻击者可以构造这样的页面。

<html>
    <body>
        <form action="https://example.com/settings/update-email" method="POST">
            <input type="hidden" name="email" value="malicious-actor-address@some-domain.com"/>
        </form>
        <script>
            document.forms[0].submit();
        </script>

        <!-- some content here to distract the user -->
    </body>
</html>

如果用户访问该页面,例如点开邮件或社交网络中的链接,并且已经登录 https://example.com,攻击者可能在用户未察觉时修改其账户邮箱,进而接管账户。

有效防护方式之一是防 CSRF 令牌:在表单隐藏字段中加入唯一令牌,由合法服务器验证请求来自预期来源,而不是恶意网站。令牌可以采用有状态方式,存于会话,并按用户和操作区分;也可以采用无状态方式,在客户端产生令牌。

安装

Symfony 提供产生和验证防 CSRF 令牌的功能。使用前,在项目中安装以下包:

$ composer require symfony/security-csrf

然后通过 csrf_protection 选项启用或关闭防护。配置详情见 CSRF 配置参考。

YAML

# config/packages/framework.yaml
framework:
    # ...
    csrf_protection: true

PHP

// config/packages/framework.php
namespace Symfony\Component\DependencyInjection\Loader\Configurator;

return App::config([
    'framework' => [
        'csrf_protection' => true,
    ],
]);

默认情况下,CSRF 令牌存储在会话中,因此一旦渲染启用了 CSRF 防护的表单,就会自动启动会话。缓存包含这类表单的页面时,可以采用以下策略:

  • 将表单放入不缓存的 ESI 片段,缓存页面其余部分。
  • 缓存整个页面,再用不缓存的 AJAX 请求加载表单。
  • 缓存整个页面,使用 hinclude.js 通过不缓存的 AJAX 请求取得令牌并替换相应字段。

原文推荐的有效缓存方式是使用下文介绍的无状态 CSRF 令牌。

Symfony 表单中的 CSRF 防护

Symfony Forms 默认包含令牌,也会自动验证。因此使用该表单组件时,通常不必额外编写防 CSRF 代码。

按照 OWASP 最佳实践,CSRF 防护针对会改变状态的操作;这类操作不应使用 GET。在 GET 参数里放入 CSRF 令牌还可能通过浏览器历史、日志、网络工具和 Referer 头泄漏令牌。只读搜索这样的 GET 表单可配置为关闭 CSRF 防护。

默认隐藏字段名是 _token,可以统一修改,也可以为单个表单修改。全局配置位于 framework.form:

YAML

# config/packages/framework.yaml
framework:
    # ...
    form:
        csrf_protection:
            enabled: true
            field_name: 'custom_token_name'

PHP

// config/packages/framework.php
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
return App::config([
    'framework' => [
        'form' => [
            'csrf_protection' => [
                'enabled' => true,
                'field_name' => 'custom_token_name',
            ],
        ],
    ],
]);

针对单个表单,在 setDefaults() 中设置:

// src/Form/TaskType.php
namespace App\Form;
// ...
use App\Entity\Task;
use Symfony\Component\OptionsResolver\OptionsResolver;

class TaskType extends AbstractType
{
    // ...
    public function configureOptions(OptionsResolver $resolver): void
    {
        $resolver->setDefaults([
            'data_class'      => Task::class,
            // enable/disable CSRF protection for this form
            'csrf_protection' => true,
            // the name of the hidden HTML field that stores the token
            'csrf_field_name' => 'custom_token_name',
            // an arbitrary string used to generate the value of the token
            // using a different string for each form improves its security
            // when using stateful tokens (which is the default)
            'csrf_token_id'   => 'task_item',
        ]);
    }
    // ...
}

还可创建自定义 表单主题,以 csrf_token 作为字段前缀。例如定义 {% block csrf_token_widget %} ... {% endblock %},即可定制整个 CSRF 字段的渲染内容。

登录表单和退出操作

这两种场景的详细配置分别见 登录表单中的 CSRF 防护和退出操作的 CSRF 防护。原页在此提供链接,没有展开额外教程。

手动产生和检查 CSRF 令牌

Symfony Forms 默认自动处理令牌。但使用普通 HTML 表单、没有使用 Form 组件时,可能需要手动产生和检查。以删除条目的表单为例,先在模板中调用 csrf_token(),将令牌存入隐藏字段:

<form action="{{ url('admin_post_delete', { id: post.id }) }}" method="post">
    {# the argument of csrf_token() is the token ID, an arbitrary string used to generate the token #}
    <input type="hidden" name="token" value="{{ csrf_token('delete-item') }}">

    <button type="submit">Delete item</button>
</form>

csrf_token() 的参数是令牌 ID,可以是用于产生令牌的任意字符串。随后,在控制器中读取提交值,并调用 isCsrfTokenValid() 验证;令牌 ID 必须和模板产生令牌时使用的 ID 一致:

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
// ...

public function delete(Request $request): Response
{
    $submittedToken = $request->getPayload()->get('token');
    // 'delete-item' is the same token ID used in the template to generate the token
    if ($this->isCsrfTokenValid('delete-item', $submittedToken)) {
        // ... do something, like deleting an object
    }
}

通过属性检查令牌

也可以用 IsCsrfTokenValid 属性代替控制器内部的显式检查:

use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid;
// ...

#[IsCsrfTokenValid('delete-item', tokenKey: 'token')]
public function delete(): Response
{
    // ... do something, like deleting an object
}

该属性还可用于控制器类;这样,此控制器定义的所有 action 都会执行 CSRF 验证:

use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid;
// ...

#[IsCsrfTokenValid('the token ID')]
final class SomeController extends AbstractController
{
    // ...
}

前面的 ID 都是静态字符串。有时需要按条目产生不同 ID,可将条目 ID 拼入令牌 ID:

<form action="{{ url('admin_post_delete', { id: post.id }) }}" method="post">
    {# the argument of csrf_token() is the token ID, an arbitrary string used to generate the token #}
    <input type="hidden" name="token" value="{{ csrf_token('delete-item-' ~ post.id) }}">

    <button type="submit">Delete item</button>
</form>

静态字符串无法匹配这种动态 ID。此时用 Expression 对象定义 ID,运行时基于控制器参数求值:

use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid;
// ...
#[IsCsrfTokenValid(new Expression('"delete-item-" ~ args["post"].getId()'), tokenKey: 'token')]
public function delete(Post $post): Response
{
    // ... do something, like deleting an object
}

默认情况下,IsCsrfTokenValid 对所有 HTTP 方法执行检查。可以用 methods 限制方法;请求方法不在数组中时,属性会被忽略,该请求不会由此属性执行 CSRF 验证:

#[IsCsrfTokenValid('delete-item', tokenKey: 'token', methods: ['DELETE'])]
public function delete(Post $post): Response
{
    // ... delete the object
}

还可用 tokenSource 指定读取令牌的位置。它是位字段,允许组合多个来源:

  • IsCsrfTokenValid::SOURCE_PAYLOAD:默认来源,POST 请求体或 JSON。
  • IsCsrfTokenValid::SOURCE_QUERY:查询字符串。
  • IsCsrfTokenValid::SOURCE_HEADER:请求头。

例如:

#[IsCsrfTokenValid(
    'delete-item',
    tokenKey: 'token',
    tokenSource: IsCsrfTokenValid::SOURCE_PAYLOAD | IsCsrfTokenValid::SOURCE_QUERY
)]
public function delete(Post $post): Response
{
    // ... delete the object
}

每个已选择的来源都会被检查;没有任何来源匹配时,验证失败。

在服务中产生和检查令牌

Twig 的 csrf_token() 和控制器的 isCsrfTokenValid() 快捷方法,内部都使用 security.csrf.token_manager 服务。自定义服务中可通过 CsrfTokenManagerInterface 类型提示,让 自动装配注入它:

// src/Service/SomeService.php
namespace App\Service;
use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;

class SomeService
{
    public function __construct(
        private CsrfTokenManagerInterface $csrfTokenManager,
    ) {
    }
    public function someMethod(string $submittedToken): void
    {
        // gets the token value for the given token ID; if that ID doesn't have
        // a token yet, a new token is generated for it
        $csrfToken = $this->csrfTokenManager->getToken('delete-item');
        $tokenValue = $csrfToken->getValue();
        // checks if the given token value is valid for the given token ID
        $csrfToken = new CsrfToken('delete-item', $submittedToken);
        $isValid = $this->csrfTokenManager->isTokenValid($csrfToken);

        // ...
    }
}

getToken() 取得指定 ID 的令牌;尚无令牌时会产生一个。用提交值构造 CsrfToken 后,isTokenValid() 检查该 ID 的令牌是否有效。管理器还提供 refreshToken() 和 removeToken(),用于重新产生或使指定 ID 的令牌失效。

令牌和压缩侧信道攻击

BREACH 和 CRIME 利用 HTTPS 的 HTTP 压缩机制泄漏的信息,恢复目标明文。为缓解这类攻击、防止攻击者猜出 CSRF 令牌,Symfony 在令牌前加上随机掩码,并用它扰乱令牌内容。

无状态 CSRF 令牌

传统 CSRF 令牌存于会话,属于有状态令牌。也可用 stateless_token_ids 将某些 ID 声明为无状态。在使用 Symfony Flex 的应用中,无状态令牌默认启用。

YAML

# config/packages/csrf.yaml
framework:
    # ...
    csrf_protection:
        stateless_token_ids: ['submit', 'authenticate', 'logout']

PHP

// config/packages/csrf.php
namespace Symfony\Component\DependencyInjection\Loader\Configurator;

return App::config([
    'framework' => [
        'csrf_protection' => [
            'stateless_token_ids' => ['submit', 'authenticate', 'logout'],
        ],
    ],
]);

无状态令牌的防护不依赖会话,因此页面可以完整缓存,同时继续防护 CSRF。验证时 Symfony 检查请求的 Origin 和 Referer 头;其中任意一个匹配应用目标 origin(即它的域)时,令牌就被认为有效。

这一机制依赖应用能正确判断自身的 origin。如果部署在反向代理后,需正确配置代理。详见 在负载均衡器或反向代理之后使用 Symfony。

使用默认令牌 ID

有状态令牌通常按表单或操作划分作用范围;无状态令牌不必使用很多 ID。上例列出的 authenticate 和 logout 是 Security 组件的默认 ID;submit 让应用自己定义的表单类型也能默认使用防护。

以下配置仅作用于通过自动配置注册的表单类型(应用自有服务默认如此),将其默认 ID 设为 submit:

YAML

# config/packages/csrf.yaml
framework:
    form:
        csrf_protection:
            token_id: 'submit'

PHP

// config/packages/csrf.php
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
return App::config([
    'framework' => [
        'form' => [
            'csrf_protection' => [
                'token_id' => 'submit',
            ],
        ],
    ],
]);

表单 ID 如果列在 stateless_token_ids 中,就会使用无状态 CSRF 防护。

通过 JavaScript 产生令牌

除 Origin 和 Referer 外,无状态防护还能用 Cookie 和请求头验证令牌,默认名为 csrf-token。这是一项纵深防御措施,可选,需启用对应 JavaScript。脚本在提交表单时产生密码学安全随机令牌,将它插入隐藏字段,同时发送到 Cookie 和请求头。

服务器比较 Cookie 和请求头中的值。这种双重提交防护依靠浏览器同源策略,并通过以下措施进一步加强:

  • 每次提交产生新令牌,防止 Cookie 固定攻击。
  • 使用 samesite=strict 和 __Host- Cookie 属性,要求 HTTPS,并将 Cookie 限定在当前域。

Symfony 的脚本默认要求隐藏字段叫 _csrf_token,或带有 data-controller="csrf-protection"。可按需求调整脚本,但须遵守同一协议。

为避免验证被降级,系统还有一项行为检查:仅当会话已经存在时,成功的双重提交会被记住,并成为后续请求的要求。因此,可选 Cookie/请求头验证一旦在该会话中已被证明可用,就会持续强制执行。

不建议所有请求都强制双重提交,因为这可能破坏用户体验。原文推荐这种机会式策略:没有 JavaScript 时可以回退到 Origin/Referer 检查。

检查请求头中的令牌

默认双重提交只比较表单提交的令牌和 Cookie 中的令牌。设为 check_header: true 时,还要求 HTTP 请求头中的令牌。cookie_name 指定 Cookie 和请求头使用的名字:

YAML

# config/packages/csrf.yaml
framework:
    # ...
    csrf_protection:
        check_header: true
        # the name of the cookie and the header (default: 'csrf-token')
        cookie_name: 'app-csrf-token'

PHP

// config/packages/csrf.php
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
return App::config([
    'framework' => [
        // ...
        'csrf_protection' => [
            'check_header' => true,
            // the name of the cookie and the header (default: 'csrf-token')
            'cookie_name' => 'app-csrf-token',
        ],
    ],
]);

实践边界

本文对应核验时的 Symfony 8.1 文档,示例保留原文中的省略部分,不能当作完整可运行应用。限制属性的 methods 时,应同时确保路由只接受预期的状态变更方法。CSRF 验证不能代替用户身份验证或资源权限检查;无状态模式也依赖正确的代理和 origin 配置。本文没有安装包或运行集成测试。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享
评论 抢沙发

请登录后发表评论

    暂无评论内容