反向代理接受客户端连接,再单独请求上游应用。因此,应用套接字看到的对端是代理,较早客户端的信息则可能通过请求头传入。这些头部只有在部署明确谁可以设置、如何验证时才有可信含义。请求经过代理,不会自动让所有转发身份或协议声明变得可靠;需要把网络连接事实与请求携带的文本声明区分开。
坏掉的样子
客户端到达代理前就可以提供 X-Forwarded-For。普通转发可能保留它,$proxy_add_x_forwarded_for 也只是追加当前对端地址,不会移除不可信前缀。如果应用盲信第一个地址,就可能把请求归属到客户端自行选择的值。正确解释取决于真实可信代理链,不能因为字段名包含 Forwarded 就赋予整条列表相同信任。
Host 处理是独立决策。nginx 默认上游 Host 基于代理目标,不会自动采用客户端访问的公共 Host。因此,构造绝对跳转或选择虚拟主机的应用,在代理后可能表现不同。应只转发预期主机上下文,并应用服务自己的可接受主机名策略;复制头部不等于验证其中名字,也不能替代虚拟服务器入口限制。
代理也不保证后端细节完全不暴露。响应头、跳转、正文、诊断信息或其他公开配置,都可能披露源站信息。限制直接访问和控制特定响应字段很有价值,但这不同于声称一条 proxy_pass 就能隐藏全部上游身份,或提供所需的所有传输保护。地址隐藏、访问控制和加密需要分别依据真实部署判断。
快速验证
下文集成实验的结果保留自历史运行记录;本次修订没有重新执行该实验,不能将这些记录当成本次环境的新实测。
使用宿主 Python、Docker,以及固定 nginx 和 Python 镜像运行脚本。实测为 Linux arm64 上的 nginx 1.28.0、Python 3.11.16、Docker 29.3.1。它创建私有 Docker 网络与真实回显后端,只把代理发布到宿主回环,验证配置并清理自己的资源,不涉及生产端点或凭据。实验中的伪造值使用文档专用地址:
from contextlib import contextmanager
from pathlib import Path
from tempfile import TemporaryDirectory
import http.client,json,socket,subprocess,time,uuid
NGINX='nginx@sha256:552e7481ca93ffccd046aa658dbbed22caefbc09c66fa7cd247cbb90b8a5c609'
PYTHON='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84'
def docker(*args,check=True):
result=subprocess.run(['docker',*args],capture_output=True,text=True,timeout=120)
if check:assert result.returncode==0,result.stderr
return result
def wait(predicate):
deadline=time.monotonic()+15
while time.monotonic()<deadline:
try:
if predicate():return
except OSError:pass
time.sleep(.1)
raise AssertionError('fixture did not become ready')
@contextmanager
def lab(configuration,files=None,backend=None):
name='xbcatm-nginx-'+uuid.uuid4().hex[:10];tags=[name+':proxy',name+':backend'];containers=[name+'-proxy',name+'-backend']
try:
with TemporaryDirectory(prefix=name+'-') as directory:
root=Path(directory);(root/'files').mkdir();(root/'nginx.conf').write_text(configuration)
for path,content in (files or {}).items():
target=root/'files'/path;target.parent.mkdir(parents=True,exist_ok=True);target.write_text(content)
docker('network','create',name)
if backend is not None:
(root/'backend.py').write_text(backend)
(root/'backend.Dockerfile').write_text(f'FROM {PYTHON}\nCOPY backend.py /backend.py\nCMD ["python", "/backend.py"]\n')
docker('build','--network','none','-f',str(root/'backend.Dockerfile'),'-t',tags[1],str(root))
docker('run','-d','--name',containers[1],'--network',name,'--network-alias','backend',tags[1])
wait(lambda:docker('exec',containers[1],'python','-c','import socket;s=socket.create_connection(("127.0.0.1",8081),timeout=.5);s.close()',check=False).returncode==0)
(root/'Dockerfile').write_text(f'FROM {NGINX}\nCOPY nginx.conf /etc/nginx/nginx.conf\nCOPY files /srv\n')
docker('build','--network','none','-t',tags[0],str(root))
docker('run','--rm','--network',name,tags[0],'nginx','-t')
docker('run','-d','--name',containers[0],'--network',name,'-p','127.0.0.1::8080',tags[0])
details=json.loads(docker('inspect',containers[0]).stdout)[0]
binding=details['NetworkSettings']['Ports']['8080/tcp'][0];assert binding['HostIp']=='127.0.0.1';port=int(binding['HostPort'])
def ready():
connection=socket.create_connection(('127.0.0.1',port),timeout=.5);connection.close();return True
wait(ready)
def request(path,method='GET',headers=None,body=None):
connection=http.client.HTTPConnection('127.0.0.1',port,timeout=5)
try:
connection.request(method,path,body=body,headers=headers or {})
response=connection.getresponse();return response.status,{k.lower():v for k,v in response.getheaders()},response.read()
finally:connection.close()
yield containers[0],request
finally:
docker('rm','-f',*containers,check=False);docker('image','rm',*tags,check=False);docker('network','rm',name,check=False)
def config(locations,extra=''):
return 'worker_processes 1;\nerror_log /tmp/error.log notice;\nevents {}\nhttp {\naccess_log off;\n'+extra+'\nserver { listen 8080; root /srv;\n'+locations+'\n}\n}\n'
backend=r'''from http.server import BaseHTTPRequestHandler,HTTPServer
import json
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
body=self.rfile.read(int(self.headers.get('Content-Length','0')))
document={'path':self.path,'method':self.command,'body':body.decode(),'headers':{key:self.headers.get(key) for key in ['Host','X-Forwarded-For','X-Real-IP','X-Forwarded-Proto','X-Forwarded-Host']}}
data=json.dumps(document).encode()
self.send_response(200);self.send_header('Content-Type','application/json');self.send_header('Content-Length',str(len(data)));self.send_header('X-Backend-Info','fixture-node');self.end_headers();self.wfile.write(data)
do_POST=do_GET
def log_message(self,*args):pass
HTTPServer(('0.0.0.0',8081),Handler).serve_forever()
'''
locations=r'''
location /default { proxy_pass http://backend:8081; }
location /append { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_pass http://backend:8081; }
location /edge {
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_hide_header X-Backend-Info;
proxy_pass http://backend:8081;
}
'''
with lab(config(locations,'server_tokens off;'),backend=backend) as (container,request):
forged={'Host':'public.example','X-Forwarded-For':'198.51.100.77','X-Forwarded-Proto':'forged-scheme','X-Real-IP':'198.51.100.88'}
status,headers,body=request('/default',headers=forged);plain=json.loads(body)
assert status==200 and plain['headers']['Host']=='backend:8081'
assert plain['headers']['X-Forwarded-For']=='198.51.100.77' and plain['headers']['X-Forwarded-Proto']=='forged-scheme'
assert headers['x-backend-info']=='fixture-node'
status,headers,body=request('/append',headers=forged);appended=json.loads(body)['headers']['X-Forwarded-For'].split(', ')
assert status==200 and len(appended)==2 and appended[0]=='198.51.100.77'
status,headers,body=request('/edge',headers=forged);edge=json.loads(body)['headers']
assert status==200 and edge['Host']==edge['X-Forwarded-Host']=='public.example'
assert edge['X-Forwarded-For']==edge['X-Real-IP']==appended[-1]
assert edge['X-Forwarded-For'] not in ['198.51.100.77','198.51.100.88'] and edge['X-Forwarded-Proto']=='http'
assert 'x-backend-info' not in headers and headers['server']=='nginx'
print('ordinary forwarding preserves forged forwarding headers and defaults Host to the upstream authority')
print('append retains the untrusted prefix; the direct-client edge configuration replaces it with the actual socket peer and scheme')
print('explicit response-header suppression removes one backend field, while server_tokens off still exposes the nginx product name')
每个请求都把 Host 设为 public.example,并携带特意伪造的转发值。默认路由向后端发送 Host: backend:8081,同时保留伪造的 X-Forwarded-For 和 X-Forwarded-Proto。这直接证明普通代理转发不是身份清洗步骤,也不会自动保留公共主机名。后端回显让这些差异可见,不需要仅凭配置推理。
追加路由使用 $proxy_add_x_forwarded_for,后端收到两个逗号分隔地址:客户端提供的地址在前,nginx 观察到的套接字对端在后。原本不可信的前缀仍在。追加有用连接上下文,不会让整个结果列表同样可信;在这个直接面对客户端的结构中,盲信最左地址就会破坏预期边界。
入口路由用 $remote_addr 替换 X-Forwarded-For 和 X-Real-IP,用 $scheme 设置转发协议,并显式传入选择的主机上下文。后端收到真实观察到的对端值,不是两个伪造地址之一;本实验有意使用普通 HTTP,所以协议为 http。测试跨请求比较这些值,不假定 Docker 网桥一定分配某个固定地址。
套接字对端可能反映 Docker 网络路径,这不表示能穿过所有 NAT 或更早代理,恢复任意互联网用户地址。如果入口本身位于可信负载均衡器之后,部署需要有意定义如何接受该跳的信息。本例验证的是直接客户端信任边界,不覆盖所有代理链;应用不能把示例中正确的取值规则无条件搬到不同拓扑。
后端还返回 X-Backend-Info: fixture-node。默认路由会暴露它,入口路由通过明确的 proxy_hide_header 删除它。关闭 server_tokens 后,响应仍包含 Server: nginx。这把隐藏一个后端字段,与隐藏代理产品名或证明其他响应内容没有实现细节区分开。一个字段消失,不等于所有信息泄露渠道都消失。
连接网络访问规则与头部解释
后端依赖代理建立的上下文时,应限制哪些调用者可以直接访问它。如果使用 real IP 模块,可信代理地址范围应准确,并确保最终地址解释与应用自身信任设置一致。过宽信任范围可能让非预期调用者提供看似权威的转发信息,因此不能把网络可达与可信来源简单画等号。
明确 TLS 在哪里终止,以及上游连接是否也需要加密和证书验证。nginx 在当前一跳观察到的协议,不会自动描述更早一跳。跳转、安全 Cookie 和生成 URL 如果依赖转发协议或主机名,就应通过实际部署链路测试。只验证内部 HTTP 回显,不能宣布外部 HTTPS 行为也正确。
最后,同时检查应用收到的请求和客户端收到的响应。头部配置、网络隔离和信息披露控制,分别回答不同问题。两侧证据可以让组合行为可审阅,而不是假定代理会默默补上每个缺失保证。明确事实来源,才能让应用正确使用代理提供的上下文。
延伸阅读
来源
- nginx HTTP proxy module,默认 Host 处理、转发头与响应头隐藏,已核验 2026-10-15。
- nginx real IP module,显式信任的地址来源与代理链,已核验 2026-10-15。
- nginx core server_tokens and variables,套接字对端地址、请求协议与服务器版本披露,已核验 2026-10-15。