关于使用NGINX反向代理可以将TLS Server Name Indication (SNI)与不同的域名路由到后端主机的问题

本文是LabBase公司《LabBase科技日历2022年圣诞日历》第22天的文章。作为LabBase公司的一员,这是我首次发布的文章。另外,昨天的文章是由Guevara先生撰写的这篇文章。

作为一个研究人员,我与LabBase的研究工程师们一起进行共同的研究开发。在这个研究开发过程中,我偶然发现了一个NGINX的问题,这个问题一直被认为是理所当然的,但实际上实施起来并非如此。我将向大家报告这个事例,并且提醒大家,这个问题可能会导致安全事件,当我发现时,我记得我感到了一阵冷汗。

此外,标题中只提到了NGINX,但在Caddy中也会出现相同的问题。在解释NGINX的情况之后,还会提及Caddy。

首先

首先,我們將介紹TLS的Server Name Indication (SNI)以及使用NGINX反向代理實現的HTTPS多域名主機託管。

传输层安全性(TLS)服务器名称指示(SNI)

clienthello.png

我們將在上圖中記錄TLS會話建立的概要圖。在TLS中,客戶端開始建立會話的初始消息ClientHello中,以明文形式註明希望連接的域名(server_name),並通知服務器。這被稱為服務器名稱指示(SNI),是TLS的一個擴展。在TLS中,為了將多個域名處理在同一個IP地址和主機下,這個擴展實際上是必需的。根據這個SNI,服務器將回應客戶端,包括相應的證書。然後,完成證書驗證等操作,建立TLS會話。

通过NGINX使用HTTPS反向代理进行多域名托管

HTTPS Reverse Proxy for Multi Doamin Hosting

在托管多个HTTPS服务的反向代理中,需要通过TLS终结来进行连接管理,这是通过SNI实现的。换句话说,

    根据收到的ClientHello中的SNI,切换证书并建立TLS会话,将HTTP消息转发到预先设置的每个server_name对应的后端服务。

我们将不得不这样做。

作为这样一种反向代理,NGINX是最常用的软件之一。具体来说,可以通过以下配置来设置上图的工作配置。

server {
  server_name www.example.org;
  listen 443 ssl http2;
  listen [::]:443 ssl http2;
  # www.example.com の証明書・秘密鍵
  ssl_certificate /path/to/com.cert;
  ssl_certificate_key /path/to/net.key;

  # backend.example.comへルーティング
  location / {
    proxy_pass http://backend.example.com:80;
  }
}

server {
  server_name www.example.net;
  listen 443 ssl http2;
  listen [::]:443 ssl http2;
  # www.example.net の証明書・秘密鍵
  ssl_certificate /path/to/net.cert;
  ssl_certificate_key /path/to/net.key;

  # backend.example.netへルーティング
  location / {
    proxy_pass http://backend.example.net:8000;
  }
}

server指令相当于Apache中的VirtualHost。NGINX在接收到来自客户端的ClientHello后,会通过SNI通知的域名来确定具有匹配的server_name的server指令。然后,将该server指令的配置反映到TLS的建立和后端服务的路由中,以供共同使用。

我也有过认为会去做某事的时候…

这篇文章想要表达的意思是什么?

在NGINX反向代理中,默认情况下不考虑SNI和HOST请求头的一致性。

NGINX作为反向代理,在TLS的建立和路由到后端服务方面,在server指令中默认没有一致性。也就是说,如果为某个域名建立了TLS会话,那么为该域名设定的原本路由目标可能会路由到完全不同的后端服务。

具体而言,以下的域名和server_name条目将与server指令匹配,它们将被独立地应用。

    • TLSの構築: TLSのClientHello内のSNI

 

    バックエンドへのルーティング: TLS構築後に流れてくるHTTPのHOSTリクエストヘッダあるいは:authority擬似ヘッダ (以下ではまとめてHOSTリクエストヘッダとして簡単化します)

换句话说,如果TLS SNI和HOST请求标头不一致,那么可能会引发意外事件。

然而,只要在配置文件中加入if语句这样令人难以置信的应对措施,就不会发生这种情况。关于这一点,我将在最后进行说明。

在安全方面存在的担忧

即使无法保持SNI和HOST请求头的一致性,路由仍然是可能的,这种情况可能存在以下安全上的顾虑。

首先,通过反向代理来终止两个域名的TLS(HTTPS)。

    • Url https://tadashii.example.com -> バックエンドホストtadashiiへルーティング

 

    Url https://akui.example.org -> バックエンドホストakuiへルーティング

假设路由设置是为了达到这个目的。

然而,如果不考虑本文介绍的NGINX的行为,可能会导致在对域名tadashii.example.com进行TLS连接之后,意外地可以访问到后端主机akui,这取决于域名tadashii.example.com的证书的可信度。

根据客户端软件(例如浏览器)的实现情况,可以在TLS通信中伪装为访问正规主机,但实际上却可以访问与正规主机不同的后端主机。这是在同一反向代理中处理多个域和后端主机,每个拥有不同所有人的情况下,特别需要考虑的事项。这可能导致秘密信息(如Cookie等)泄漏到意外的后端主机,造成潜在的安全风险。

