引言

在 NGINX 中常用一种 “比较变量” 的手法,在编程语言中称为 “多路分支” (Case statement),也就是 nginx map,需要注意的一点是,太低版本 NGINX MAP 中只能使用单变量

Before version 0.9.0 only a single variable could be specified in the first parameter. [1]

下面将了解下 nginx map 的具体使用方式

nginx map使用

Nginx 配置主要是声明性的,这同样应用于 MAP 指令,NGINX MAP 是定义在 http{} 级别,最大的特点是仅在引用时进行处理, 如果请求未触及使用 NGINX MAP 变量的配置部分,则不会执行该 map 变量查找。换句话来理解,当在上下文 server, Location, if 等中使用结果变量时(指定的不是计算结果,而是在需要时计算该结果的公式),才会被使用,在 NGINX 需要使用该变量之前,NGINX MAP 不会给请求增加任何开销。

NGINX MAP 用于根据另一个变量的值创建一个变量,如下所示:

conf
map $variable_to_check $variable_to_set {
    "check_if_variable_matches_me"   "variable_matches_checked_value";
    default                          "no_match";
}

在上面的例子中, 变量 $variable_to_set 的被设置的结果为:如果 $variable_to_check 值为 “check_if_variable_matches_me”, 那么 $variable_to_set 将被设置为值 “variable_matches_checked_value” , 否则将设置为 “no_match”。

上面的就是一个编程语言的分支语句,例如将上面语句转换为 bash shell,那么意思为

bash
1
2
3
4
5
if [ "$variable_to_check" == "check_if_variable_matches_me" ]; then
    variable_to_set="variable_matches_checked_value"
else
    variable_to_set="no_match"
fi

当然作为分支语句,是支持多路分支的,他的写法如下:

conf
map $thing $useful_variable {
    "thing_matches_me"         "thing_matched_1";
    "nope_thing_matches_me"    "thing_matched_2";
    default                    "no_match";
}

正则表达式在map中特性

正则表达式 (regular expression) 是一种用于匹配源变量中复杂字符串模式的有用方法,但它会增加解析表达式的开销。默认情况下,NGINX MAP 指令在每个请求处理过程中只执行一次查找,即正则表达式的开销被限制为一次查找。但启用 “volatile” 参数会关闭变量缓存,这意味着每次使用 NGINX MAP 时都需要执行一次完整查找,从而增加了请求的额外开销,尤其是在高负载情况下,特别是当使用正则表达式时,可能会导致性能下降。

另外 “volatile” 参数的副作用是关闭了依赖于 “volatile” MAP 变量的缓存。如果需要复杂的正则表达式,那么在 MAP 中不要使用 “volatile” 参数,如果关联的 MAP 变量或任何依赖于该 MAP 的变量在多个地方被引用。这种情况在流量增加并导致查找操作增多时才会变得明显,此时CPU利用率会升高;最重要的是要注意,CPU利用率上升的原因可能不明显,因此需要谨慎使用 “volatile” 参数。

volatile 在计算机中的术语是 “易失性”,例如IP 地址暂时保存在Web 服务器的 易失性 存储器中;随后将被立即删除

使用正则表达式检查漏洞接口

这里提出一个关于正则表达式的示例 “用于验证某些输入数据” 将其代理到一个有漏洞的后端服务器的 URI 中包含 “//” 字符,从而绕过了某些安全保护措施,观察到的模式符合以下格式,"//api/product" 或 “/api//product”。此外,在某些时间点会出现周期性的请求峰值,URI 中包含 “%2f” 或 “%2F”,类似这样:"/api%2Fproduct" 或 “%2fapi/product”。这些模式可以使用带有正则表达式的 MAP 来匹配,并可以在安全规则中使用 MAP 变量。

使用到的匹配模式包含内置变量 $uri (不包含请求参数),这个参数可以用于上面提到的案例来做风险过滤,因为使用 $uri 来构建匹配规则看起来很适合。然而,这里存在一个问题,即 NGINX 可能会在请求处理的执行阶段修改或规范化 $uri 的值,这可能导致匹配规则不会匹配实际发送的 URI。

规范化编码:NGINX 在规范化 $uri 时可能会解码其中的特殊字符。例如,%2f 可能被解码为 /,这意味着匹配规则很将不会匹配实际的编码。

这里建议使用 NGINX 变量 $request_uri 来构建匹配器,而不是 $uri,以确保准确匹配请求的 URI,同时保留查询字符串。$request_uri 是一个 NGINX 内置变量,包含了请求的完整 URI,包括查询字符串。与之前提到的 $uri 不同,$request_uri 不会在请求处理的执行阶段修改或规范化,因此匹配内容与原请求保持原始不变。

