Nginx 反向代理问题汇总

Nginx 反向代理问题汇总

使用Nginx反向代理中出现的多类问题统一汇总分析并给出解决思路。

Nginx 反代提示421 misdirected request

在一次反向代理某星球网站时出现了421的问题。

https://www.zsxq.com/ 以该网站为例。

使用最简单反代配置。

server {
        listen        88;
        server_name  localhost;
        location /api/ {
                proxy_pass https://api.zsxq.com/;
                proxy_set_header  Host api.zsxq.com;
        }
}

返回421的状态码。

是什么导致“421 错误定向请求”错误?

客户端需要为此请求建立新连接,因为请求的主机名与用于此连接的服务器名称指示 (SNI) 不匹配。

Misdirected Request

当一个 TLS 证书在多个域之间共享时,该证书要么具有通配符名称,例如“*.example.org”,要么带有多个备用名称。使用 HTTP/2 的浏览器会识别这一点,并为此类主机重用已打开的连接。
但是当在同一个 TLS 连接上有多个主机的多个请求时,重新协商就变得不可能了。然后它可能会向客户端触发错误“421 Misdirected Request”。

上面的配置报错也是这种情况。*.zsxq.com使用了通配符的域名证书。nginx 不知道具体要访问哪一个网站,所以需要指定对应的 proxy_ssl_name

正确的配置如下,指定ssl_name多个参数:

server {
        listen        88;
        server_name  localhost;
        location /api/ {
                proxy_ssl_server_name on;
                proxy_ssl_name api.zsxq.com;
                proxy_ssl_verify off;
                proxy_pass https://api.zsxq.com/;
                proxy_set_header  Host api.zsxq.com;
                proxy_set_header Accept-Encoding '';
                proxy_set_header Cookie 'xxxx';
        }
}

反代成功!

反向代理解决图片等资源防盗链

还是以zsxq.com为例,该网站的图片资源会检测referer头,如果不是从自己网站过去的就会返回403,但直接用浏览器打开图片链接就能正常显示。

区别在于前者的request请求header中是带有referer头,浏览器访问时默认会带上。

这里有两种解决思路:

  1. 将图片等静态资源也进行代理,但比较占用反代服务器的带宽资源。
  2. 让浏览器发起请求时,不带上referer头。

解决方法:
在反代的页面中插入meta标签。

<meta name="referrer" content="no-referrer">

虽然Nginx没有直接插入的指令来实现,但可以用sub_filter替换的方式曲线救国。

添加配置如下:

sub_filter_once off;
sub_filter_types *;
sub_filter '</head>' '<meta name="referrer" content="no-referrer"></head>';

通过替换实现在head标签中插入meta标签。此时浏览器在请求资源时就不会携带referer头。

图片资源加载成功!

反向代理视频资源

设置反向代理缓冲大小。之前文章有写过,参考:

反向代理sub_filter字符替换不生效

通常有以下两种原因:

  1. 源站点启用了gzip压缩。
  2. 需替换的MIME类型为text/html之外的字符串,比如server端返回json格式的内容,sub_filter是不会进行替换的。

第一种的解决方案如下:

proxy_set_header Accept-Encoding "";

如果增加这行代码后问题依旧存在,大概率是源站点启用了强制gzip压缩。nginx反代替换关键字前并不会自动解压缩,所以无法执行替换内容。

解决思路:
反代2次。第一次反代时增加gzip off;设置项,以输出无压缩的内容,第二次反代本机地址,实现关键字替换。

    location /unzip/ {
        # 负责解压缩内容
        proxy_set_header Host target.com; #目标域名
        proxy_pass https://target.com/; #目标域名
    }

    location / {
        proxy_set_header Host my.target.com; #自己的域名

        proxy_set_header Accept-Encoding '';

        gzip off;
        sub_filter_types *; #替换所有类型
        sub_filter 'xxxxx' 'xxxxxxx'; #替换内容
        sub_filter_once off; #所有匹配到的都替换
        proxy_pass http://127.0.0.1/unzip/; #多走一次转发, 让/go先解压缩gzip
    }

第二种的解决方案如下:

sub_filter_types *;  #替换所有类型

Location 和 proxy_pass 加不加斜杠的区别?

proxy_pass 指令后面的参数有讲究,但在实际的应用中就分为两种情况。

