下面给你一套网关自动拦截 + 更完整日志记录的落地方案,优先以 NGINX / OpenResty 为例,适合你们后续接 ELK、Loki、Splunk、Azure Monitor 或 SIEM。


一、网关中如何实现自动拦截?

1. 推荐拦截策略

自动拦截不要一上来全量阻断,建议分三档:

观察期:只记录日志,不拦截
灰度期:只拦截高风险 Path,不拦截普通 Query
强制期:高风险 Path + 高风险参数双重编码都拦截

Double Encoding 的风险在于攻击者可能把参数或路径编码两次,从而绕过只解码一次的安全过滤器;OWASP 明确提到它可用于绕过认证、访问控制和安全过滤,典型场景包括路径穿越和 XSS。 [rfc-editor.org]


2. 高风险路径拦截规则

建议优先拦截这些模式:

%252e%252e%252f    双重编码的 ../
%252e%252e%255c    双重编码的 ..\
%252f              双重编码的 /
%255c              双重编码的 \
%253c              双重编码的 <
%253e              双重编码的 >

其中 %252e%252e%252f 解码一次是 %2e%2e%2f,再解码才是 ../,这是典型路径穿越绕过形式。OWASP 示例中也说明 ../ 可先编码为 %2E%2E%2F,再编码为 %252E%252E%252F[rfc-editor.org]


二、NGINX 原生自动拦截配置

适合先快速落地。

http {
    map $request_uri $block_double_encoded_path {
        default 0;

        # 双重编码的 ../
        ~*%252e%252e%252f 1;
        ~*%252e%252e%255c 1;

        # 双重编码的 / 或 \
        ~*%252f 1;
        ~*%255c 1;

        # 双重编码的 < >
        ~*%253c 1;
        ~*%253e 1;
    }

    server {
        listen 80;
        server_name api.example.com;

        if ($block_double_encoded_path = 1) {
            return 400;
        }

        location / {
            proxy_set_header X-Request-ID $request_id;
            proxy_pass http://backend;
        }
    }
}

说明:

  • $request_uri 更适合检测原始请求,因为它保留原始 URI 和 QueryString。
  • $uri 是 NGINX 当前处理的 URI,可能经过规范化、内部重写或路径处理。
  • 排查编码问题时建议同时记录 $request_uri$uri[nodejs.cn], [baeldung-cn.com]

三、NGINX 记录更多日志字段

NGINX 的 ngx_http_log_module 支持通过 log_format 自定义日志格式,并通过 access_log 指定输出文件;日志可以配置多个格式,也可以按条件输出。 [nginx.org]

1. 推荐 JSON 日志格式

http {
    map $request_uri $has_percent25 {
        default 0;
        ~*%25 1;
    }

    map $request_uri $has_double_encoded_path_risk {
        default 0;
        ~*%252e%252e%252f 1;
        ~*%252e%252e%255c 1;
        ~*%252f 1;
        ~*%255c 1;
        ~*%253c 1;
        ~*%253e 1;
    }

    map $block_double_encoded_path $encoding_action {
        default "allow";
        1 "block";
    }

    log_format url_encoding_json escape=json
      '{'
      '"time":"$time_iso8601",'
      '"request_id":"$request_id",'
      '"remote_addr":"$remote_addr",'
      '"x_forwarded_for":"$http_x_forwarded_for",'
      '"method":"$request_method",'
      '"scheme":"$scheme",'
      '"host":"$host",'
      '"server_name":"$server_name",'
      '"server_addr":"$server_addr",'
      '"request":"$request",'
      '"request_uri":"$request_uri",'
      '"uri":"$uri",'
      '"args":"$args",'
      '"query_string":"$query_string",'
      '"status":$status,'
      '"body_bytes_sent":$body_bytes_sent,'
      '"request_length":$request_length,'
      '"request_time":$request_time,'
      '"upstream_addr":"$upstream_addr",'
      '"upstream_status":"$upstream_status",'
      '"upstream_response_time":"$upstream_response_time",'
      '"referer":"$http_referer",'
      '"user_agent":"$http_user_agent",'
      '"content_type":"$content_type",'
      '"content_length":"$content_length",'
      '"has_percent25":$has_percent25,'
      '"has_double_encoded_path_risk":$has_double_encoded_path_risk,'
      '"encoding_action":"$encoding_action"'
      '}';

    access_log /var/log/nginx/url_encoding_access.log url_encoding_json;
}