让我们来实证一下

环境配置

使用Docker快速启动实验环境。通过在NGINX上设置反向代理,将两个域名路由到不同的后端服务(容器)。这次我们将尝试使用普通的Web服务器NGINX作为后端服务器,以及使用jwilder/whoami返回容器ID。

version: '3.9'
services:
  nginx:
    image: nginx:latest
    container_name: proxy-nginx
    ports:
      - 80:80
      - 443:443
    restart: unless-stopped
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf
      - ./certs:/etc/nginx/certs:ro

  # https://<Domain_A>/のルーティング先サービス
  backend-nginx:
    image: nginx:latest
    container_name: backend-nginx
    restart: unless-stopped

  # https://<Domain_B>/のルーティング先サービス
  backend-whoami:
    image: jwilder/whoami:latest
    container_name: backend-whoami
    restart: unless-stopped

以下是使用最基本的NGINX反向代理配置(nginx.conf)。本次配置是使用Let’s Encrypt证书获取的,但也可以使用自签名证书。请注意,针对两个后端服务,我们设置了完全不同的域名和证书。

# このバックエンドサーバへルーティングするのは<Domain A>へのアクセスのみ
server {
  server_name <Domain A>;
  listen 443 ssl http2;
  listen [::]:443 ssl http2;
  # Domain A の証明書・秘密鍵
  ssl_certificate /etc/nginx/certs/<Domain A>.crt;
  ssl_certificate_key /etc/nginx/certs/<Domain A>.key;

  # ルーティング先はbackend-nginxの80ポート。
  location / {
    proxy_pass http://backend-nginx:80;
  }
}

server {
  # このバックエンドサーバへルーティングするのは<Domain B>へのアクセスのみ
  server_name <Domain B>;
  listen 443 ssl http2;
  listen [::]:443 ssl http2;
  # Domain B の証明書・秘密鍵
  ssl_certificate /etc/nginx/certs/<Domain B>.crt;
  ssl_certificate_key /etc/nginx/certs/<Domain B>.key;

  # ルーティング先はbackend-whoamiの8000ポート。
  location / {
    proxy_pass http://backend-whoami:8000;
  }
}

如果写NGINX的配置和生成/获取证书太繁琐的话,可以使用nginx-proxy来配置NGINX,同时使用acme-companion来获取证书,最终结果是一样的,所以没有问题。

尝试使用cURL

首先我们尝试使用常规的cURL命令。

% curl https://<Domain A>/
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>

〜中略〜

</html>
% curl https://<Domain B>/
I'm 22777b5d3e12

这是最初设计的操作吧。那么,我们来改变HOST请求头并尝试使用cURL进行访问。

% curl -vv -H "HOST: <Domain A>" https://<Domain B>/

〜中略〜
〜Domain Bのport 443へ接続〜

