Cross-Site Request Forgery Prevention
Cross-Site Request Forgery (CSRF) 是一种攻击,它迫使用户在他们当前已通过身份验证的 Web 应用程序中执行不需要的操作。
Quarkus Security 提供了一项 CSRF 防护功能,该功能实现了 Double Submit Cookie 和 CSRF Request Header 技术。
Double Submit Cookie
技术要求 CSRF 令牌以 HTTPOnly
的形式发送,可以签名,通过 Cookie 发送到客户端,直接嵌入到服务端呈现的 HTML 表单的隐藏表单输入中,或者作为请求头值提交。
该扩展包括一个 Quarkus REST (formerly RESTEasy Reactive) 服务器过滤器,该过滤器在 application/x-www-form-urlencoded
和 multipart/form-data
表单中创建并验证 CSRF 令牌,以及一个 Qute HTML 表单参数提供程序,该提供程序支持 injection of CSRF tokens in Qute templates 。
Creating the Project
首先,我们需要一个新项目。使用以下命令创建一个新项目:
quarkus create app {create-app-group-id}:{create-app-artifact-id} \
--no-code
cd {create-app-artifact-id}
要创建一个 Gradle 项目,添加 --gradle
或 --gradle-kotlin-dsl
选项。
有关如何安装和使用 Quarkus CLI 的详细信息,请参见 Quarkus CLI 指南。
mvn {quarkus-platform-groupid}:quarkus-maven-plugin:{quarkus-version}:create \
-DprojectGroupId={create-app-group-id} \
-DprojectArtifactId={create-app-artifact-id} \
-DnoCode
cd {create-app-artifact-id}
要创建一个 Gradle 项目,添加 -DbuildTool=gradle
或 -DbuildTool=gradle-kotlin-dsl
选项。
适用于 Windows 用户:
-
如果使用 cmd,(不要使用反斜杠
\
,并将所有内容放在同一行上) -
如果使用 Powershell,将
-D
参数用双引号引起来,例如"-DprojectArtifactId={create-app-artifact-id}"
此命令生成一个导入 csrf-reactive
扩展的项目。
如果您已配置 Quarkus 项目,则可以通过在项目基本目录中运行以下命令将 csrf-reactive
扩展添加到您的项目:
quarkus extension add {add-extension-extensions}
./mvnw quarkus:add-extension -Dextensions='{add-extension-extensions}'
./gradlew addExtension --extensions='{add-extension-extensions}'
这会将以下内容添加到构建文件中:
<dependency>
<groupId>io.quarkus</groupId>
<artifactId>quarkus-csrf-reactive</artifactId>
</dependency>
implementation("io.quarkus:quarkus-csrf-reactive")
接下来,让我们添加一个 csrfToken.html
Qute 模板,在 src/main/resources/templates
文件夹中生成一个 HTML 表单:
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>User Name Input</title>
</head>
<body>
<h1>User Name Input</h1>
<form action="/service/csrfTokenForm" method="post">
<input type="hidden" name="{inject:csrf.parameterName}" value="{inject:csrf.token}" /> 1
<p>Your Name: <input type="text" name="name" /></p>
<p><input type="submit" name="submit"/></p>
</form>
</body>
</html>
1 | 此表达式用于将 CSRF 令牌注入一个隐藏表单字段。该令牌将由 CSRF 过滤器使用 CSRF cookie 进行验证。 |
现在,让我们创建一个资源类,该类返回一个 HTML 表单并处理表单 POST 请求:
package io.quarkus.it.csrf;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.FormParam;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import io.quarkus.qute.Template;
import io.quarkus.qute.TemplateInstance;
@Path("/service")
public class UserNameResource {
@Inject
Template csrfToken; 1
@GET
@Path("/csrfTokenForm")
@Produces(MediaType.TEXT_HTML)
public TemplateInstance getCsrfTokenForm() {
return csrfToken.instance(); 2
}
@POST
@Path("/csrfTokenForm")
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@Produces(MediaType.TEXT_PLAIN)
public String postCsrfTokenForm(@FormParam("name") String userName) {
return userName; 3
}
}
1 | 注入 csrfToken.html 作为 Template 。 |
2 | 返回包含 CSRF 过滤器创建的 CSRF 令牌的隐藏表单字段的 HTML 表单。 |
3 | 处理 POST 表单请求,此方法只能在 CSRF 过滤器已成功验证令牌的情况下调用。 |
如果过滤器发现隐藏的 CSRF 表单字段缺失、CSRF cookie 缺失,或 CSRF 表单字段和 CSRF cookie 的值不匹配,表单 POST 请求将失败,HTTP 状态为 400
。
在这一阶段,不需要任何其他配置 - 默认情况下,CSRF 表单字段和 cookie 名称将设置为 csrf-token
,过滤器将验证令牌。但是,如果您愿意,可以更改这些名称:
quarkus.rest-csrf.form-field-name=csrftoken
quarkus.rest-csrf.cookie-name=csrftoken
Sign CSRF token
您可以为生成的 CSRF 令牌获取创建的 HMAC
签名,并如果不想承担攻击者重新创建 CSRF 令牌的风险,可以将这些 HMAC
值存储为 CSRF 令牌 Cookie。您需要做的就是配置令牌签名密钥,密钥长度必须至少为 32 个字符:
quarkus.rest-csrf.token-signature-key=AyM1SysPpbyDfgZld3umj1qzKObwVMkoqQ-EstJQLr_T-1qS0gZH75aKtMN3Yj0iPS4hcgUuTwjAzZr1Z9CAow
CSRF Request Header
如果未使用 HTML form
标签,而您需要将 CSRF 令牌作为标题传递,则将标题名称和令牌注入到 HTMX 中,例如:
<body hx-headers='{"{inject:csrf.headerName}":"{inject:csrf.token}"}'> 1
</body>
1 | 此表达式用于注入 CSRF 令牌标头和令牌。CSRF 过滤器会根据 CSRF Cookie 检查此令牌。 |
默认标题名称是 X-CSRF-TOKEN
,您可以使用 quarkus.rest-csrf.token-header-name
自定义它,例如:
quarkus.rest-csrf.token-header-name=CUSTOM-X-CSRF-TOKEN
如果您需要从 JavaScript 访问 CSRF Cookie 以将其值作为标题传递,请使用 {inject:csrf.cookieName}
和 {inject:csrf.headerName}
注入必须作为 CSRF 标头值读取的 Cookie 名称,并允许访问此 Cookie:
quarkus.rest-csrf.cookie-http-only=false
Cross-origin resource sharing
如果您想要在一个跨域环境中实施 CSRF 保护,请避免支持所有域。 将受支持的域限制为仅受信任的域,有关更多信息,请参阅“跨域资源共享”指南的 CORS filter 部分。 |
Restrict CSRF token verification
Jakarta REST 端点不仅可以接受带有 application/x-www-form-urlencoded
或 multipart/form-data
有效负载的 HTTP POST 请求,还可以接受具有其他媒体类型且路径 URL 相同或不同的有效负载,因此您希望避免在这种情况下验证 CSRF 令牌,例如:
package io.quarkus.it.csrf;
import jakarta.inject.Inject;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.FormParam;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
import io.quarkus.qute.Template;
import io.quarkus.qute.TemplateInstance;
@Path("/service")
public class UserNameResource {
@Inject
Template csrfToken;
@GET
@Path("/user")
@Produces(MediaType.TEXT_HTML)
public TemplateInstance getCsrfTokenForm() {
return csrfToken.instance();
}
1
@POST
@Path("/user")
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@Produces(MediaType.TEXT_PLAIN)
public String postCsrfTokenForm(@FormParam("name") String userName) {
return userName;
}
2
@POST
@Path("/user")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.TEXT_PLAIN)
public String postJson(User user) {
return user.name;
}
3
@POST
@Path("/users")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.TEXT_PLAIN)
public String postJson(User user) {
return user.name;
}
public static class User {
private String name;
public String getName() {
return this.name;
}
public void setName(String name) {
this.name = name;
}
}
}
1 | POST 表单请求至 /user ,CSRF 令牌验证由 CSRF 过滤器实施 |
2 | POST json 请求至 /user ,不需要 CSRF 令牌验证 |
3 | POST json 请求至 /users ,不需要 CSRF 令牌验证 |
正如您所见,将在接受 application/x-www-form-urlencoded
有效负载的 /service/user
路径上需要 CSRF 令牌验证,但是发送到 /service/user
和 /service/users
方法的 User
JSON 表示形式将不具有 CSRF 令牌,因此必须在这些情况下通过将其限制为特定的 /service/user
请求路径但在这个路径上不仅允许 application/x-www-form-urlencoded
来跳过令牌验证:
# Verify CSRF token only for the `/service/user` path, ignore other paths such as `/service/users`
quarkus.rest-csrf.create-token-path=/service/user
# If `/service/user` path accepts not only `application/x-www-form-urlencoded` payloads but also other ones such as JSON then allow them
# Setting this property is not necessary when the token is submitted as a header value
quarkus.rest-csrf.require-form-url-encoded=false
Verify CSRF token in the application code
如果您希望在应用程序代码中比较 CSRF 表单字段和 Cookie 值,则可以执行以下操作:
package io.quarkus.it.csrf;
import jakarta.inject.Inject;
import jakarta.ws.rs.BadRequestException;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.CookieParam;
import jakarta.ws.rs.FormParam;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.Cookie;
import jakarta.ws.rs.core.MediaType;
import io.quarkus.qute.Template;
import io.quarkus.qute.TemplateInstance;
@Path("/service")
public class UserNameResource {
@Inject
Template csrfToken;
@GET
@Path("/csrfTokenForm")
@Produces(MediaType.TEXT_HTML)
public TemplateInstance getCsrfTokenForm() {
return csrfToken.instance();
}
@POST
@Path("/csrfTokenForm")
@Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@Produces(MediaType.TEXT_PLAIN)
public String postCsrfTokenForm(@CookieParam("csrf-token") Cookie csrfCookie, @FormParam("csrf-token") String formCsrfToken, @FormParam("name") String userName) {
if (!csrfCookie.getValue().equals(formCsrfToken)) { 1
throw new BadRequestException();
}
return userName;
}
}
1 | 比较 CSRF 表单字段和 Cookie 值,如果不匹配,则以 HTTP 状态 400 失败。 |
还在过滤器中禁用令牌验证:
quarkus.rest-csrf.verify-token=false