建议使用 escape=json,因为 User-Agent、Referer、URI 里可能包含引号、反斜杠、换行等字符,直接拼 JSON 容易破坏日志结构;NGINX 的 log_format 支持 escape 参数,官方文档也说明日志格式可自定义并由 access_log 使用。 [nginx.org]


2. 建议增加的日志字段

建议记录这些字段:

基础字段:
time
request_id
remote_addr
x_forwarded_for
method
scheme
host
server_name
server_addr
status

URI 字段:
request
request_uri
uri
args
query_string

性能字段:
request_time
upstream_response_time
upstream_addr
upstream_status
request_length
body_bytes_sent

安全字段:
has_percent25
has_double_encoded_path_risk
encoding_action
matched_pattern
risk_level
event_type

客户端字段:
user_agent
referer
content_type
content_length

业务透传字段:
x_app_id
x_tenant_id
x_user_id
x_sdk_name
x_sdk_version
x_trace_id

注意:敏感字段不要明文记录,例如 access_tokenrefresh_tokenpasswordauthorizationcookiecodesecretsignature。OWASP Logging Cheat Sheet 建议应用日志应服务于安全和运营目的,但要避免记录过多或记录敏感数据。 [mdn.org.cn]


四、OpenResty 实现更强自动拦截

如果需要识别具体命中的规则、区分 Path 和 Query、输出更丰富的安全日志,建议使用 OpenResty。

1. OpenResty 自动拦截 Path

server {
    listen 80;
    server_name api.example.com;

    access_by_lua_block {
        local cjson = require "cjson.safe"

        local request_uri = ngx.var.request_uri or ""
        local uri = ngx.var.uri or ""
        local lower_request_uri = string.lower(request_uri)

        local risk_patterns = {
            { name = "double_encoded_dot_dot_slash", pattern = "%%252e%%252e%%252f", risk = "critical" },
            { name = "double_encoded_dot_dot_backslash", pattern = "%%252e%%252e%%255c", risk = "critical" },
            { name = "double_encoded_slash", pattern = "%%252f", risk = "high" },
            { name = "double_encoded_backslash", pattern = "%%255c", risk = "high" },
            { name = "double_encoded_lt", pattern = "%%253c", risk = "medium" },
            { name = "double_encoded_gt", pattern = "%%253e", risk = "medium" }
        }

        for _, rule in ipairs(risk_patterns) do
            if string.find(lower_request_uri, rule.pattern) then
                local event = {
                    event_type = "url_encoding_anomaly",
                    event_subtype = "high_risk_double_encoded_path",
                    action = "block",
                    risk_level = rule.risk,
                    matched_rule = rule.name,
                    matched_pattern = rule.pattern,
                    request_id = ngx.var.request_id,
                    remote_addr = ngx.var.remote_addr,
                    x_forwarded_for = ngx.var.http_x_forwarded_for,
                    method = ngx.var.request_method,
                    host = ngx.var.host,
                    request_uri = request_uri,
                    uri = uri,
                    user_agent = ngx.var.http_user_agent
                }

                ngx.log(ngx.WARN, cjson.encode(event))

                ngx.status = 400
                ngx.say("Bad Request")
                return ngx.exit(400)
            end
        end
    }

    location / {
        proxy_set_header X-Request-ID $request_id;
        proxy_pass http://backend;
    }
}

这类策略适合直接拦截路径穿越、路径混淆、双重编码 slash/backslash 等风险,因为这些模式常用于绕过安全检查。OWASP 和安全厂商文档都指出 Double URL Encoding 可作为规避技术,常见于目录遍历、XSS、SQL 注入和访问控制绕过。 [rfc-editor.org], [rfc2cn.com]


2. OpenResty 拦截高风险 Query 参数

建议对这些参数启用严格策略:

redirect_uri
returnUrl
next
callback
url
target
continue
path
file
download
resource

配置示例:

