原文作者:Liam Crilly of F5
原文链接:将 NGINX 部署为 API 网关,第 2 部分:保护后端服务
转载来源:NGINX 官方网站
本文是将 NGINX 开源版和 NGINX Plus 部署为 API 网关系列博文的第二篇。
- 第 1 部分提供了几个用例的详细配置说明。
本文对这些用例进行了扩展,探讨了一系列可用于保护生产环境中后端 API 服务的安全措施:
本文最初发布于 2018 年,现进行了更新,以反映 API 配置的当前最佳实践——即使用嵌套的
location
块路由请求,而不是重写规则。- 第 3 部分解释了如何将 NGINX 开源版和 NGINX Plus 部署为 gRPC 服务的 API 网关。
注:除非另有说明,否则本文中的所有信息都适用于 NGINX 开源版和 NGINX Plus。为了便于阅读,下文将 NGINX 开源版和 NGINX Plus 统称为“NGINX”。
限流
与基于浏览器的客户端不同,单个 API 客户端就能够给您的 API 造成巨大的负载,甚至会消耗大量的系统资源,以致其他 API 客户端因此被“排挤”。不仅恶意客户端会构成这种威胁,行为异常或存在缺陷的 API 客户端也可能会反复压垮后端。为了防止出现这种情况,我们用限流来确保每个客户端合理使用 API 并保护后端服务的资源。
NGINX 可以根据请求的任何属性应用限流。通常使用客户端 IP 地址,但如果为 API 启用身份验证,则经过身份验证的客户端 ID 将是更为可靠和准确的属性。
限流本身在顶层 API 网关配置文件中定义,并且可以全局、按每个API 甚至每个 URI 来应用。
include api_backends.conf;
include api_keys.conf;
limit_req_zone $binary_remote_addr zone=client_ip_10rs:1m rate=10r/s; limit_req_zone $http_apikey zone=apikey_200rs:1m rate=200r/s;
server {
access_log /var/log/nginx/api_access.log main; # Each API may also log to a
# separate file
listen 443 ssl;
server_name api.example.com;
# TLS config
ssl_certificate /etc/ssl/certs/api.example.com.crt;
ssl_certificate_key /etc/ssl/private/api.example.com.key;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 5m;
ssl_ciphers HIGH:!aNULL:!MD5;
ssl_protocols TLSv1.2 TLSv1.3;
# API definitions, one per file
include api_conf.d/*.conf;
# Error responses
error_page 404 = @400; # Invalid paths are treated as bad requests
proxy_intercept_errors on; # Do not send backend errors to the client
include api_json_errors.conf; # API client friendly JSON error responses
default_type application/json; # If no content-type then assume JSON
}
在此示例中,第 4 行的limit_req_zone
指令为每个客户端 IP 地址 ($binary_remote_addr
) 定义每秒 10 个请求的限流,第 5 行的 limit\_req\_zone 指令为每个经过身份验证的客户端 ID ($http_apikey
) 定义每秒 200 个请求的限流。该示例说明了我们可以定义多个限流,而不受它们所应用位置的约束。一个 API 可以同时应用多个限流,或者对不同的资源应用不同的限流。
在下面的配置段中,我们使用limit_req
指令来应用本系列博文第 1 部分中描述的“Warehouse API”策略部分中的第一个限流。默认情况下,当超过限流阈值时,NGINX 会发送503
`(ServiceUnavailable)`响应。然而,让 API 客户端明确地知道自己已超过限流阈值,有助于它们调整自己的行为。为此,我们使用[`limit_req_status`](https://nginx.org/en/docs/http/ngx_http_limit_req_module.html#limit_req_status)指令来发送`429
(TooMany
Requests)`响应。
# Warehouse API
#
location /api/warehouse/ {
# Policy configuration here (authentication, rate limiting, logging...)
#
access_log /var/log/nginx/warehouse_api.log main;
limit_req zone=client_ip_10rs;
limit_req_status 429;
# URI routing
#
location /api/warehouse/inventory {
limit_except GET {
deny all;
}
error_page 403 = @405; # Convert deny response from '403 (Forbidden)'
# to '405 (Method Not Allowed)'
proxy_pass http://warehouse_inventory;
}
location /api/warehouse/pricing {
limit_except GET PATCH {
deny all;
}
error_page 403 = @405;
proxy_pass http://warehouse_pricing;
}
return 404; # Catch-all
}
您可以使用limit_req
指令的附加参数来微调 NGINX 执行限流的方式。例如,当超过限流阈值时,可以让请求排队而不是直接拒绝它们,从而使请求速率有时间降至定义的限制之下。有关微调限流阈值的更多信息,请参阅我们的博文《使用NGINX 和 NGINX Plus 实现限流》。
限定请求方法
对于 RESTful API,HTTP 方法是每个 API 调用的重要组成部分,对 API 定义非常重要。以Warehouse API 的定价服务 service 为例:
GET
`/api/warehouse/pricing/item001` returns the price of item001PATCH
`/api/warehouse/pricing/item001` changes the price of item001
我们可以更新Warehouse API 中的 URI 路由定义,以便在对定价 service 的请求中只接受这两个 HTTP 方法(并且在对库存 service 的请求中只接受GET
方法)。
# URI routing
#
location /api/warehouse/inventory {
limit_except GET {
deny all;
}
error_page 403 = @405; # Convert deny response from '403 (Forbidden)'
# to '405 (Method Not Allowed)'
proxy_pass http://warehouse_inventory;
}
location /api/warehouse/pricing {
limit_except GET PATCH {
deny all;
}
error_page 403 = @405; # Convert deny response from '403 (Forbidden)'
# to '405 (Method Not Allowed)'
proxy_pass http://warehouse_pricing;
}
使用此配置后,未使用第 22 行所列方法向定价 service 发出的请求(以及未使用第 13 行所列方法对库存 service 进行请求)将被拒绝,并且不会传递到后端 service 。NGINX 发送405
`(MethodNot
Allowed)响应,以通知 API 客户端确切的错误类型,如以下控制台跟踪所示。在需要遵循“最小披露”的安全策略时,可使用[
error_page](https://nginx.org/en/docs/http/ngx_http_core_module.html#error_page)指令将此响应转换为信息量较少的错误,例如
400(Bad
Request)`。
$ curl https://api.example.com/api/warehouse/pricing/item001
{"sku":"item001","price":179.99}
$ curl -X DELETE https://api.example.com/api/warehouse/pricing/item001
{"status":405,"message":"Method not allowed"}
应用细粒度的访问控制
本系列博文的第 1 部分介绍了如何通过启用身份验证选项(例如API 密钥和JSON Web Tokens (JWT))保护 API 免受未经授权的访问。我们可以使用经过身份验证的 ID 或经过身份验证的 ID 的属性来执行细粒度的访问控制。
我们在此处提供了两个相关示例:
- 第一个示例为控制对特定 API 资源的访问,扩展了第 1 部分中介绍的配置,并使用 API Key认证方式验证给定 API 客户端是否在允许名单(allowlist)中。
- 第二个示例为限定客户端使用哪些 HTTP 方法。它实现了第 1 部分中提到的 JWT 认证方式,使用自定义声明来识别符合条件的 API 客户端。(请注意,JWT 支持是 NGINX Plus 的独有功能。)
当然,其他认证方式也适用于这些示例中的用例,例如HTTP Basic 认证和OAuth 2.0 令牌自省。
控制对特定资源的访问
假设我们只允许“基础设施客户端”访问Warehouse API 库存 service 的audit资源。启用 API Key 认证方式后,我们使用map
块创建基础设施客户端名称的允许名单,以便在使用相应的 API Key时变量$is_infrastructure
的计算结果为1
。
map $api_client_name $is_infrastructure {
default 0;
"client_one" 1;
"client_six" 1;
}
在Warehouse API 的定义中,我们为库存audit资源添加了一个location
块(第 15-20 行)。if
块可确保只有基础设施客户端可以访问该资源。
# Warehouse API
#
location /api/warehouse/ {
# Policy configuration here (authentication, rate limiting, logging...)
#
access_log /var/log/nginx/warehouse_api.log main;
auth_request /_validate_apikey;
# URI routing
#
location /api/warehouse/inventory {
proxy_pass http://warehouse_inventory;
}
location = /api/warehouse/inventory/audit {
if ($is_infrastructure = 0) {
return 403; # Forbidden (not infrastructure)
}
proxy_pass http://warehouse_inventory;
}
location /api/warehouse/pricing {
proxy_pass http://warehouse_pricing;
}
return 404; # Catch-all
}
请注意,第 15 行的location
指令使用=
(等号)修饰符与audit资源进行精确匹配。精确匹配优先于用于其他资源的默认路径前缀定义。以下跟踪显示了在使用此配置的情况下,不在允许名单上的客户端如何无法访问库存audit资源。所示 API Key属于client_two(如第 1 部分中所定义)。
$ curl -H "apikey: QzVV6y1EmQFbbxOfRCwyJs35"
https://api.example.com/api/warehouse/inventory/audit
{"status":403,"message":"Forbidden"}
控制对特定方法的访问
如上所述,定价 service 接受GET
和PATCH
方法,分别支持客户端获取和修改特定物品的价格。(我们还可以选择允许POST
和DELETE
方法,以提供定价数据的全生命周期管理。)在本部分,我们对该用例进行扩展,控制特定用户可以发出哪些方法。为Warehouse API 启用 JWT 身份验证后,每个客户端的权限都被编码为自定义声明。发给授权更改定价数据的管理员的 JWT 包含声明"admin":true
。现在,我们扩展了访问控制逻辑,以便只有管理员才能进行更改。
# Access to write operations is evaluated by JWT claim 'admin'
map $request_method $admin_permitted_method {
"GET" true;
"HEAD" true;
"OPTIONS" true;
default $jwt_claim_admin;
}
此map
块(被添加到api_gateway.conf的底部)将请求方法 ($request_method
) 作为输入并生成一个新变量$admin_permitted_method
。只读方法始终允许(第 62-64 行),但对写入操作的访问取决于 JWT 中admin声明的值(第 65 行)。我们现在扩展了Warehouse API 配置,以确保只有管理员才能更改定价。
# Warehouse API
#
location /api/warehouse/ {
# Policy configuration here (authentication, rate limiting, logging...)
#
access_log /var/log/nginx/warehouse_api.log main;
auth_jwt "Warehouse API";
auth_jwt_key_file /etc/nginx/idp_jwks.json;
# URI routing
#
location /api/warehouse/inventory {
limit_except GET {
deny all;
}
error_page 403 = @405; # Convert deny response from '403 (Forbidden)'
# to '405 (Method Not Allowed)'
proxy_pass http://warehouse_inventory;
}
location /api/warehouse/pricing {
limit_except GET PATCH {
deny all;
}
if ($admin_permitted_method != "true") {
return 403;
}
error_page 403 = @405; # Convert deny response from '403 (Forbidden)'
# to '405 (Method Not Allowed)'
proxy_pass http://warehouse_pricing;
}
return 404; # Catch-all
}
Warehouse API 要求所有客户端都提供有效的 JWT(第 7 行)。我们还通过评估$admin_permitted_method
变量(第 25 行)来检查是否允许写入操作。再次提醒,JWT 身份验证是 NGINX Plus 的独有功能。
控制请求大小
HTTP API 通常使用请求正文来包含后端 API service要处理的指令和数据。XML/SOAP API 以及 JSON/REST API 也是如此。因此,请求正文可能会构成后端 API service 的攻击向量,当后端 API service 处理超大的请求正文时,可能容易受到缓冲区溢出攻击。
默认情况下,NGINX 拒绝正文大于 1MB 的请求。对于专门处理大型负载(例如图像处理)的 API,此值可以增加,但对于大多数 API,我们会设置一个较低的值。
# Warehouse API
#
location /api/warehouse/ {
# Policy configuration here (authentication, rate limiting, logging...)
#
access_log /var/log/nginx/warehouse_api.log main;
client_max_body_size 16k;
第 7 行的client_max_body_size
指令限制了请求正文的大小。有了此配置,我们就可以比较 API 网关在接收到两个不同的PATCH
定价 service 请求时的行为。第一个curl
命令发送一小段 JSON 数据,而第二个命令则尝试发送一个大文件 (/etc/services) 的内容。
$ curl -iX PATCH -d '{"price":199.99}' https://api.example.com/api/warehouse/pricing/item001
HTTP/1.1 204 No Content
Server: nginx/1.19.5
Connection: keep-alive
$ curl -iX PATCH [email protected]/etc/services https://api.example.com/api/warehouse/pricing/item001
HTTP/1.1 413 Request Entity Too Large
Server: nginx/1.19.5
Content-Type: application/json
Content-Length: 45
Connection: close
{"status":413,"message":"Payload too large"}
验证请求正文
[编者按—— 以下用例是 NGINX JavaScript 模块几个用例之一。查看完整列表,请参阅《NGINX JavaScript 模块的用例》]。
除了容易受到大型请求正文的缓冲区溢出攻击之外,后端 API service 还容易受到包含无效或意外数据的正文的影响。对于需要请求正文具有正确格式的 JSON 的应用,我们可以在将 JSON 数据代理到后端 API service 之前,使用NGINX JavaScript 模块验证其解析是否正确。
安装 JavaScript 模块后,我们使用js_import
指令来引用包含 JSON 数据验证函数的 JavaScript 代码的文件。
js_import json_validation.js;
js_set $json_validated json_validation.parseRequestBody;
js_set
指令定义了一个新变量$json_validated
,通过调用parseRequestBody
函数对其进行计算。
export default { parseRequestBody };
function parseRequestBody(r) {
try {
if (r.variables.request_body) {
JSON.parse(r.variables.request_body);
}
return r.variables.upstream;
} catch (e) {
r.error('JSON.parse exception');
return '127.0.0.1:10415'; // Address for error response
}
}
parseRequestBody
函数尝试使用JSON.parse
方法(第 6 行)解析请求正文。如果解析成功,则返回此请求所需上游 group 的名称(第 8 行)。如果无法解析请求正文(导致异常),则返回本地服务器地址(第 11 行)。return
指令将填充$json_validated
变量,以便我们可以使用它来确定将请求发送到何处。
# URI routing
#
location /api/warehouse/inventory {
proxy_pass http://warehouse_inventory;
}
location /api/warehouse/pricing {
set $upstream warehouse_pricing;
mirror /_get_request_body; # Force early read
client_body_in_single_buffer on; # Minimize memory copy operations
# on request body
client_body_buffer_size 16k; # Largest body to keep in memory
# (before writing to file)
client_max_body_size 16k;
proxy_pass http://$json_validated$request_uri;
}
在Warehouse API 的 URI 路由部分,我们在第 22 行修改了proxy_pass
指令。它将请求传递给后端 API service ,如前面部分中讨论的Warehouse API 配置一样,但是现在使用$json_validated
变量作为目标地址。如果客户端正文被成功解析为 JSON,那么我们将代理到第 15 行定义的上游 group。但是,如果出现异常,我们将使用返回值127.0.0.1:10415
向客户端发送错误响应。
server {
listen 127.0.0.1:10415;
return 415; # Unsupported media type
include api_json_errors.conf;
}
当请求被代理到这个虚拟服务器时,NGINX 将向客户端发送415
`(UnsupportedMedia
Type)`) 响应。
有了这个完整的配置,NGINX 将只在请求具有正确格式的 JSON 正文时才将其代理到后端 API service 。
$ curl -iX POST -d '{"sku":"item002","price":85.00}' https://api.example.com/api/warehouse/pricing
HTTP/1.1 201 Created
Server: nginx/1.19.5
Location: /api/warehouse/pricing/item002
$ curl -X POST -d 'item002=85.00' https://api.example.com/api/warehouse/pricing
{"status":415,"message":"Unsupported media type"}
关于$request_body
变量的说明
JavaScript 函数parseRequestBody
使用$request_body
变量来执行 JSON 解析。但是,NGINX 默认不赋值此变量,只是将请求正文流式传输到后端而创建中间副本。我们通过在 URI 路由部分(第 16 行)使用mirror
指令,创建客户端请求的副本,并赋值$request_body
变量。
location /api/warehouse/pricing {
set $upstream warehouse_pricing;
mirror /_get_request_body; # Force early read
client_body_in_single_buffer on; # Minimize memory copy operations
# on request body
client_body_buffer_size 16k; # Largest body to keep in memory
# (before writing to file)
client_max_body_size 16k;
proxy_pass http://$json_validated$request_uri;
}
第 17 行和第 19 行的指令控制 NGINX 如何在内部处理请求正文。我们将client_body_buffer_size
设置为与client_max_body_size
相同的大小,这样请求正文就不会写入磁盘。这样做有助于最大限度地减少磁盘 I/O 操作,从而提高整体性能,但代价是内存利用率会有所增加。对于大多数请求正文较小的 API 网关用例,这是一个不错的折衷方案。
如前所述,mirror
指令会创建客户端请求的副本。除了赋值$request_body
之外,我们不需要此副本,因此我们将其发送到我们在顶层 API 网关配置的server
块中定义的“死胡同(dead end)”位置 (/_get_request_body
)。
# Dummy location used to populate $request_body for JSON validation
location = /_get_request_body {
return 204;
}
此位置只发送204
`(No`Content)
响应。此响应与镜像请求相关,因此被忽略,对原始客户端请求的处理所增加的开销也可以忽略不计。
总结
本文是将 NGINX 开源版和 NGINX Plus 部署为 API 网关系列博文的第二篇,主要关注如何保护生产环境中的后端 API service 免受恶意和行为异常的客户端的影响。NGINX 所使用的 API 流量管理技术同样被用于支持和保护当今互联网上最繁忙的站点。
查看本系列博文的其他文章:
更多资源
想要更及时全面地获取 NGINX 相关的技术干货、互动问答、系列课程、活动资源?
请前往 NGINX 开源社区: