正则声明顺序改变处理器选择,精确和最长前缀规则则保留各自优先关系。

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,结束时清理自己的容器、镜像标签和私有网络,不修改既有服务: 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' prefixes=[ 'location / { return 200 "fallback"; }', 'location /assets/ { return 200 "asset-prefix"; }', 'location = /assets/exact.css { return 200 "exact"; }', 'location ^~ /static/ { return 200 "static-prefix"; }', 'location /static/dynamic/ { return 200 "dynamic-prefix"; }'] regexes=[r'location ~ \.css$ { return 200 "generic-css"; }',r'location ~ ^/assets/ { return 200 "asset-regex"; }'] for reverse,expected in [(False,'generic-css'),(True,'asset-regex')]: ordered_prefixes=list(reversed(prefixes)) if reverse else prefixes ordered_regexes=list(reversed(regexes)) if reverse else regexes locations='\n'.join(ordered_prefixes+ordered_regexes)+'\n'+r'location ~* \.txt$ { return 200 "case-folded-text"; }' with lab(config(locations)) as (container,request): cases={'/assets/file.css':expected,'/assets/exact.css':'exact','/static/file.css':'static-prefix','/static/dynamic/file.css':'generic-css','/static/dynamic/plain':'dynamic-prefix','/README.TXT':'case-folded-text','/elsewhere.CSS':'fallback','/elsewhere':'fallback'} for path,wanted in cases.items(): status,headers,body=request(path);assert status==200 and body.decode()==wanted,(path,status,body) print('reversing the matching regex declarations changes the winner; the first matching regex is not necessarily the most specific') print('exact locations and longest-prefix selection ignore declaration order; a longer ordinary prefix can make a shorter ^~ prefix irrelevant') 第一份配置把 .css 正则放在 /assets/ 开头的正则之前,请求 /assets/file.css 返回 generic-css。第二份反转两个正则,相同请求改为返回 asset-regex。请求没有变得更具体或更宽泛,变化的只有两个同时匹配模式的顺序。这为“正则自动选最具体”的说法提供直接反例。 ...

十二月 27, 2027 · 2 分钟 · XB Field Notes
三个相连的方块,依次标注观察、决策与行动。

框架策略明确谁可以嵌入当前页面

