通用 nginx 技术笔记标题卡,带有 Observe、Decide、Act 三个面板。

nginx 选择第一个匹配正则,不会计算通用具体程度分数

nginx 不会给所有 location 表达式计算一份通用的具体程度分数。普通前缀按匹配长度选择,精确 location 可以结束搜索,竞争正则则按声明顺序求值。某个表达式在人看来更窄,也可能输给前面更宽的正则。把这些选择规则混在一起,是许多看似仔细的路由变更影响意外路径的原因,必须分别理解每类规则。 坏掉的样子 通用 .css 后缀正则,可以先于后面的 /assets/ 限定正则获胜。后者因为包含目录,看起来似乎更具体,但 nginx 不会比较两个模式在语义上谁更窄。在这里的平面结构中,只要两者都匹配,顺序就决定使用哪个配置。路径更长或正则文字更多,都不是正则之间的额外优先分。 调整普通前缀顺序是另一回事。把 /assets/ 移到 / 之前,不会让它“更优先”;在采用前缀结果时,更长匹配本来就决定它获胜。如果把所有 location 都当成同一个先匹配先使用列表,审阅可能做出无效调整,却漏掉真正控制故障路径的正则顺序。修改文本与修改行为之间,需要通过规则和请求证据连接。 较短的 ^~ 前缀,也不是整个子树的永久保护层。如果存在更长的普通前缀并被选中,较短的标记前缀就不再是抑制正则阶段的候选。因此,新增一个看似无害的更详细前缀,可能让原来停留在标记 location 的请求重新被后缀正则接管。这种变化不能仅凭新增块自身内容看出来。 快速验证 下文集成实验的结果保留自历史运行记录;本次修订没有重新执行该实验,不能将这些记录当成本次环境的新实测。 使用宿主 Python、Docker 和固定 nginx 镜像运行脚本。实测为 Linux arm64 上的 nginx 1.28.0、Docker 29.3.1。它构建两份临时配置,分别执行 nginx -t,再通过宿主回环端口发送真实请求。每份配置都只使用平面 location,结束时清理自己的容器、镜像标签和私有网络,不修改既有服务: ...

十二月 27, 2027 · 2 分钟 · XB Field Notes
通用 nginx 技术笔记标题卡,带有 Observe、Decide、Act 三个面板。

响应缓冲在资源预算内吸收两端速度差