* Connected to <Domain B> (<IP Addr>) port 443 (#0)

〜中略〜
〜Domain Bの証明書 (CN/SANに注目) が提示され、その検証も成功〜

* Server certificate:
*  subject: CN=<Domain B>
*  start date: Nov  9 17:08:21 2022 GMT
*  expire date: Feb  7 17:08:20 2023 GMT
*  subjectAltName: host "<Domain B>" matched cert's "<Domain B>"
*  issuer: C=US; O=Let's Encrypt; CN=R3
*  SSL certificate verify ok.
* Using HTTP2, server supports multiplexing
* Copying HTTP/2 data in stream buffer to connection buffer after upgrade: len=0
* h2h3 [:method: GET]
* h2h3 [:path: /]
* h2h3 [:scheme: https]
* h2h3 [:authority: <Domain A>]
* h2h3 [user-agent: curl/7.86.0]
* h2h3 [accept: */*]
* Using Stream ID: 1 (easy handle 0x14e00be00)
> GET / HTTP/2

〜ここでHOSTリクエストヘッダはDomain Aを示す〜

> Host: <Domain A>
> user-agent: curl/7.86.0
> accept: */*

〜中略〜
〜Domain Aに接続され、コンテンツがGETされる〜

<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>

〜中略〜

</html>
* Connection #0 to host <Domain B> left intact

嘿……TLS SNI被忽略了,而是连接到了由HOST头部指定的A域的后端服务器。

当然也可以逆向思考。

% curl -H "HOST: <Domain B>" https://<Domain A>/
I'm 22777b5d3e12

其实,我们发现服务器上配置的证书与实际连接的后端服务完全不匹配,而是通过HOST请求头指定的。当我偶然发现这个时,非常吃惊。

要注意的防范措施:为了保持NGINX中SNI和HTTP HOST请求头的一致性。

只需在NGINX配置文件(nginx.conf)的每个server指令中添加以下内容,就可以解决这个问题。

if ($ssl_server_name != $host) {
  return 421;
}

通过这种方式,在TLS SNI和HTTP HOST请求头不一致的情况下,可以返回421错误的misdirected request,并拒绝访问。cURL的响应也会如下所示。

% curl -H "HOST: <Domain B>" https://<Domain A>/
<html>
<head><title>421 Misdirected Request</title></head>
<body>
<center><h1>421 Misdirected Request</h1></center>
<hr><center>nginx/1.23.3</center>
</body>
</html>

如果请求URL的scheme(HTTPS)与authority不匹配,就会返回可以处理的400系列HTTP响应。由于TLS SNI和authority(=HOST)的组合有问题,因此自然而然地会返回这个错误。

421错误的请求
这可能是由于服务器未配置为针对请求URI中包含的方案和授权组合生成响应而导致的。

实际上,RFC6066明确规定了需要检查应用层协议和SNI所指示的域名(server_name)是否相同。

由于客户端可能在应用协议中提供不同的服务器名称,依赖于这些名称相同的应用服务器实现必须检查以确保客户端在应用协议中没有提供不同的名称。

问答

Apache和Caddy怎么样呢?

Apache 是一个开源软件基金会的项目,用于开发和维护一个跨平台的服务器软件。

默认情况下,如果未能确保SNI和HOST请求头的一致性,则会返回421错误代码。

% curl -vv -H "HOST: <Domain A>" https://<Domain B>/
<!DOCTYPE HTML PUBLIC "-//IETF//DTD HTML 2.0//EN">
<html><head>
<title>421 Misdirected Request</title>
</head><body>
<h1>Misdirected Request</h1>
<p>The client needs a new connection for this
request as the requested host name does not match
the Server Name Indication (SNI) in use for this
connection.</p>
</body></html>

Caddy could be paraphrased in Chinese as 手提包 .

…Caddy啊,你也是啊…… Caddy跟NGINX有相同的问题。以下是Caddyfile的示例。

{
  # 取得済み証明書・秘密鍵を利用する
  auto_https disable_certs
}

<Domain A> {
  tls /certs/<Domain A>.crt /certs/<Domain A>.key
  reverse_proxy backend-nginx:80
}

<Domain B> {
  tls /certs/<Domain B>.crt /certs/<Domain B>.key
  reverse_proxy backend-whoami:8000
}

由于docker-compose.yml与NGINX几乎相同,所以割愛。以下是cURL的结果。

% curl -H "HOST: <Domain B>" https://<Domain A>/
I'm 22777b5d3e12

通过在Caddyfile的全局选项中添加strict_sni_host选项,Caddy可以实施以下防护措施。这样,如果TLS SNI(服务器名称指示)与HOST请求头不一致,将会返回421状态码。

{
  servers {
    strict_sni_host
  }
}

因为可以进行集中设置,所以比NGINX好,但是希望默认情况下将strict_sni_host设置为ON…

如果使用客户证书,是否有同样的担忧?

假设正在为两个域名建立HTTPS反向代理,其中一个需要通过客户端证书进行身份验证,而另一个则不需要。客户端认证也是在server指令内部进行配置的。在这种情况下,

    • 認証を行うドメインのバックエンドサービスに対して、

 

    認証のないドメインについて構築したTLSから認証なしでアクセスできるかどうか、

在中文中转述上述内容有以下选项:

有这样的担忧。这对于NGINX和Caddy都是不可能的。然而,明确规定strict_sni_host insecure_off后,似乎可以实现Caddy的通信。

汇总

我认为保持TLS SNI与后端主机的域名一致,以避免意外路由到意外的后端服务是在运行反向代理时默认假设的一项内容。然而,在NGINX和Caddy中存在一个陷阱,它们并不做这样的假设。特别是在NGINX中,我对需要在配置文件的每个server指令中编写if语句来应对这个问题的做法持强烈质疑。

据说,这一事件已经向NGINX的安全团队报告了,但回复显示这是客户端实现的问题,并非NGINX的问题。

我将此问题提交给了 Nginx 安全团队,他们表示这不是 Nginx 的安全漏洞,而应由客户端验证证书,并且不要发送针对他们应该通过该连接访问的主机的恶意请求。

随着调查这个事件的进行,发现NGINX团队的响应情况并不好,因此我自己制作了一个使用Rust编写的HTTPS反向代理,以保持SNI和HTTP HOST请求头的一致性。

GitHub junkurihara/rust-rpxy是一个用Rust编写的简单而超快的HTTP反向代理,可以为多个域名提供服务,并在http/1.1、2和3上终止TLS连接。

虽然不具备多功能性,但也可以支持HTTPS多域名,并且运行稳定、快速,与NGINX相当。Caddy和Apache太慢了。

下面的文章是关于。。。

以下的文章是关于@takahiro-yamada的!请期待吧!

根据此server_name使用TCP的HTTP/2图表,但基本上在HTTP/3也是一样的。 ↩仅限于实证,使用cURL并添加–insecure选项即可进行确认。 ↩

广告
将在 10 秒后关闭
bannerAds