另外,为了简化处理,可以创建两个不同的 MAP,一个用于匹配实际内容,另一个用于 shun 规则。这种分离方式将允许在其他 MAP 中重复使用包含原始不变 URI 的 $uri_only 变量,如下所示:

conf
map $request_uri $uri_only {
    "~^(?<u>[^\?]+)\?(?:.*)?"  $u;
    default                    $request_uri;
}

map $uri_only $shun_if_client_is_a_baddy {
    "~\/\/"                    1;
    "~*%2f"                    1;
    default                    0;
}

在上面示例中,建立了 “分离” 方式的规则,下面是针对这组 map 含义解释:

map \$request_uri \$uri_only 创建了一个 map 将 $request_uri 的值用于匹配和分析

  • ~:告诉解析器后面的字符串应被解释为正则表达式。
  • ^:表示正则表达式将从 $request_uri 的字符串值的开头进行匹配。
  • ^(?<u>...):这里创建了一个 “命名捕获组”,名为 u,它会匹配括号中的表达式,并将匹配的部分分配给 $u 变量。这个捕获组只在映射内部有效,不能在其他地方使用。
  • ^(?<u>[^\?]+):这部分正则表达式使用方括号来定义捕获的字符,匹配的内容是从开头(^)到第一个问号 ? 之前的所有字符。这样,它捕获了 $request_uri 的未修改部分,即不包括查询字符串的部分。
  • \?(?:.*)?:这一部分匹配一个问号 ?,后面跟着一个可选的未捕获组,包含任意数量的任何字符直到字符串的末尾。尽管这部分正则表达式不是必要的,因为前面已经捕获了整个 URI 的未修改部分,但它被添加为完整性和可能的其他情况。

map \$uri_only \$shun_if_client_is_a_baddy :这个 map,用于将 $uri_only 的值用于匹配和决定是否将客户端标记为不良客户端。以下是有关这行代码的解释:

  • ~:告诉解析器后面的字符串应被解释为正则表达式,用于匹配 $uri_only
  • \/\/:这部分正则表达式匹配 $uri_only 中是否包含两个连续的正斜杠。如果匹配成功,将为 $shun_if_client_is_a_baddy 赋值 “1”,否则为 “0”。
  • ~*:这部分告诉解析器正则表达式应该以不区分大小写的方式进行匹配。
  • %2f:这部分正则表达式匹配 $uri_only 中是否包含字符串 “%2f”。如果匹配成功,将为 $shun_if_client_is_a_baddy 赋值 “1”,否则为 “0”。

规则应用

要使用上面 map 生效,可以将其放置在 server{} 上(同级),用于执行上述两个 map 并在请求处理阶段的早期触发响应:

conf
if ($shun_if_client_is_a_baddy = 1) {
    return 403 'You shall not pass!!!';
}

比较变量

在 NGINX MAP 应用中经常遇到的示例是使用纯 NGINX 指令来比较两个变量是否相等。而在 nginx location 上下文中,并不推荐使用 if [2],更多情况下执行变量比较通常推荐借助脚本语言来处理,例如下面一个 lua 示例

conf
location /compare {
    access_by_lua_block {
        if ngx.var.variable1 == ngx.var.variable2 then
            ngx.say("Variables are equal")
        else
            ngx.say("Variables are not equal")
        end
    }
}

但使用脚本语言增加了 NGINX 配置的复杂性以及需要内嵌或在引用单独文件中管理的另一段代码,在这种情况下 map 操作比较两个变量的场景是非常有用的。

conf
# if delimeter between two variables is ':'
map $thing1:$thing2 $do_things_match {
    "~^([^:]+):\1$"         1;
    default                 0;
}

上面的规则解析如下:

  1. ~:字符告诉解析器后面的字符串应被解释为正则表达式。
  2. ^:这个符号表示正则表达式将从字符串值的开头进行匹配。
  3. ^([^:]+):这部分正则表达式创建了一个无名捕获组,用于捕获从字符串开头(^)到冒号 : 之前的所有字符(不包含冒号)。这个捕获的内容将被分配给一个名为 “\1” 的后向引用变量。
  4. :\1$:这一部分用于将捕获的内容与 $thing2 变量的值进行比较。

如果 $thing1$thing2 匹配,则这个表达式将被视为匹配(或为真),并将设置一个新的变量 $do_things_match 的值为 “1”。如果 $thing1 不匹配 $thing2,则表达式不匹配,并将设置 $do_things_match 的值为 “0”。

下面是这个示例的一个应用,请求应该具有匹配的查询字符串 $arg_foo 和 X-BAR header

conf
map $arg_FOO:$http_x_bar $shun_mismatched_payload {
    "~^([^:]+):\1$"         1;
    default                 0;
}