proxy_pass 后面不带路径,就原封不动传给后端。
proxy_pass 后面带路径,就去掉 Location 的匹配再传给后端。

proxy_pass 后面url只有host没有路径

这里指不包含 $uri ,如:

  • http://host  ✅
  • https://host  ✅
  • http://host:port  ✅
  • https://host:port  ✅
  • http://host/  ❌
  • http://host:port/  ❌

这时候 location 匹配的完整路径将直接透传给后端,如:

// 访问:   /                               后端:   /
// 访问:   /api/xx                         后端:   /api/xx
// 访问:   /api/xx?aa                      后端:   /api/xx?aa
location / {
    proxy_pass http://node:8080;
}

// 访问:   /api/                           后端:   /api/
// 访问:   /api/xx                         后端:   /api/xx
// 访问:   /api/xx?aa                      后端:   /api/xx?aa
// 访问:   /api-xx?aa                      后端:
location /api/ {
    proxy_pass http://node:8080;
}

// 访问:   /api/                           后端:   /api/
// 访问:   /api/xx                         后端:   /api/xx
// 访问:   /api/xx?aa                      后端:   /api/xx?aa
// 访问:   /api-xx?aa                      后端:   /api-xx?aa
location /api {
    proxy_pass http://node:8080;
}

proxy_pass 后面的url中带有路径

注意,这里的路径哪怕只是一个 / 也是存在的,如:

  • http://host  ❌
  • https//host/  ✅
  • http://host:port  ❌
  • https://host:port/  ✅
  • http://host/api  ✅
  • http://host/api/  ✅

当 proxy_pass url 的 url 包含路径时,匹配时会根据 location 的匹配后的链接透传给 url ,注意匹配后是下面这样。

location 规则访问的原始链接匹配之后的路径
location //
location //aa
location //a/b/c?da/b/c?d
location /a//a/
location /a//a/b/c?db/c?d
// 访问:   /                               后端:   /
// 访问:   /api/xx                         后端:   /api/xx
// 访问:   /api/xx?aa                      后端:   /api/xx?aa
location / {
    proxy_pass http://node:8080/;
}

// 访问:   /api/                           后端:   /
// 访问:   /api/xx                         后端:   /xx
// 访问:   /api/xx?aa                      后端:   /xx?aa
// 访问:   /api-xx?aa                      未匹配
location /api/ {
    proxy_pass http://node:8080/;
}

// 访问:   /api                            后端:   /
// 访问:   /api/                           后端:   //
// 访问:   /api/xx                         后端:   //xx
// 访问:   /api/xx?aa                      后端:   //xx?aa
// 访问:   /api-xx?aa                      后端:   /-xx?aa
location /api {
    proxy_pass http://node:8080/;
}

// 访问:   /api/                           后端:   /v1
// 访问:   /api/xx                         后端:   /v1xx
// 访问:   /api/xx?aa                      后端:   /v1xx
// 访问:   /api-xx?aa                      未匹配
location /api/ {
    proxy_pass http://node:8080/v1;
}

// 访问:   /api/                           后端:   /v1/
// 访问:   /api/xx                         后端:   /v1/xx
// 访问:   /api/xx?aa                      后端:   /v1/xx
// 访问:   /api-xx?aa                      未匹配
location /api/ {
    proxy_pass http://node:8080/v1/;
}

可以看出,当 proxy_pass url 中包含路径时,结尾的 / 最好同 location 匹配规则一致。

举个例子:

配置A(有斜杠)

location /some/path/ {
    proxy_pass https://xxxx.com/;
}

请求 /some/path/document 会被代理到 https://xxxx.com/document

配置B(无斜杠)

location /some/path {
    proxy_pass https://xxxx.com;
}

请求 /some/path/document 会被代理到 https://xxxx.com/some/path/document

alias指定返回某个文件

alias使用注意两点:

  1. Windows使用绝对路径一定要用反斜杠 \ 。
  2. 使用相对路径注意将文件放到默认路径下面,直接指定文件名。
location = /api/v2/groups {
		default_type application/json;
		alias C:\phpstudy_pro\Extensions\Nginx1.15.11\groups.json;
                #alias groups.json;     #使用相对路径
}

这里测试发现使用alias默认会拼接路径导致报错。可以通过error.log进行debug。

比如我是用绝对路径,但是注意一定要用 \ ,不然会出现下面的 failed (3: The system cannot find the path specified) 的报错。

