nginx location 匹配使用规范化后的 URI 路径,而不是完全未经处理的请求目标文本。百分号编码字符会被解码,相对路径组件会被消解,相邻斜杠也可能按配置合并。之后的处理还能再次改变当前 URI,并触发新的 location 搜索。当请求似乎进入了与原始拼写不同的处理器时,需要同时理解这两类转换,而不是只比较配置中的字面前缀。
坏掉的样子
只根据原始请求文本判断的路径过滤器,可能与 nginx 路由视图不一致。编码斜杠可以变成路径分隔符,点段可以移除前面的组件,后端还可能采用自己的解码规则。因此,安全与路由决策需要在每个边界约定解释方式,不能假定所有组件看到完全相同的路径字符串。否则同一请求可能在检查时属于一个家族,处理时却属于另一个。
只记录 $uri 也可能掩盖实际输入。它表示当前规范化 URI,并可能在请求处理期间改变;$request_uri 则保留完整原始 URI,包括原始查询字符串。两者不能互相完全替代:前者解释当前路由,后者帮助重建客户端实际请求。诊断时应按问题选择字段,而不是把一个名字相近的变量当成全部路径信息。
index 和 try_files 还会引入另一类转换。目录请求可以内部变成索引文件请求,文件查找失败可以内部跳到回退 URI,使不同 location 和参数生效。所以,最初的前缀匹配不一定就是产生最终响应的配置。若只验证入口匹配,而忽略后续内容处理,就可能漏掉访问控制、缓存或响应状态发生变化的位置。
快速验证
下文集成实验的结果保留自历史运行记录;本次修订没有重新执行该实验,不能将这些记录当成本次环境的新实测。
使用宿主 Python、Docker 和固定 nginx 镜像运行脚本。实测为 Linux arm64 上的 nginx 1.28.0、Docker 29.3.1。它构建并检查临时配置,只发布宿主回环端口,发送客户端未预先规范化的请求目标,最后删除自身镜像、容器和网络。这样可以观察 nginx 收到明确文本之后的处理,而不被浏览器自动整理路径干扰:
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'
locations=r'''
location / { return 200 "$uri|$request_uri|$args"; }
location /protected/ { return 403 "blocked"; }
location /docs/ { try_files $uri $uri/ /fallback?reason=missing; }
location = /docs/index.html { return 200 "$uri|$request_uri|$args"; }
location = /fallback { return 200 "$uri|$request_uri|$args"; }
'''
with lab(config(locations),{'docs/index.html':'stored-index'}) as (container,request):
cases={
'/a//b/../c?x=1':'/a/c|/a//b/../c?x=1|x=1',
'/a%2Fb/%2e%2e/c?x=1':'/a/c|/a%2Fb/%2e%2e/c?x=1|x=1',
'/Protected/item':'/Protected/item|/Protected/item|',
'/docs/':'/docs/index.html|/docs/|',
'/docs/missing?x=1':'/fallback|/docs/missing?x=1|reason=missing'}
for path,expected in cases.items():
status,headers,body=request(path);assert status==200 and body.decode()==expected,(path,status,body)
for path in ['/protected/item','/protected%2Fitem','/a/../protected/item']:assert request(path)[0]==403,path
assert request('/bad%ZZ')[0]==400
print('matching uses normalized paths: percent-decoding, dot segments, and merged slashes differ from the original request URI')
print('index and try_files internal redirects choose another location; request_uri remains original while uri and args can change')
请求 /a//b/../c?x=1 得到当前 URI /a/c,原始请求 URI 仍为 /a//b/../c?x=1,参数仍为 x=1。这在实测默认配置下展示斜杠合并和点段消解。响应同时包含三个字段,能够直接比较含义,不需要从最终页面内容反向猜测路径到底经过哪些转换。
编码请求 /a%2Fb/%2e%2e/c?x=1 到达同样的当前路径 /a/c,但 request_uri 仍保留原始编码拼写。它说明百分号解码参与路由前的规范化。测试没有依赖浏览器地址栏,因为浏览器可能在发送前已经改写路径。只有知道线上真正收到的请求目标,才能正确解释服务器行为。
受保护前缀对 /protected/item、/protected%2Fitem 和 /a/../protected/item 都返回 403,因为规范化后都属于该路径家族。在这个 Linux 环境中,/Protected/item 因大小写不同而进入普通默认处理。无效百分号编码 %ZZ 则得到 HTTP 400,而不是被当成成功通向任意内容的路径。这些是不同边界,不能混成一个通用字符串比较。
实验放置了真实 /srv/docs/index.html。请求 /docs/ 经 index 处理后进入精确的 /docs/index.html location,该块返回诊断标记,而不是存储文件本身的正文。原始 request_uri 仍是 /docs/。这让内部 location 变化变得可见,虽然客户端没有收到跳转状态,也没有自行发起第二次请求。
对于 /docs/missing?x=1,try_files 找不到文件或目录,会内部选择 /fallback?reason=missing。响应报告当前 URI 为 /fallback,原始请求 URI 为 /docs/missing?x=1,当前参数为 reason=missing。本例回退中显式查询替换了早先参数;如果希望保留原参数,需要明确配置,不能认为所有内部跳转都会自动保持它们。
沿完整请求链验证路径契约
修改路由时,测试有代表性的编码形式、重复斜杠、点段、缺失文件和目录请求。如果客户端、代理与应用的规范化方式不同,集成验证应包含真实组件。这个小实验建立的是所选配置下的 nginx 行为,不证明所有上游框架或安全过滤器都以相同方式解释相同字节。跨组件一致性需要另外的实际请求证据。
诊断中有意选择原始和当前 URI 字段,同时认识到查询字符串可能包含敏感信息,只记录运维任务真正需要的内容。内部跳转可能改变最终处理器和日志上下文,所以除了首次匹配,也要验证事件最终记录在哪里。否则日志缺失或字段变化可能被误判成请求没有到达。
回退行为应明确。缺失资源不应意外变成成功的应用页面,从而掩盖部署错误;受保护路径也不应在重写后落入无关回退。语法检查能够发现配置格式问题,带有明确状态、字段和正文断言的请求,才能验证用户最终实际收到的行为。这两种证据需要一起使用。
来源
- nginx core location, try_files, and variables,URI 规范化、内部跳转以及原始与当前 URI,已核验 2026-10-15。
- How nginx processes a request,index 处理与后续 location 选择,已核验 2026-10-15。
- nginx 1.28.0 core implementation,实测版本的 location 搜索与内部跳转行为,已核验 2026-10-15。