Nginx 反向代理 数据脱敏 配置详解
代理层脱敏的合理预期
在真实系统里,Nginx 做数据脱敏通常不是为了替代业务安全,而是为了兜底和统一治理。比如后端接口已经稳定运行多年,突然要求所有手机号在返回给前端时统一打码;再比如测试环境日志里经常记录到 token、password、身份证等字段;又或者某个第三方接口会返回敏感调试信息,你不希望它透传到外部调用方。
这种场景下,在反向代理层做一层过滤是务实的。它能减少改动业务代码的成本,也能让日志、响应体、请求头在同一处统一处理。但也要保持克制:代理层看到的通常是已经序列化后的字符串或请求头,不是完整的业务对象。它能挡住部分误传出的敏感字段,却不能替代权限校验、字段级访问控制和数据加密。
先分清三类脱敏对象
配置之前,最好把目标拆清楚,否则容易把规则写复杂。
第一类是响应体脱敏,例如 JSON 里的手机号、身份证号、邮箱、token。
第二类是请求头和响应头脱敏,例如 Authorization、Cookie、X-Session-Token、内部网关头。
第三类是访问日志脱敏,例如 URL 查询参数里的 access_token、password、card_no。
这三类机制不同。响应体可以用 sub_filter 或 Lua 过滤,请求头可以用 map 和 proxy_set_header 控制,日志脱敏则通常要结合变量改写和 log_format。
用 sub_filter 处理固定响应内容
Nginx 的 ngx_http_sub_module 适合做简单字符串替换。它的优点是配置轻,不依赖第三方模块。缺点也很明显:它不是正则引擎,更适合替换可预测的固定文本。
location /api/ {
proxy_pass http://backend;
proxy_set_header Accept-Encoding "";
sub_filter_once off;
sub_filter_types application/json text/plain;
sub_filter '"debug":true' '"debug":false';
sub_filter 'test_api_key' '[FILTERED]';
sub_filter 'internal_user' '[FILTERED]';
}
这里有几个细节值得注意。
proxy_set_header Accept-Encoding ""; 是为了避免后端返回 gzip 压缩响应。Nginx 的 sub_filter 不能直接处理压缩后的二进制内容,如果响应已经压缩,替换就不会发生。
sub_filter_once off; 表示一个响应里出现多次目标字符串时都要替换。默认只替换第一次匹配,很多敏感信息会出现在多处,所以通常要关闭。
sub_filter_types 需要显式列出要处理的内容类型。默认只处理 text/html,如果接口返回的是 application/json,不加这行基本不会生效。
但 sub_filter 不适合做“手机号打码”这类规则。因为手机号不是固定字符串,长度和值都会变化。如果只是为了把某几个调试字段替换掉,它很轻;如果要处理身份证、银行卡、手机号,它就不够用了。
用 OpenResty 处理响应体中的敏感字段
如果需要变长匹配,通常会用 OpenResty 的 Lua 能力,核心是 body_filter_by_lua_block。下面的配置是一个示例:
location /api/ {
proxy_pass http://backend;
proxy_http_version 1.1;
proxy_set_header Accept-Encoding "";
body_filter_by_lua_block {
local chunk = ngx.arg[1]
if not chunk or chunk == "" then
return
end
local content_type = ngx.header["Content-Type"] or ""
if content_type:find("application/json", 1, true)
or content_type:find("text/", 1, true) then
chunk = chunk:gsub("(1[3-9]%d)%d%d%d%d(%d%d%d%d)", "%1****%2")
chunk = chunk:gsub(
'"access_token"%s*:%s*"[^"]*"',
'"access_token":"[REDACTED]"'
)
chunk = chunk:gsub(
'"id_card"%s*:%s*"[^"]*"',
'"id_card":"[REDACTED]"'
)
end
ngx.arg[1] = chunk
}
}
这段配置能做几件事:把中国大陆手机号中间四位打码,把 JSON 字段 access_token 和 id_card 的值替换成脱敏占位符。
但它也有边界。body_filter_by_lua_block 处理的是分块响应。如果网络层把一个字段拆成多个 chunk,简单正则可能匹配不到完整内容。对于小响应、普通 JSON 接口,这通常问题不大;对于大响应、流式接口或复杂嵌套字段,更稳妥的方式是缓冲完整 body 后用 cjson 解析 JSON,再按字段脱敏。
另外,不要用代理层做过度泛化替换。比如“看到 18 位数字就打码”很容易误伤订单号、流水号、时间戳。脱敏规则最好和业务字段约定绑定,例如只处理 phone、mobile、access_token、id_card 这类明确字段。
过滤请求头与响应头
反向代理经常会透传大量请求头。有些内部头不应该暴露给上游,更不应该返回给客户端。可以用 map 配合 proxy_set_header 处理。
map $http_x_internal_token $pass_internal_token {
default "";
}
map $http_authorization $pass_authorization {
default "";
"~^Bearer\s+" $http_authorization;
}
server {
listen 443 ssl;
location /api/ {
proxy_pass http://backend;
proxy_set_header X-Internal-Token $pass_internal_token;
proxy_set_header Authorization $pass_authorization;
}
}
这个例子的含义是:默认不透传 X-Internal-Token;Authorization 只在值以 Bearer 开头时透传。这样可以在代理层收紧认证头的传播范围。
响应头可以用 proxy_hide_header 隐藏,也可以用 Lua 做更精细处理:
location /api/ {
proxy_pass http://backend;
proxy_hide_header Server;
proxy_hide_header X-Powered-By;
proxy_hide_header X-Session-Token;
header_filter_by_lua_block {
local sensitive_headers = {
["x-internal-token"] = true,
["x-user-id"] = true,
}
for k, _ in pairs(sensitive_headers) do
if ngx.header[k] ~= nil then
ngx.header[k] = nil
end
end
}
}
这里要注意,删除响应头只是减少信息泄露,不代表接口安全。如果后端仍然把敏感数据放在响应头里,最好从接口契约层面改掉,而不是永远依赖代理层擦屁股。
访问日志脱敏
Nginx 默认日志里的 $request 可能包含完整查询参数,例如:
GET /api/user?phone=13800138000&access_token=abc123 HTTP/1.1
如果直接把这条日志落盘,敏感信息就已经进入日志系统了。可以在 Lua 的 access_by_lua_block 中改写一个自定义变量,再让 log_format 使用这个变量。
server {
location /api/ {
set $safe_request $request;
access_by_lua_block {
local req = ngx.var.safe_request or ""
req = req:gsub("([?&]access_token=)[^&]*", "%1[REDACTED]")
req = req:gsub("([?&]refresh_token=)[^&]*", "%1[REDACTED]")
req = req:gsub("([?&]password=)[^&]*", "%1[REDACTED]")
req = req:gsub("([?&]id_card=)[^&]*", "%1[REDACTED]")
ngx.var.safe_request = req
}
proxy_pass http://backend;
}
}
然后定义日志格式:
log_format masked '$remote_addr - $remote_user [$time_local] '
'"$safe_request" $status $body_bytes_sent '
'"$http_referer" "$http_user_agent"';
access_log /var/log/nginx/masked.access.log masked;
这种写法的优点是不动业务代码,日志层直接打码。但它只能处理 URL 查询参数,不能处理 POST JSON body 里的敏感字段。如果登录接口把 password 放在请求体里,Nginx 访问日志不会记录 body,因此也不需要额外处理;但如果是应用自己打印日志,就必须在应用层处理,代理层无法完全兜住。
生产环境的几个坑
第一,别只盯着 Nginx。HTTPS 仍然要有。代理层脱敏解决的是“返回内容里不该出现的东西”,不是传输加密。没有 TLS,数据在链路上仍然可能是明文。
第二,不要对所有路径开启过滤。sub_filter 和 Lua 都会增加 CPU 开销,尤其是正则匹配和大响应体。通常应该只挂在需要脱敏的 API 路径上。
第三,注意编码问题。Lua pattern 处理的是字节流。如果响应里包含非 UTF-8 文本,或者 JSON 字符串里出现转义字符,正则可能不完全匹配。对核心字段,最好由后端保证稳定格式。
第四,避免“顺手打码”造成事故。手机号打码看起来简单,但业务可能依赖完整号码做校验、跳转或缓存。代理层改动响应体,等于改变了客户端看到的数据契约,需要和前端、后端、测试一起确认。
第五,把脱敏规则当作临时方案还是长期方案,要有判断。如果长期在代理层解析 JSON 并修改字段,维护成本可能很高。真正稳定的做法,通常是后端按角色返回脱敏后的数据结构,代理层只负责日志、响应头和少量兜底过滤。
小结
Nginx 反向代理做数据脱敏,适合三类场景:响应体固定字段替换、请求和响应头过滤、访问日志中的查询参数打码。简单文本替换可以用 sub_filter,但它的表达能力有限;需要匹配手机号、token 这类变长内容时,OpenResty 的 Lua 能力更合适。
配置时最关键的,不只是写对规则,而是明确边界。代理层能统一过滤,但不能成为系统唯一的安全防线。脱敏规则要尽量精确,范围要尽量小,日志和响应都要分别处理,并且要为未来把脱敏逻辑收回到业务层留出余地。
评论
还没有评论。
发表评论
提交后评论将经过自动审核,审核通过后公开展示。