点击劫持利用用户对界面的误判,让一个动作落到并非其真实意图的控件上。恶意父页面可以把被嵌入的应用放在误导性视觉层后面,使用户点击实际进入该应用中的真实按钮。父页面不必能够读取子页面的文档结构,用户仍然可以与框架里的内容交互。因此,同源读取限制不能替代应用是否允许被嵌入的独立决定,也不能仅靠看不到对方文档就判断点击过程安全。 被嵌入的响应可以通过内容安全策略禁止任何祖先嵌入,或者明确列出允许的祖先来源。这里写的应当是获准包含当前文档的页面来源。假设商户页面嵌入支付页面,应由支付页面的策略允许商户来源;仅列出支付组件自己的来源,并不能表达商户有权嵌入它。配置之前先明确哪一份响应属于子页面、哪个来源是父页面,可以避免把允许列表方向写反。 把限制放到正确的响应上 嵌入祖先指令回答谁能嵌入当前文档,框架来源指令则回答当前文档自己可以加载哪些框架。给子页面设置严格的 frame-src,并不会阻止另一个父页面嵌入它。默认来源全部拒绝,也不会作为 frame-ancestors 的回退值。这些指令控制的是关系中的不同方向,不能仅因为名字都涉及框架,就把其中一个当成另一个的替代品。 嵌入祖先指令不能通过 CSP 的 meta 元素生效,必须通过响应头交付。遇到多层嵌套时,每一级祖先都需要符合策略,而不只是直接父级。某个组件即使可以合法放在指定应用中,当该应用本身又被放进未经许可的外层框架时,仍可能被阻止。因此制定允许列表时,要审阅整个预期嵌入链,不能只凭最内层组件和它紧邻的容器作判断。 坏掉的样子 一次部署可能只给容器页面增加了策略,却让真正敏感的子页面响应保持不受限制;另一次部署可能把策略写入 HTML 元数据,看到配置文字存在就误以为浏览器会执行。应检查实际被框架加载的响应,并验证浏览器是否渲染。仅收到二百状态,既不能证明嵌入成功,也不能证明没有框架防护,因为浏览器可以收到文档后,拒绝在当前祖先环境中显示它。 互相冲突的响应头也可能误导审阅者。在支持这套规则的现代浏览器里,强制执行的嵌入祖先策略优先于该响应的 X-Frame-Options。给允许某个父来源的策略再附加一个 DENY,并不必然得到期望中的更严格结果。如果确实需要保留多个响应头,应让它们与实际受众和浏览器支持约定一致,不能假定它们都像多份 CSP 那样自然取限制交集。 快速验证 将代码保存为 frame-policy.py,执行 python3 frame-policy.py –serve,并打开打印出的父页面地址。本次实际使用 Python 3.14.4 与 Chrome 152。父服务和子服务位于不同的本机回环端口,六份子响应只有合成说明文字,以及表示成功渲染的消息。父页面只接受预期子来源发来的消息并展示结果,结束后按控制键加 C 关闭两个临时服务,不会操作真实账号或业务控件。 import json import sys from http.server import BaseHTTPRequestHandler, HTTPServer from threading import Event, Thread from urllib.request import urlopen paths = ["open", "csp-block", "xfo-block", "csp-allow", "meta", "frame-src"] requests = [] class Child(BaseHTTPRequestHandler): def log_message(self, *args): pass def do_GET(self): name = self.path.lstrip("/") assert name in paths requests.append(name) meta = ('<meta http-equiv="Content-Security-Policy" content="frame-ancestors \'none\'">' if name == "meta" else "") body = ("<!doctype html><meta charset=utf-8>" + meta + "<p>Rendered: " + name + "</p>" "<script>parent.postMessage({frame:" + json.dumps(name) + "}," + json.dumps(app_origin) + ");</script>").encode() self.send_response(200) self.send_header("Content-Type", "text/html; charset=utf-8") self.send_header("Content-Length", str(len(body))) self.send_header("Cache-Control", "no-store") if name == "csp-block": self.send_header("Content-Security-Policy", "frame-ancestors 'none'") elif name == "xfo-block": self.send_header("X-Frame-Options", "DENY") elif name == "csp-allow": self.send_header("Content-Security-Policy", "frame-ancestors " + app_origin) self.send_header("X-Frame-Options", "DENY") elif name == "frame-src": self.send_header("Content-Security-Policy", "frame-src 'none'") self.end_headers() self.wfile.write(body) class Parent(BaseHTTPRequestHandler): def log_message(self, *args): pass def do_GET(self): if self.path == "/events": body = json.dumps(requests).encode() kind = "application/json" else: body = ("""<!doctype html><meta charset="utf-8"><title>Frame policy lab</title> <h1>Which child responses can this parent embed?</h1><pre id="result">Waiting for frame messages...</pre> <button id="check">Show server requests</button><div id="frames"></div><script> const child=""" + json.dumps(child_origin) + ";const names=" + json.dumps(paths) + """; window.received=[]; window.snapshot=async()=>{ const result={rendered:[...window.received].sort(),serverRequests:await (await fetch('/events')).json()}; document.getElementById('result').textContent=JSON.stringify(result,null,2);return result; }; addEventListener('message',event=>{ if(event.origin===child && names.includes(event.data?.frame)) { window.received.push(event.data.frame); document.getElementById('result').textContent=JSON.stringify(window.received,null,2); } }); document.getElementById('check').onclick=()=>window.snapshot(); for(const name of names) { const frame=document.createElement('iframe');frame.src=child+'/'+name;frame.title=name; frame.width=240;frame.height=100;document.getElementById('frames').append(frame); } </script>""").encode() kind = "text/html; charset=utf-8" self.send_response(200) self.send_header("Content-Type", kind) self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) child = HTTPServer(("127.0.0.1", 0), Child) app = HTTPServer(("127.0.0.1", 0), Parent) child_origin = f"http://127.0.0.1:{child.server_port}" app_origin = f"http://127.0.0.1:{app.server_port}" threads = [Thread(target=server.serve_forever, daemon=True) for server in (child, app)] for thread in threads: thread.start() try: for name in paths: with urlopen(child_origin + "/" + name, timeout=5) as response: assert response.status == 200 if name == "csp-allow": assert response.headers["Content-Security-Policy"] == "frame-ancestors " + app_origin assert response.headers["X-Frame-Options"] == "DENY" response.read() requests.clear() print(json.dumps({"app_url": app_origin, "child_origin": child_origin, "wire_responses": 6}), flush=True) if "--serve" in sys.argv: Event().wait() except KeyboardInterrupt: pass finally: for server in (child, app): server.shutdown() server.server_close() for thread in threads: thread.join(timeout=5) 开放子页面应正常渲染;csp-block 被禁止祖先嵌入策略拒绝,xfo-block 被 DENY 拒绝。csp-allow 虽然同时发送 DENY,但强制祖先策略明确允许当前父来源,因此正常渲染。meta 页面也会渲染,因为该交付方式不能执行嵌入祖先指令;frame-src 页面同样渲染,因为它限制的是子页面接下来自己加载的框架,而不是正在验证的父子嵌入关系。 ...