root与alias的区别?

root与alias主要区别在于nginx如何解释location后面的uri,这会使两者分别以不同的方式将请求映射到服务器文件上。

root的处理结果是:root路径+location路径
alias的处理结果是:使用alias路径替换location路径
alias是一个目录别名的定义,root则是最上层目录的定义。

还有一个重要的区别是alias后面必须要用“/”结束,否则会找不到文件的,而root则可有可无。

location ^~ /path/ {
     root /www/root/html/;
}

如果一个请求的URI是/path/a.html时,web服务器将会返回服务器上的/www/root/html/path/a.html的文件。

location ^~ /path/ {
     alias /www/root/html/new_path/;
}

如果一个请求的URI是/path/a.html时,web服务器将会返回服务器上的/www/root/html/new_path/a.html的文件。注意这里是new_path,因为alias会把location后面配置的路径丢弃掉,把当前匹配到的目录指向到指定的目录。

注意:
1. 使用alias时,目录名后面一定要加”/”。
3. alias在使用正则匹配时,必须捕捉要匹配的内容并在指定的内容处使用。
4. alias只能位于location块中。(root可以不放在location中)

envsubst 替换环境变量的坑

在docker中配置Nginx模板通常会用到envsubst来替换 docker -e var=xxx 传递进来的变量,生成Nginx的配置文件。

第一个坑:bash -c 无法获取到环境变量的值

下面这个dockerfile的配置是获取不到变量的。

CMD /bin/bash -c "envsubst '${YOUR_ACCESS_TOKEN},${YOUR_IP_OR_DOMAIN}' < /etc/nginx/nginx.template > /etc/nginx/nginx.conf && \
exec /usr/sbin/nginx -g 'daemon off;"'

需要直接执行才能获取。

CMD envsubst '${YOUR_ACCESS_TOKEN},${YOUR_IP_OR_DOMAIN}' < /etc/nginx/nginx.template > /etc/nginx/nginx.conf && \
exec /usr/sbin/nginx -g 'daemon off;'

第二个坑:envsubst 不指定变量会替换所有的$var

因为Nginx的模板文件中会自带变量,比如$uri、$http_user_agent等。

envsubst < /etc/nginx/nginx.template

采用这种方式生成的配置,上面$uri、$http_user_agent都会被替换为空。

// 正常的替换结果
if ($uri = '/'){
	return 301 http://xx.xx.xx.xx/dweb2/index/group/init;
}
// envsubst 不指定变量的替换结果
if (  = '/'){
	return 301 http://xx.xx.xx.xx/dweb2/index/group/init;
}

此时执行Nginx就会报错。

使用auth_basic限制页面账号密码访问

最好不要将auth_basic放在server中,建议放在需要认证的页面location中。

auth_basic "zsxq";
auth_basic_user_file /etc/nginx/htpasswd.txt;

如果将auth放在server中,也就是所有的页面包括下面api接口也会需要鉴权,由于api接口是没有页面的,所以浏览器是不会弹出认证窗口的,请求接口直接报错401未授权访问。需要打开控制台才能排查出原因。

不建议的配置
推荐的配置

如何生成auth_basic_user_file密码文件?

可以通过openssl来生成账号密码,比如需要生成账号密码都为zsxq的文件可以用下面的命令。

[root@zgao ~]# echo zsxq:"$(openssl passwd zsxq)"
zsxq:VwI6UZuCQF0AE

如何开启反向代理缓存?

在http中添加proxy_cache_path,注意不要放在server中。同时在日志中添加”$upstream_cache_status”,可以显示当前请求是否命中缓存。

    log_format  main  '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for" "$upstream_cache_status"'
                      '$request_time $upstream_response_time $pipe';

    access_log  /var/log/nginx/access.log  main;
    proxy_cache_path /var/cache/nginx levels=1:2 keys_zone=my_cache:10m max_size=10g inactive=60m use_temp_path=off;

然后在location中添加开启反向代理缓存的配置。

        location /api/ {
                proxy_ssl_server_name on;
                proxy_ssl_name api.zsxq.com;
                proxy_ssl_verify off;
                proxy_pass https://api.zsxq.com/;
                proxy_set_header  Host api.zsxq.com;


                proxy_cache my_cache;
                proxy_cache_valid 200 302 60m;
                proxy_cache_valid 404 1m;
                # add_header X-Cache-Status $upstream_cache_status;

                set $cache_key "$scheme$proxy_host$request_uri";
                proxy_cache_key $cache_key;
                proxy_hide_header Set-Cookie;
                proxy_hide_header Expires;
                proxy_ignore_headers Cache-Control Expires Set-Cookie ;               
        }