server {
    listen 80;
    server_name api.example.com;

    access_by_lua_block {
        local cjson = require "cjson.safe"

        local high_risk_params = {
            redirect_uri = true,
            returnUrl = true,
            next = true,
            callback = true,
            url = true,
            target = true,
            continue = true,
            path = true,
            file = true,
            download = true,
            resource = true
        }

        local function has_encoded_pattern(s)
            return type(s) == "string"
                and string.find(s, "%%[0-9a-fA-F][0-9a-fA-F]") ~= nil
        end

        local function is_probably_double_encoded(s)
            if type(s) ~= "string" then
                return false
            end

            local decoded_once = ngx.unescape_uri(s)

            if not decoded_once then
                return true
            end

            return has_encoded_pattern(decoded_once)
        end

        local args = ngx.req.get_uri_args()

        for name, value in pairs(args) do
            if high_risk_params[name] then
                local values = type(value) == "table" and value or { value }

                for _, v in ipairs(values) do
                    if is_probably_double_encoded(v) then
                        local event = {
                            event_type = "url_encoding_anomaly",
                            event_subtype = "double_encoded_high_risk_param",
                            action = "block",
                            risk_level = "high",
                            param_name = name,
                            param_value_sample = string.sub(v, 1, 200),
                            request_id = ngx.var.request_id,
                            remote_addr = ngx.var.remote_addr,
                            x_forwarded_for = ngx.var.http_x_forwarded_for,
                            method = ngx.var.request_method,
                            host = ngx.var.host,
                            request_uri = ngx.var.request_uri,
                            uri = ngx.var.uri,
                            user_agent = ngx.var.http_user_agent
                        }

                        ngx.log(ngx.WARN, cjson.encode(event))

                        ngx.status = 400
                        ngx.say("Bad Request")
                        return ngx.exit(400)
                    end
                end
            end
        end
    }

    location / {
        proxy_set_header X-Request-ID $request_id;
        proxy_pass http://backend;
    }
}

判断逻辑是:解码一次后仍然包含 %XX,就认为疑似双重编码。这对应 Double Encoding 的典型绕过方式:一个安全组件只解码一次,而后端或其他模块又执行第二次解码,导致安全检查看到的内容和业务真正使用的内容不一致。 [rfc-editor.org]


五、OpenResty 记录更多日志字段

如果只用 NGINX access log,字段比较固定;如果用 OpenResty,可以写出非常详细的安全事件日志。

1. 安全事件日志函数

http {
    lua_shared_dict encoding_security_events 20m;

    server {
        listen 80;
        server_name api.example.com;

        access_by_lua_block {
            local cjson = require "cjson.safe"

            local function safe_sample(value, max_len)
                if not value then
                    return nil
                end

                value = tostring(value)

                if string.len(value) > max_len then
                    return string.sub(value, 1, max_len) .. "..."
                end

                return value
            end

            local function write_security_log(event)
                event.time = ngx.var.time_iso8601
                event.request_id = ngx.var.request_id
                event.remote_addr = ngx.var.remote_addr
                event.x_forwarded_for = ngx.var.http_x_forwarded_for
                event.method = ngx.var.request_method
                event.scheme = ngx.var.scheme
                event.host = ngx.var.host
                event.server_name = ngx.var.server_name
                event.request_uri = ngx.var.request_uri
                event.uri = ngx.var.uri
                event.args = safe_sample(ngx.var.args, 1000)
                event.status = ngx.status
                event.user_agent = ngx.var.http_user_agent
                event.referer = ngx.var.http_referer
                event.content_type = ngx.var.content_type
                event.content_length = ngx.var.http_content_length
                event.trace_id = ngx.var.http_x_trace_id
                event.app_id = ngx.var.http_x_app_id
                event.tenant_id = ngx.var.http_x_tenant_id
                event.sdk_name = ngx.var.http_x_sdk_name
                event.sdk_version = ngx.var.http_x_sdk_version

                ngx.log(ngx.WARN, cjson.encode(event))
            end

            local request_uri = ngx.var.request_uri or ""
            local lower_request_uri = string.lower(request_uri)

            if string.find(lower_request_uri, "%%25") then
                write_security_log({
                    event_type = "url_encoding_anomaly",
                    event_subtype = "percent25_detected",
                    action = "warn",
                    risk_level = "medium",
                    has_percent25 = true
                })
            end

            if string.find(lower_request_uri, "%%252e%%252e%%252f") then
                write_security_log({
                    event_type = "url_encoding_anomaly",
                    event_subtype = "double_encoded_path_traversal",
                    action = "block",
                    risk_level = "critical",
                    matched_pattern = "%252e%252e%252f"
                })

                ngx.status = 400
                ngx.say("Bad Request")
                return ngx.exit(400)
            end
        }

        location / {
            proxy_set_header X-Request-ID $request_id;
            proxy_pass http://backend;
        }
    }
}