九月 23, 2027 · 2 分钟 · XB Field Notes
服务器被诱导去访问内网地址。

SSRF 让服务器替你去开门

任何「从用户那里拿一个 URL、再由服务端去抓」的功能,都是在用服务器的身份、网络位置与凭据去跑这次抓取。缩略图生成、Webhook 测试、数据导入、PDF 渲染——只要目标来自请求,攻击者就不再是「自己去访问你的内网」,而是让你的服务器替他访问。 它比大多数注入类漏洞更值得优先处理,原因在可达性。服务器坐在边界之内:它能打开回环上的管理面板、能连 RFC 1918 网段里的服务、也能碰到云厂商提供实例凭据的那个 link-local 地址。攻击者与这张内网之间唯一的阻隔,就是那段决定「哪些 URL 可接受」的代码——而那段代码通常是一次字符串比较。 这次抓取带着服务器的权限 最经典的目标是实例元数据服务。在 AWS 上它监听 169.254.169.254,而 GET /latest/meta-data/iam/security-credentials/ 会返回实例所挂 IAM 角色的名字,紧接着就是该角色的临时凭据。实例的每一次 AWS API 调用用的都是这份凭据,所以一次打到元数据端点的 SSRF 不只是读到一个配置文件——它交出的是这台实例的云身份。OWASP 对内部 REST 接口、以及对那些「本来就只打算在内网可达」因而根本不带认证的 HTTP 数据库接口,记录的是同一种形态。 实验把两半都测了。对着一个本地替身,它用 127.0.0.1 的十六进制写法去打一个只检查 URL 字符串的校验,结果是 ALLOWED,抓取返回了一份凭据文档。接着它测加固:一台只开 v2 的实例对裸 GET 回 401,只有当请求带上 X-aws-ec2-metadata-token 时才放出元数据,而这个值攻击者必须先用 PUT /latest/api/token 拿到。一次只读一个 URL 的单发 SSRF 走不完这套握手。 ...

九月 5, 2027 · 1 分钟 · XB Field Notes
一个已登录的会话被第三方页面诱导去执行一次状态变更。

CSRF 检查请求到底来自哪里