确保在请求中不包含变化频繁的头字段,例如动态生成的 Cookie。如果必须设置 Cookie,可以使用 proxy_ignore_headers 忽略某些头字段,如果服务端响应头中存在Cache-Control、Expires、Set-Cookie这类字段,Nginx是不会进行缓存的。

proxy_hide_header和proxy_ignore_headers区别

  • proxy_hide_header:用于隐藏指定的响应头,使其不传递给客户端。
  • proxy_ignore_headers:用于忽略指定的响应头,使其对 Nginx 的缓存逻辑无效,但仍然可以传递给客户端。

如何判断请求是否命中缓存?

确保在日志格式中包含 $upstream_cache_status 变量。这样,当查看 Nginx 日志(通常在 /var/log/nginx/access.log)时,可以看到缓存命中状态。例如:

192.168.1.1 - - [24/May/2024:12:00:00 +0000] "GET /api/v2/data HTTP/1.1" 200 1234 "-" "Mozilla/5.0" "HIT"
192.168.1.2 - - [24/May/2024:12:00:01 +0000] "GET /api/v2/data HTTP/1.1" 200 1234 "-" "Mozilla/5.0" "MISS"

可以确认哪些请求是从缓存中获取的(例如 HIT),哪些是未命中的(例如 MISS)。

如何替换header中的Location重定向?

sub_filter只针对body生效,替换header头需要用到其他的指令。比如反向代理上游返回一个302的跳转,我们需要修改Location重定向的URL就需要用到proxy_redirect。

< HTTP/1.1 302 Found
< Content-Type: text/html; charset=UTF-8
< Date: Thu, 20 Jun 2024 08:29:24 GMT
< Location: https://xxxx.com/mweb/views/topicdetail/topicdetail.html
< Server: openresty
< X-Frame-Options: SAMEORIGIN
< Transfer-Encoding: chunked

这是上游返回302的重定向,需要将Location中xxxx.com替换为我们的域名,但是又需要保留后面的路径和查询参数。

server {
    listen 80;
    server_name yourdomain.com;

    location / {
        proxy_pass http://xxxx.com;
        # 替换Location头部中的域名,保留路径和查询参数
        proxy_redirect ~*^https://xxxx.com(.*)$ https://yourdomain.com$1;
    }
}

解释:

  • ~*:这表示使用正则表达式匹配。
  • **^https://xxxx.com(.*)$**:这是正则表达式,`^` 表示字符串开始,https://xxxx.com是需要被替换的部分,(.*)是一个捕获组,它匹配https://xxxx.com之后的任何内容(包括路径和查询字符串),并将这部分存储起来以便在替换中使用。
  • **https://yourdomain.com$1**:这是替换后的新URL,`$1`代表之前正则表达式中捕获的内容(即原URL中`https://xxxx.com`之后的部分)。

通过这种方式配置后,任何来自后端服务器的以https://xxxx.com开头的重定向响应都会被修改为以https://yourdomain.com开头,同时保留原有的路径和查询参数。这样可以确保重定向行为的一致性,而不会因为域名的更改而影响原有的链接结构。

赞赏

微信赞赏支付宝赞赏

Zgao

愿有一日,安全圈的师傅们都能用上Zgao写的工具。

4条评论

Github+Picgo+jsdelivr构建图床的注意事项 – Longlong's Blog 发布于11:16 上午 - 7月 14, 2023

[…] 今天又又又421了,终于在这片文章中找到了答案 Nginx 反向代理问题汇总 具体操作就是: […]

ccqnb 发布于1:08 下午 - 2月 22, 2023

你好,我用nginx反代v2ex.com网站时发现头像(gravator)无法显示,请问该如何修改配置文件?

    匿名 发布于10:31 上午 - 2月 23, 2023

    可以F12看下图片加载情况

      ccqnb 发布于9:56 下午 - 2月 23, 2023

      cdn.v2ex.com/avatar/ 和cdn.v2ex.com/gravatar/无法访问

发表评论