响应缓冲允许 nginx 以一种速度读取上游,同时以另一种速度向客户端交付数据。开启 proxy_buffering on 后,nginx 使用配置的内存缓冲区,也可以把大响应的一部分写入临时文件。它不必先收齐完整响应才发送第一批字节。两条连接能在多大程度上独立推进,取决于可用缓冲容量,而不是“开启”这两个字就能无限解耦。 坏掉的样子 “慢客户端永远不会阻塞后端”是错误承诺。内存缓冲、临时文件上限、磁盘吞吐和套接字缓冲都有限。当 nginx 无法继续吸收数据,上游仍然可能受到反压。禁用临时文件缓冲,也不等于关闭响应缓冲;它只是移除了一个等待数据的存储位置,使容量边界更容易到达。应明确哪个容量耗尽,而不是把所有等待都叫作后端慢。 缓冲还可能影响端点的延迟预期。虽然不要求收完整份响应,小事件仍可能先累积,之后才对客户端可见。流式端点应明确选择响应缓冲策略,并通过完整链路验证事件可见时间。即使关闭 nginx 响应缓冲,应用自身缓冲、压缩、TLS 和其他中间层仍会影响交付。因此,修改一条指令不能代替实际观察用户什么时候收到每个事件。 资源交换发生在代理层。把响应写入临时文件,可以让后端较早结束工作,但在客户端慢慢读取期间会占用临时存储和磁盘读写能力。为每条连接扩大内存缓冲,则可能在高并发下产生另一类容量问题。真正需要决定的是:面对实际工作负载,代理应该吸收多少数据、承担多少等待,而不是只凭单个缓冲值看起来更大来判断性能。 快速验证 下文集成实验的结果保留自历史运行记录;本次修订没有重新执行该实验,不能将这些记录当成本次环境的新实测。 将脚本保存为 check-response-buffering.py,准备 Python、Docker、固定镜像和足够的临时磁盘空间后运行。记录环境为 Linux arm64 上的 nginx 1.28.0。专用后端提供受控响应开关和进度计数,nginx 仅通过宿主回环监听。实验会传输三份 32 MiB 正文,完成后清理自己的资源,不使用现有业务后端: 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_bytes(content if isinstance(content,bytes) else content.encode()) 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() request.port=port 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' import hashlib backend=r''' from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer from urllib.parse import urlsplit,parse_qs import json,socket,threading lock=threading.Lock();state={};gates={};chunk=b'x'*65536;total=32*1024*1024 class Handler(BaseHTTPRequestHandler): protocol_version='HTTP/1.1' def log_message(self,*args):pass def do_GET(self): parsed=urlsplit(self.path);path=parsed.path if path=='/state': with lock:data=json.dumps(state).encode() self.send_response(200);self.send_header('Content-Length',str(len(data)));self.end_headers();self.wfile.write(data);return if path=='/release': target=parse_qs(parsed.query)['path'][0] with lock:gates[target].set() self.send_response(204);self.end_headers();return gated=path.startswith('/early-');size=2*len(chunk) if gated else total with lock:state[path]={'written':0,'done':False};gates[path]=threading.Event() self.connection.setsockopt(socket.SOL_SOCKET,socket.SO_SNDBUF,65536) self.send_response(200);self.send_header('Content-Length',str(size));self.end_headers() for offset in range(0,size,len(chunk)): self.wfile.write(chunk) with lock:state[path]['written']=offset+len(chunk) if gated and offset==0:assert gates[path].wait(15) with lock:state[path]['done']=True ThreadingHTTPServer(('0.0.0.0',8081),Handler).serve_forever() ''' locations=''' location /early-on { proxy_buffering on; proxy_pass http://backend:8081; } location /early-off { proxy_buffering off; proxy_pass http://backend:8081; } location /large-on { proxy_buffering on; proxy_pass http://backend:8081; } location /large-off { proxy_buffering off; proxy_pass http://backend:8081; } location /large-nodisk { proxy_buffering on; proxy_max_temp_file_size 0; proxy_pass http://backend:8081; } location / { proxy_pass http://backend:8081; } ''' extra=''' proxy_buffer_size 4k; proxy_buffers 4 4k; proxy_busy_buffers_size 8k; proxy_temp_file_write_size 8k; proxy_max_temp_file_size 64m; proxy_temp_path /tmp/proxy; ''' with lab(config(locations,extra),backend=backend) as (proxy,request): results={} def state():return json.loads(request('/state')[2]) for mode in ('on','off'): path='/early-'+mode;connection=http.client.HTTPConnection('127.0.0.1',request.port,timeout=10) connection.request('GET',path);response=connection.getresponse();first=response.read(1024) snapshot=state()[path];assert response.status==200 and first==b'x'*1024 and not snapshot['done'] assert request('/release?path='+path)[0]==204 rest=response.read();connection.close();assert first+rest==b'x'*(2*65536) results[path]={'first_bytes_before_backend_completion':len(first),'backend_written_at_first_read':snapshot['written']} total=32*1024*1024;expected=hashlib.sha256(b'x'*total).hexdigest() for mode in ('on','off','nodisk'): path='/large-'+mode;sock=socket.socket();sock.setsockopt(socket.SOL_SOCKET,socket.SO_RCVBUF,65536) sock.settimeout(20);sock.connect(('127.0.0.1',request.port)) connection=http.client.HTTPConnection('127.0.0.1',request.port,timeout=20);connection.sock=sock connection.request('GET',path);response=connection.getresponse();assert response.status==200 wait(lambda:state().get(path,{}).get('written',0)>0) if mode=='on':wait(lambda:state()[path]['done']) else: previous=-1;stable_since=time.monotonic();deadline=time.monotonic()+10 while time.monotonic()<deadline: current=state()[path];assert not current['done'],current if current['written']!=previous:previous=current['written'];stable_since=time.monotonic() if time.monotonic()-stable_since>1:break time.sleep(.05) else:raise AssertionError('backend did not reach observable backpressure') before=state()[path] if mode!='on':assert 0<before['written']<total sock.setsockopt(socket.SOL_SOCKET,socket.SO_RCVBUF,1024*1024) digest=hashlib.sha256();received=0 while block:=response.read(1024*1024):digest.update(block);received+=len(block) connection.close();wait(lambda:state()[path]['done']) assert received==total and digest.hexdigest()==expected results[path]={'before_client_drain':before,'received_bytes':received,'sha256':digest.hexdigest()} log=docker('exec',proxy,'cat','/tmp/error.log').stdout spills=[line for line in log.splitlines() if 'buffered to a temporary file' in line] assert any('/large-on ' in line for line in spills),log assert not any('/large-off ' in line or '/large-nodisk ' in line for line in spills) results['temporary_file_used_for_large_on']=True print(json.dumps(results,sort_keys=True)) 首先,后端为一个 131,072 字节响应写出 65,536 字节,然后等待显式放行。无论响应缓冲开启还是关闭,客户端都能在后端尚未完成时读到 1,024 字节正文。脚本随后才放行后一半,并检查完整响应。这直接否定了“开启响应缓冲就一定等上游整份响应完成才交付”的说法;受控开关把响应尚未完成这一事实固定下来。 ...

十二月 7, 2027 · 3 分钟 · XB Field Notes
通用 nginx 技术笔记标题卡,带有 Observe、Decide、Act 三个面板。

显式启用 gzip,再验证协商与解码后的字节

gzip 是响应内容编码:nginx 可以发送压缩表示,由接受该编码的客户端解码。启用它需要 gzip on;gzip_types 扩展有资格压缩的 MIME 类型,却不会单独激活压缩。最终结果取决于响应、请求方能力和实际生效配置。只有压缩后仍能正确还原目标内容,较小正文才有价值,不能把“传输字节变少”与“功能已经正确”直接画等号。 坏掉的样子 在 gzip off 下列出 MIME 类型,不会让响应变成 gzip。另一个常见遗漏是 gzip_vary on。nginx 并不会仅因为发生压缩,就无条件添加 Vary: Accept-Encoding;该行为受对应指令控制。共享缓存需要知道哪些请求头维度影响表示选择,否则不同解码能力的客户端可能拿到不合适的已存响应。压缩成功和缓存选择正确是两个需要分别检查的结果。 最小长度设置的契约比名称暗示的更窄。gzip_min_length 根据响应 Content-Length 字段确定长度,不一定知道未指定长度响应的最终大小。因此,一个很短的动态流式响应仍可能被压缩,而同样短的静态文件会因为已知长度低于阈值而跳过压缩。不能从阈值配置推断所有小响应都不会进入压缩路径。 测量方法也可能误导。解压后输出文件的大小,不是压缩传输正文大小;压缩正文字节也不包含全部 HTTP 分帧、响应头和 TLS 流量。.gz 等文件扩展名本身不能证明实际编码。解释任何表面上的带宽节省之前,都应检查真实 Content-Encoding,并解码核对字节,否则可能只是把两种不同计量对象放在一起比较。 快速验证 下文集成实验的结果保留自历史运行记录;本次修订没有重新执行该实验,不能将这些记录当成本次环境的新实测。 将脚本保存为 check-gzip.py,准备 Python、Docker 和固定镜像后运行。记录环境为 Linux arm64 上的 nginx 1.28.0,以及一个小型 Python 后端。nginx 显式开启三级 gzip,在内置 HTML 资格之外允许 JSON,设置 1,024 字节下限,并显式启用 Vary。服务只发布到宿主回环,实验完成后清理自己的资源: ...

八月 29, 2027 · 2 分钟 · XB Field Notes
通用 nginx 技术笔记标题卡,带有 Observe、Decide、Act 三个面板。

请求限流保护的是 key 所命名的预算

请求速率限制作用于 key 表达的身份,不会自动理解人、账户或端点。limit_req_zone 为这些 key 值保存共享状态,limit_req 则在处理请求时应用对应区域。两个 location 使用相同区域和 key,可以消耗同一份预算。因此,选择变量实际上是在定义公平性与执行边界,并不是只给配置找一个方便填写的字符串。 坏掉的样子 客户端可控标识可能把一个表面上的限制变成许多独立额度。如果修改查询参数就能改变 key,不可信客户端就能选择另一份预算。空 key 更直接:nginx 不会统计 key 求值为空的请求。身份变量缺失时,请求可能无声地离开原本计划的限流范围。只测试正常携带身份的访问,会漏掉这条重要边界。 IP key 衡量的是地址,它可能代表 NAT 或中间层后面的很多用户。转发地址头要先经过明确信任策略,才能成为身份输入。相信任意 X-Forwarded-For,会让调用者制造身份;忽略真实可信代理的地址链,又可能把所有用户放进代理自身的一份预算。两种错误都不能靠提高速率解决,因为问题首先在于限制对象选错了。 共享内存的范围也很重要。区域在同一 nginx 实例的 worker 之间共享,不会自动形成跨无关进程或机器的全局计数器。兼容的平滑 reload 可以保留区域状态。如果假定每次重载配置都会重置全部额度,就可能在测试或恢复操作中,把继续出现的拒绝误判为部署坏了。应区分配置换代、进程换代与共享状态生命周期。 快速验证 下文集成实验的结果保留自历史运行记录;本次修订没有重新执行该实验,不能将这些记录当成本次环境的新实测。 将脚本保存为 check-limit-keys.py,准备 Python、Docker 和固定镜像后执行。记录环境使用带两个 worker 的 nginx 1.28.0,以及可丢弃 HTTP 后端。额度刻意设为每分钟一个请求,使各次观察落在同一预算区间;不同公开测试 key 隔离案例。监听只发布到宿主回环,结束后清理实验资源,不修改现有站点配置: ...

八月 28, 2027 · 3 分钟 · XB Field Notes
通用 nginx 技术笔记标题卡,带有 Observe、Decide、Act 三个面板。

反向代理需要明确的请求头信任边界

反向代理接受客户端连接,再单独请求上游应用。因此,应用套接字看到的对端是代理,较早客户端的信息则可能通过请求头传入。这些头部只有在部署明确谁可以设置、如何验证时才有可信含义。请求经过代理,不会自动让所有转发身份或协议声明变得可靠;需要把网络连接事实与请求携带的文本声明区分开。 坏掉的样子 客户端到达代理前就可以提供 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。这直接证明普通代理转发不是身份清洗步骤,也不会自动保留公共主机名。后端回显让这些差异可见,不需要仅凭配置推理。 ...

八月 27, 2027 · 2 分钟 · XB Field Notes
通用 nginx 技术笔记标题卡,带有 Observe、Decide、Act 三个面板。

nginx 匹配规范化路径,并可能再次选择 location

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。这在实测默认配置下展示斜杠合并和点段消解。响应同时包含三个字段,能够直接比较含义,不需要从最终页面内容反向猜测路径到底经过哪些转换。 ...

八月 26, 2027 · 2 分钟 · XB Field Notes
通用 nginx 技术笔记标题卡,带有 Observe、Decide、Act 三个面板。

结构化 nginx 日志需要同时约定转义和字段类型

看起来像 JSON 的 log_format,不一定能可靠地产生结构化事件。nginx 把变量值代入文本,而变量可能包含引号、反斜杠、控制字符,也可能完全没有数据。正确字符串转义与正确字段类型是两个独立要求。用真实解析器检查输出,能发现肉眼查看配置或 nginx -t 成功无法证明的问题,避免上线后才发现部分日志无法进入查询系统。 坏掉的样子 把 User-Agent 原样插入带引号的 JSON 字段,遇到请求头中的引号就会失败。关闭转义不会更安全,只会让请求字符改变外层记录语法。nginx 默认日志转义则有另一种问题:它可能使用十六进制 \xXX 序列,而这不是合法 JSON 字符串转义。日志适合人阅读,不代表它符合另一种数据格式的语法。 加入 escape=json 可以处理 JSON 字符串转义,但不会自动添加引号、提供 null,或把变量转换成数字。对于本地生成的响应,上游耗时可能根本不存在。如果没有定义可选值的表示方式,就直接不加引号输出,字段可能在语法上缺值;即使实际存在的字符全部转义正确,整个 JSON 仍然无效。 缺失值表示也取决于实测版本的转义路径。nginx 1.28.0 默认转义会为找不到的变量写入短横线,JSON 转义则不写变量文本,所以外面的双引号最终成为空字符串。解析器还必须把这种情况与变量本身确实包含短横线、或包含多次上游尝试列表的情况区分开,不能把所有形式都硬当成同一个数字。 快速验证 下文集成实验的结果保留自历史运行记录;本次修订没有重新执行该实验,不能将这些记录当成本次环境的新实测。 使用宿主 Python、Docker 和固定 nginx 镜像运行脚本。实测为 Linux arm64 上的 nginx 1.28.0、Docker 29.3.1。它构建包含四种日志格式的临时配置,验证语法,发送两次真实 HTTP 请求,解析生成文件,最后清理自己的容器、镜像标签和网络。请求头都是公开测试字符串,不读取真实凭据或业务日志: ...

七月 15, 2027 · 2 分钟 · XB Field Notes
通用 nginx 技术笔记标题卡,带有 Observe、Decide、Act 三个面板。

过期缓存需要明确的重新验证与陈旧响应策略

nginx 代理缓存需要分别作出几项决定:怎样识别已存响应,什么时候允许读取,什么时候允许保存新响应,以及过期副本还能怎样使用。如果把这些都看成一个 TTL 设置,故障就很难解释。验证时应同时观察请求实际经过的路径与交付字节,而不只是确认 nginx 接受了配置文件。 存储容量与新鲜度分开看 proxy_cache_path 声明磁盘存储位置及保存元数据的共享内存 keys_zone,proxy_cache 再为请求启用这个区域。元数据区大小不等于正文缓存容量。max_size 由缓存维护机制管理,不是瞬时硬磁盘配额;inactive 保留策略也不是响应的新鲜度寿命。已经过期的响应仍可能留在存储中,用于后续重新验证。 默认缓存键为 $scheme$proxy_host$request_uri,其中 request_uri 已经包含查询串,后面再加 $is_args$args 就重复拼接了参数。也不能随意替换成规范化后的 URI,或遗漏会改变表示的输入。默认启用 HEAD 转换,因此 GET 与 HEAD 共享相应缓存行为;关闭转换时,就需要在缓存键中考虑请求方法。 核对真正生效的响应策略 proxy_cache_valid 按状态码提供配置寿命,但正常处理的源站新鲜度头具有更高优先级。X-Accel-Expires 还有自己的优先顺序;它不存在时,Cache-Control 和 Expires 才参与相应设置。忽略这些头会改变实际契约。下面的实验里,源站 max-age 为一秒,覆盖了配置的六十秒;no-store 则阻止响应进入存储。 proxy_cache_revalidate 让过期条目使用 ETag、Last-Modified 等验证器发送条件请求。匹配的 304 可以保留正文并更新元数据,变化后的 200 则提供替代字节。但重新验证不等于异步刷新。后台更新是另一项指令,还需要允许在更新期间返回旧内容,不能仅凭某一个开关推断整套刷新方式。 ...

六月 24, 2027 · 3 分钟 · XB Field Notes