2. 建议记录的扩展字段

建议最终日志结构包含:

{
  "time": "2026-07-04T11:10:00+08:00",
  "event_type": "url_encoding_anomaly",
  "event_subtype": "double_encoded_path_traversal",
  "action": "block",
  "risk_level": "critical",
  "matched_pattern": "%252e%252e%252f",
  "request_id": "xxxx",
  "trace_id": "xxxx",
  "remote_addr": "10.1.2.3",
  "x_forwarded_for": "1.2.3.4",
  "method": "GET",
  "scheme": "https",
  "host": "api.example.com",
  "server_name": "api.example.com",
  "request_uri": "/download/%252e%252e%252fetc/passwd",
  "uri": "/download/%2e%2e%2fetc/passwd",
  "args": "file=%252e%252e%252fetc%252fpasswd",
  "status": 400,
  "user_agent": "curl/8.0",
  "referer": "-",
  "content_type": "-",
  "content_length": "-",
  "app_id": "portal",
  "tenant_id": "mdl",
  "sdk_name": "mdl-web-sdk",
  "sdk_version": "1.2.3"
}

这类结构化日志便于后续按 event_typeevent_subtyperisk_levelactionsdk_versionclient_ip 聚合分析。OWASP Logging Cheat Sheet 也建议应用日志保持一致,便于跨系统采集、关联、分析和管理。 [mdn.org.cn]


六、同时输出 Access Log 和 Security Log

建议分两个日志:

access log:所有请求都记录
security log:只记录异常/拦截/告警事件

1. Access Log

access_log /var/log/nginx/access_json.log url_encoding_json;

2. Security Log

OpenResty 用:

ngx.log(ngx.WARN, cjson.encode(event))

如果要单独文件,可在 NGINX error log 中按级别收集,或者通过 sidecar / fluent-bit / filebeat 采集后按 event_type=url_encoding_anomaly 分流。


七、自动拦截上线建议

阶段 1:只记录,不拦截

# 命中后只写日志,不 return 400

观察:

1. 哪些服务最多
2. 哪些 SDK 版本最多
3. 哪些参数最多
4. 是否有业务合法误报

阶段 2:只拦截高风险 Path

先拦截:

%252e%252e%252f
%252e%252e%255c
%255c

谨慎处理:

%252f

因为某些业务可能把完整 URL 放到参数里,里面的 / 被双重编码可能造成误报。Thales 的文档也提到,Double URL Encoding 常作为规避技术,但 redirect URL 参数保存真实 URL 时可能出现误报,需要结合参数语义处理。 [rfc2cn.com]


阶段 3:高风险 Query 参数拦截

对以下字段启用:

redirect_uri
returnUrl
next
callback
path
file
download
url
target

策略:

解码一次后仍有 %XX => block
非法 URL 编码 => block
普通参数 => warn

八、测试命令

1. 应该被拦截:双重编码路径穿越

curl -i 'http://api.example.com/download/%252e%252e%252fetc/passwd'

预期:

HTTP/1.1 400 Bad Request

2. 应该被记录:包含 %25

curl -i 'http://api.example.com/search?q=Tom%2520Jerry'

预期:

可先放行,但日志中 has_percent25=1

3. 高风险参数双重编码应拦截

curl -i 'http://api.example.com/login?redirect_uri=https%253A%252F%252Fapp.example.com%252Fcallback'

预期:

HTTP/1.1 400 Bad Request

九、最终推荐配置组合

如果你们当前是普通 NGINX:

1. 先用 map + if 实现高风险路径自动拦截
2. access_log 用 JSON 格式记录更多字段
3. 后续接入 OpenResty 或 WAF 做更细粒度参数检测

如果可以用 OpenResty:

1. Path 风险直接 block
2. 高风险 Query 参数 decode once 后仍有 %XX 直接 block
3. 普通参数出现 %25 先 warn
4. 安全事件输出 JSON 日志
5. 日志中记录 request_id、sdk_name、sdk_version、matched_pattern、action

十、简短总结

自动拦截:
优先拦截 %252e%252e%252f、%252e%252e%255c、%255c、%253c、%253e 等高风险模式。

更多日志字段:
记录 request_uri、uri、args、has_percent25、matched_pattern、risk_level、action、request_id、sdk_name、sdk_version。

上线策略:
先观察,再灰度拦截,最后对高风险 Path 和高风险参数强制拦截。