那么可以在 server{} 段中增加判断

conf
if ($shun_mismatched_payload = 1) {
    return 403 'You shall not pass!!!';
}

真实请求IP的获取

例如通常我们需要在日志中打印用户的真实IP,而这个IP隐藏的很深,通常引用了多个字段,例如

  • Remote Address 是nginx与客户端进行TCP连接过程中,获得的客户端真实地址。Remote Address 无法伪造,因为建立 TCP 连接需要三次握手,如果伪造了源 IP,无法建立 TCP 连接,更不会有后面的 HTTP 请求。 一般情况下,在Envoy作为最外层代理时,此IP为真实的IP客户端IP
  • X-Real-IP 是一个自定义头。X-Real-Ip 通常被 HTTP 代理用来表示与它产生 TCP 连接的设备 IP,这个设备可能是其他代理,也可能是真正的请求端。X-Real-Ip 目前并不属于任何标准,代理和 Web 应用之间可以约定用任何自定义头来传递这个信息。
  • X-Forwarded-For X-Forwarded-For 是一个扩展头。HTTP/1.1(RFC 2616)协议并没有对它的定义,它最开始是由 Squid 这个缓存代理软件引入,用来表示 HTTP 请求端真实 IP,现在已经成为事实上的标准,被各大 HTTP 代理、负载均衡等转发服务广泛使用,并被写入 RFC 7239(Forwarded HTTP Extension)标准之中。通常,X-Forwarded-For可被伪造,并且使用CDN会被重写

例如,下面从 CDN 获取真实 IP 的示例

conf
map $http_x_connecting_ip $client_vsip {
	"" $http_x_real_ip;
	~^(?P<firstAddr>[0-9\.]+),?.*$ $fristAddr;
}

map $client_vsip $client_ydip {
	"" $http_Incap_client_IP;
	~^(?P<firstAddr>[0-9\.]+),?.*$ $fristAddr;
}

map $client_ydip $client_ip {
	"" $http_x_forwarded_for;
	~^(?P<firstAddr>[0-9\.]+),?.*$ $fristAddr;
}

map $client_ip $clientRealIP {
	"" $remote_addr;
	~^(?P<firstAddr>[0-9\.]+),?.*$ $fristAddr;
}

这个 NGINX 配置示例中包含了一系列的 map 指令,用于创建变量映射,以根据不同的请求头信息来设置一系列变量的值。这些变量之间形成了一种链式映射,最终将请求的真实 IP 地址存储在 $clientRealIP 变量中。让我解释这些映射的作用:

  1. 第一个 MAP 指令:
    • $http_x_connecting_ip 是输入变量,根据请求中的 X-Connecting-IP 头部的值。
    • $client_vsip 是输出变量,它根据 $http_x_real_ip 或请求头部中的 X-Real-IP 值进行映射。
    • 这个映射检查 $http_x_connecting_ip 的值,如果为空(""),则将 $http_x_real_ip 的值赋给 $client_vsip,否则根据正则表达式提取 $http_x_connecting_ip 中的 IP 地址,并赋给 $client_vsip
  2. 第二个 MAP 指令:
    • $client_vsip 是输入变量,它是前一个映射的输出。
    • $client_ydip 是输出变量,它根据 $http_Incap_client_IP$client_vsip 进行映射。
    • 这个映射检查 $client_vsip 的值,如果为空(""),则将 $http_Incap_client_IP 的值赋给 $client_ydip,否则根据正则表达式提取 $client_vsip 中的 IP 地址,并赋给 $client_ydip
  3. 第三个 MAP 指令:
    • $client_ydip 是输入变量,它是前一个映射的输出。
    • $client_ip 是输出变量,它根据 $http_x_forwarded_for$client_ydip 进行映射。
    • 这个映射检查 $client_ydip 的值,如果为空(""),则将 $http_x_forwarded_for 的值赋给 $client_ip,否则根据正则表达式提取 $client_ydip 中的 IP 地址,并赋给 $client_ip
  4. 第四个 MAP 指令:
    • $client_ip 是输入变量,它是前一个映射的输出。
    • $clientRealIP 是输出变量,它根据 $remote_addr$client_ip 进行映射。
    • 这个映射检查 $client_ip 的值,如果为空(""),则将 $remote_addr 的值赋给 $clientRealIP,否则根据正则表达式提取 $client_ip 中的 IP 地址,并赋给 $clientRealIP

最后在 location 段中将 $clientRealIP 向后传递

conf
proxy_set_header X-Forwarded-For $clientRealIP

Reference

[1] Module ngx_http_map_module

[2] If is Evil… when used in location context

[3] NGINX Map Comparisons