浏览器在为任何页面发起的请求都会带上 Cookie。反 CSRF 令牌与 SameSite Cookie 能把每个状态变更与用户真正见过的那个页面绑定起来。 Cookie 无意识地跟随请求 浏览器判别是否带上 Cookie,依据的是目标域名与路径,而不是「这次请求是由用户先前的什么意图触发的」。于是,来自攻击者网站的页面,也能驱动受害者的浏览器发出一条改变状态的请求——改密、改资料——并且这条请求会带上已登录的会话 Cookie。服务器看到这样一条请求,会以为它来自合法用户。这正是关键:请求带了凭据,并不能证明这是用户的本意;浏览器只是在执行它的 Cookie 规则,而不是用户的意志。 令牌把动作与来源绑定 对策是让「改变状态的动作」带一个攻击者无法预料的参数。服务端为每个会话生成一个反 CSRF 令牌,在它自己的表单里带上,提交时再比对;攻击者的跨站页面既没有这个令牌,校验就会失败。配合严格的方法约束——不要让 GET 去改变状态——让「带令牌」成为改变状态的唯一途径。比对必须严格:缺失或不匹配就拒绝,而且绝不接受攻击者自带的 origin。 SameSite 是浏览器护栏 给 Cookie 加上 SameSite=Lax/Strict,浏览器就不会在跨站请求里带上它,于是攻击者触发的请求从一开始就少了一样东西。但它不是单独就够的保险:旧浏览器、某些上下文仍然会忽略这一属性,所以它要和反令牌叠加,而不是替代。更稳的姿态是两层并用:用 SameSite 让 Cookie 别成为攻击者的默认送信工具,再用双提交令牌,让即使一条带着 Cookie 的请求,也仍缺一个只有目标页面才有的秘密。 把 Origin 当作意图信号 对于改变状态的请求,服务端要先问浏览器这次请求从哪里开始。浏览器在跨站的表单提交与数据接口里带上来源头,顶层导航则回退到引用头;跨站页面的脚本写不了这两个头,因为它们属于浏览器禁止改写的字段。服务端只接受与本站完全一致的来源,包括协议主机与端口,改变状态的提交一旦缺失该信号就直接拒绝,而不是当作默认通过。现代浏览器的取元站点标记提供同一判断,跨站标记就是可疑起点。来源证据只能证明浏览器的说法,命令行工具随手就能伪造任意首部,所以仍要叠加会话令牌。与 Cookie 篇的分工正在这里:那篇讲存储与传输属性,这篇讲每次变更都要验证意图。 坏掉的样子 第一种是把状态变更放在获取方法后面。图片与链接就能触发,宽松的站点隔离仍会在顶层获取导航里带上会话,伪造的跳转带着真实凭据到达,服务端却误以为是用户本意。排查时只看是否登录成功没有意义,还要看这条改变状态的请求能不能被一行标签触发。 第二种是回退到显式跨站放行。旧浏览器直接忽略该属性,跨站照样带上会话;显式写成放行又没有加密限定,等同于把运送通道重新打开,令牌一旦缺席立刻被利用。更隐蔽的是开发环境为了联调用了放行,上线忘记改回,测试时一切正常,攻击者却多了一条现成的通道。 ...

九月 4, 2027 · 2 分钟 · XB Field Notes
Accept-Encoding 允许 gzip,Content-Encoding 标明所用编码,实际解压验证交付内容。

显式启用 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
相同 key 共享预算,改变 key 会选择另一份预算,空 key 则不被计数。

请求限流保护的是 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
代理明确处理客户端提供的头部后,应用才能正确解释连接上下文。

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

反向代理接受客户端连接,再单独请求上游应用。因此,应用套接字看到的对端是代理,较早客户端的信息则可能通过请求头传入。这些头部只有在部署明确谁可以设置、如何验证时才有可信含义。请求经过代理,不会自动让所有转发身份或协议声明变得可靠;需要把网络连接事实与请求携带的文本声明区分开。 坏掉的样子 客户端到达代理前就可以提供 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
原始请求 URI 被保留,规范化和内部跳转则改变用于路由的当前路径。

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